Plugin framework maturation, reports overhaul, theming, and USB frontend repair
Framework: - Per-plugin Alembic migration chains (ADR-008): every bundled plugin carries its own chain with a stamp-only anchor at the ownership cutover; new plugin schema lands in plugins/<name>/migrations/, never the core chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the shared alembic template (engine URL resolution) and taught the metadata filter to include FK-referenced core tables. - Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin; a disabled plugin's pages redirect to the dashboard via a cached, fail-open check against the new public GET /api/plugins/enabled. - get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute report cards; warranty and toner cards moved off the hardcoded list. Reports: - Hub grouped by category with search; inline reports render at the top, are URL-backed (?report=id, back-button and deep links work), expose their server-side filter params as controls, and export CSV. Warranty and Toner pages gained CSV export. - Deleted the dead legacy Warranty Status report (always-zero buckets from a retired column). Theming and fonts: - Inter (variable) bundled locally via @fontsource, replacing the Google Fonts Roboto import - air-gapped installs now render correctly; tables use tabular numerals. - Optional brand_primary_dark_color, brand_accent_color, brand_sidebar_color settings applied to CSS vars at bootstrap. USB frontend repair (views were reading a dead legacy shape): - List/detail/form and the employee profile USB panels remapped to the real API shape (device_id/device_desc/checkinoutlog); employee panels now use /usb/checkouts endpoints; external-mode /usb/checkouts/active honors the badge filter; dead client methods pruned. Also: warranties list page no longer requires login (matches app convention); collector doc rewritten with a GE-Enforce integration guide and paste-ready PowerShell reporter; ADR index and CHANGELOG updated. Verified: 323 tests pass, naming/style green, frontend builds, plugin migration dry-run green on scratch MySQL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,7 @@ import { useRouter } from 'vue-router'
|
||||
import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
@@ -127,6 +127,7 @@ const iconMap = {
|
||||
'bar-chart-3': BarChart3,
|
||||
'image': Image,
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
|
||||
@@ -75,24 +75,20 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Checked Out</th>
|
||||
<th>Purpose</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in usbDevices" :key="device.usbdeviceid">
|
||||
<tr v-for="checkout in usbDevices" :key="checkout.log_id">
|
||||
<td>
|
||||
<router-link :to="`/usb/${device.usbdeviceid}`">
|
||||
{{ device.displayname }}
|
||||
<router-link :to="`/usb/${checkout.device_id}`">
|
||||
{{ checkout.device_id }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ device.serialnumber }}</td>
|
||||
<td>{{ formatDate(device.checkoutdate) }}</td>
|
||||
<td>{{ device.checkoutpurpose || '-' }}</td>
|
||||
<td>{{ formatDate(checkout.timestamp) }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-small btn-success" @click="checkinDevice(device)">
|
||||
<button class="btn btn-small btn-success" @click="checkinDevice(checkout)">
|
||||
Check In
|
||||
</button>
|
||||
</td>
|
||||
@@ -115,20 +111,18 @@
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Checked Out</th>
|
||||
<th>Checked In</th>
|
||||
<th>Purpose</th>
|
||||
<th>Device Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in checkoutHistory" :key="record.usbcheckoutid">
|
||||
<tr v-for="record in checkoutHistory" :key="record.log_id">
|
||||
<td>
|
||||
<router-link :to="`/usb/${record.usbdeviceid}`">
|
||||
{{ record.devicename || `Device #${record.usbdeviceid}` }}
|
||||
<router-link :to="`/usb/${record.device_id}`">
|
||||
{{ record.device_id }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ formatDate(record.checkoutdate) }}</td>
|
||||
<td>{{ record.checkindate ? formatDate(record.checkindate) : 'Still out' }}</td>
|
||||
<td>{{ record.purpose || '-' }}</td>
|
||||
<td>{{ formatDate(record.timestamp) }}</td>
|
||||
<td>{{ record.device_status || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -213,6 +207,7 @@ async function loadRecognitions() {
|
||||
async function loadUSBDevices() {
|
||||
usbLoading.value = true
|
||||
try {
|
||||
// active check-out log rows for this badge
|
||||
const response = await usbApi.getUserCheckouts(route.params.sso)
|
||||
usbDevices.value = response.data.data || []
|
||||
} catch (err) {
|
||||
@@ -225,10 +220,9 @@ async function loadUSBDevices() {
|
||||
async function loadCheckoutHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
// Get user's checkout history (all past checkouts)
|
||||
const response = await usbApi.list({ user_id: route.params.sso, include_history: true })
|
||||
// Filter to only show returned items (not currently checked out)
|
||||
checkoutHistory.value = (response.data.data || []).filter(d => d.checkindate)
|
||||
// all check-out log rows for this badge, newest first
|
||||
const response = await usbApi.getUserCheckouts(route.params.sso, false)
|
||||
checkoutHistory.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading checkout history:', err)
|
||||
checkoutHistory.value = []
|
||||
@@ -237,11 +231,11 @@ async function loadCheckoutHistory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkinDevice(device) {
|
||||
if (!confirm(`Check in ${device.displayname}?`)) return
|
||||
async function checkinDevice(checkout) {
|
||||
if (!confirm(`Check in ${checkout.device_id}?`)) return
|
||||
|
||||
try {
|
||||
await usbApi.checkin(device.usbdeviceid)
|
||||
await usbApi.checkin(checkout.device_id, { badge: checkout.badge_number || route.params.sso })
|
||||
await loadUSBDevices()
|
||||
await loadCheckoutHistory()
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,288 +1,513 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Reports</h1>
|
||||
</div>
|
||||
|
||||
<div class="reports-grid">
|
||||
<div class="report-card card" @click="router.push('/reports/toner')">
|
||||
<h3>Toner Report</h3>
|
||||
<p>View printers with low or critical toner/supply levels</p>
|
||||
<span class="badge">Printers</span>
|
||||
</div>
|
||||
<div class="report-card card" @click="router.push('/reports/warranty')">
|
||||
<h3>Warranty Report</h3>
|
||||
<p>Assets bucketed by coverage: expired, expiring soon, active</p>
|
||||
<span class="badge">Warranty</span>
|
||||
</div>
|
||||
<div v-for="report in reports" :key="report.id" class="report-card card" @click="runReport(report)">
|
||||
<h3>{{ report.name }}</h3>
|
||||
<p>{{ report.description }}</p>
|
||||
<span class="badge">{{ report.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Results -->
|
||||
<div v-if="currentReport" class="report-results card">
|
||||
<div class="report-header">
|
||||
<h2>{{ currentReport.name }}</h2>
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<button class="btn btn-secondary" @click="clearReport">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading report...</div>
|
||||
<div v-else-if="reportData">
|
||||
<!-- Equipment by Type -->
|
||||
<div v-if="currentReport.id === 'equipment-by-type'" class="report-content">
|
||||
<p class="report-summary">Total: {{ reportData.total }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Equipment Type</th>
|
||||
<th>Description</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.equipmenttype">
|
||||
<td>{{ item.equipmenttype }}</td>
|
||||
<td>{{ item.description }}</td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Assets by Status -->
|
||||
<div v-else-if="currentReport.id === 'assets-by-status'" class="report-content">
|
||||
<p class="report-summary">Total: {{ reportData.total }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.status">
|
||||
<td>
|
||||
<span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span>
|
||||
</td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- KB Popularity -->
|
||||
<div v-else-if="currentReport.id === 'kb-popularity'" class="report-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Article</th>
|
||||
<th>Application</th>
|
||||
<th>Clicks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.linkid">
|
||||
<td>
|
||||
<a v-if="item.linkurl" :href="item.linkurl" target="_blank">{{ item.shortdescription }}</a>
|
||||
<span v-else>{{ item.shortdescription }}</span>
|
||||
</td>
|
||||
<td>{{ item.application || '-' }}</td>
|
||||
<td>{{ item.clicks }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Asset Inventory -->
|
||||
<div v-else-if="currentReport.id === 'asset-inventory'" class="report-content">
|
||||
<p class="report-summary">Total Assets: {{ reportData.total }}</p>
|
||||
|
||||
<h3>By Type</h3>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bytype" :key="item.type">
|
||||
<td>{{ item.type }}</td><td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>By Status</h3>
|
||||
<table>
|
||||
<thead><tr><th>Status</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bystatus" :key="item.status">
|
||||
<td><span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span></td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>By Location</h3>
|
||||
<table>
|
||||
<thead><tr><th>Location</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bylocation" :key="item.location">
|
||||
<td>{{ item.location }}</td><td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Generic fallback -->
|
||||
<div v-else class="report-content">
|
||||
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { reportsApi } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const reports = ref([])
|
||||
const currentReport = ref(null)
|
||||
const reportData = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadReports()
|
||||
})
|
||||
|
||||
async function loadReports() {
|
||||
try {
|
||||
const response = await reportsApi.list()
|
||||
reports.value = response.data.data.reports
|
||||
} catch (error) {
|
||||
console.error('Error loading reports:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function runReport(report) {
|
||||
// PC Relationships has its own dedicated page
|
||||
if (report.id === 'pc-relationships') {
|
||||
router.push('/reports/pc-relationships')
|
||||
return
|
||||
}
|
||||
|
||||
currentReport.value = report
|
||||
loading.value = true
|
||||
reportData.value = null
|
||||
|
||||
try {
|
||||
let response
|
||||
switch (report.id) {
|
||||
case 'equipment-by-type':
|
||||
response = await reportsApi.equipmentByType()
|
||||
break
|
||||
case 'assets-by-status':
|
||||
response = await reportsApi.assetsByStatus()
|
||||
break
|
||||
case 'kb-popularity':
|
||||
response = await reportsApi.kbPopularity()
|
||||
break
|
||||
case 'warranty-status':
|
||||
response = await reportsApi.warrantyStatus()
|
||||
break
|
||||
case 'software-compliance':
|
||||
response = await reportsApi.softwareCompliance()
|
||||
break
|
||||
case 'asset-inventory':
|
||||
response = await reportsApi.assetInventory()
|
||||
break
|
||||
default:
|
||||
console.error('Unknown report:', report.id)
|
||||
return
|
||||
}
|
||||
reportData.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error running report:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
if (!currentReport.value) return
|
||||
window.open(`/api/reports/${currentReport.value.id}?format=csv`, '_blank')
|
||||
}
|
||||
|
||||
function clearReport() {
|
||||
currentReport.value = null
|
||||
reportData.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reports-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.report-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.report-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-card p {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.report-results {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.report-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.report-content h3 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-content table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Reports</h1>
|
||||
</div>
|
||||
|
||||
<!-- Card grid hides while a report is open so results sit at the top. -->
|
||||
<template v-if="!currentReport">
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search reports..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- All report cards, grouped by category. Cards come from GET /api/reports,
|
||||
which merges core reports with plugin-contributed cards (get_reports). -->
|
||||
<div v-for="group in groupedReports" :key="group.category" class="report-group">
|
||||
<h2 class="group-title">{{ titleCase(group.category) }}</h2>
|
||||
<div class="reports-grid">
|
||||
<div
|
||||
v-for="report in group.reports"
|
||||
:key="report.id"
|
||||
class="report-card card"
|
||||
@click="openReport(report)"
|
||||
>
|
||||
<h3>{{ report.name }}</h3>
|
||||
<p>{{ report.description }}</p>
|
||||
<span class="badge">{{ report.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!groupedReports.length" class="empty-state">
|
||||
No reports match your search.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Inline report results (endpoint-backed reports) -->
|
||||
<div v-if="currentReport" class="report-results card">
|
||||
<div class="report-header">
|
||||
<h2>{{ currentReport.name }}</h2>
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<button class="btn btn-secondary" @click="clearReport">Back to Reports</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server-side filters this report accepts (see reportFilterFields) -->
|
||||
<div v-if="activeFilterFields.length" class="filters">
|
||||
<select
|
||||
v-if="activeFilterFields.includes('businessunit')"
|
||||
v-model="filterState.businessunitid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All business units</option>
|
||||
<option v-for="bu in filterOptions.businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="activeFilterFields.includes('assettype')"
|
||||
v-model="filterState.assettypeid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All asset types</option>
|
||||
<option v-for="at in filterOptions.assettypes" :key="at.assettypeid" :value="at.assettypeid">
|
||||
{{ titleCase(at.assettype) }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="activeFilterFields.includes('location')"
|
||||
v-model="filterState.locationid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All locations</option>
|
||||
<option v-for="loc in filterOptions.locations" :key="loc.locationid" :value="loc.locationid">
|
||||
{{ loc.locationname }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="activeFilterFields.includes('application')"
|
||||
v-model="filterState.appid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All required applications</option>
|
||||
<option v-for="app in filterOptions.applications" :key="app.appid" :value="app.appid">
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-if="activeFilterFields.includes('limit')"
|
||||
v-model.number="filterState.limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
class="form-control limit-input"
|
||||
placeholder="Limit (20)"
|
||||
@change="refreshReport"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading report...</div>
|
||||
<div v-else-if="reportData">
|
||||
<!-- Equipment by Type -->
|
||||
<div v-if="currentReport.id === 'equipment-by-type'" class="report-content">
|
||||
<p class="report-summary">Total: {{ reportData.total }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Equipment Type</th>
|
||||
<th>Description</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.equipmenttype">
|
||||
<td>{{ item.equipmenttype }}</td>
|
||||
<td>{{ item.description }}</td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Assets by Status -->
|
||||
<div v-else-if="currentReport.id === 'assets-by-status'" class="report-content">
|
||||
<p class="report-summary">Total: {{ reportData.total }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.status">
|
||||
<td>
|
||||
<span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span>
|
||||
</td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- KB Popularity -->
|
||||
<div v-else-if="currentReport.id === 'kb-popularity'" class="report-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Article</th>
|
||||
<th>Application</th>
|
||||
<th>Clicks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.linkid">
|
||||
<td>
|
||||
<a v-if="item.linkurl" :href="item.linkurl" target="_blank">{{ item.shortdescription }}</a>
|
||||
<span v-else>{{ item.shortdescription }}</span>
|
||||
</td>
|
||||
<td>{{ item.application || '-' }}</td>
|
||||
<td>{{ item.clicks }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Asset Inventory -->
|
||||
<div v-else-if="currentReport.id === 'asset-inventory'" class="report-content">
|
||||
<p class="report-summary">Total Assets: {{ reportData.total }}</p>
|
||||
|
||||
<h3>By Type</h3>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bytype" :key="item.type">
|
||||
<td>{{ item.type }}</td><td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>By Status</h3>
|
||||
<table>
|
||||
<thead><tr><th>Status</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bystatus" :key="item.status">
|
||||
<td><span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span></td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>By Location</h3>
|
||||
<table>
|
||||
<thead><tr><th>Location</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bylocation" :key="item.location">
|
||||
<td>{{ item.location }}</td><td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Generic fallback -->
|
||||
<div v-else class="report-content">
|
||||
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const reports = ref([])
|
||||
const search = ref('')
|
||||
const currentReport = ref(null)
|
||||
const reportData = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Preferred category order; anything else falls in after, sorted alphabetically.
|
||||
const categoryOrder = ['inventory', 'compliance', 'usage']
|
||||
|
||||
// server-side filters each inline report accepts (query params on its endpoint)
|
||||
const reportFilterFields = {
|
||||
'equipment-by-type': ['businessunit'],
|
||||
'assets-by-status': ['assettype', 'businessunit'],
|
||||
'asset-inventory': ['businessunit', 'location'],
|
||||
'kb-popularity': ['limit'],
|
||||
'software-compliance': ['application']
|
||||
}
|
||||
|
||||
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' })
|
||||
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
|
||||
|
||||
const activeFilterFields = computed(() =>
|
||||
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
|
||||
)
|
||||
|
||||
function resetFilters() {
|
||||
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' }
|
||||
}
|
||||
|
||||
// fetch dropdown options once, only for the fields the open report needs
|
||||
async function loadFilterOptions(fields) {
|
||||
try {
|
||||
if (fields.includes('businessunit') && !filterOptions.value.businessunits.length) {
|
||||
const response = await businessunitsApi.list({ perpage: 100 })
|
||||
filterOptions.value.businessunits = response.data.data || []
|
||||
}
|
||||
if (fields.includes('assettype') && !filterOptions.value.assettypes.length) {
|
||||
const response = await assetsApi.types.list()
|
||||
filterOptions.value.assettypes = response.data.data || []
|
||||
}
|
||||
if (fields.includes('location') && !filterOptions.value.locations.length) {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
filterOptions.value.locations = response.data.data || []
|
||||
}
|
||||
if (fields.includes('application') && !filterOptions.value.applications.length) {
|
||||
const response = await applicationsApi.list({ perpage: 100 })
|
||||
filterOptions.value.applications = response.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading filter options:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function filterParams() {
|
||||
const params = {}
|
||||
const state = filterState.value
|
||||
if (state.businessunitid) params.businessunitid = state.businessunitid
|
||||
if (state.assettypeid) params.assettypeid = state.assettypeid
|
||||
if (state.locationid) params.locationid = state.locationid
|
||||
if (state.appid) params.appid = state.appid
|
||||
if (state.limit) params.limit = state.limit
|
||||
return params
|
||||
}
|
||||
|
||||
function refreshReport() {
|
||||
if (currentReport.value) runReport(currentReport.value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadReports()
|
||||
// Honor a deep link / refresh with ?report=<id> already in the URL.
|
||||
applyQuery(route.query.report)
|
||||
})
|
||||
|
||||
async function loadReports() {
|
||||
try {
|
||||
const response = await reportsApi.list()
|
||||
reports.value = response.data.data.reports
|
||||
} catch (error) {
|
||||
console.error('Error loading reports:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function titleCase(text) {
|
||||
return String(text || '')
|
||||
.split(/[\s_-]+/)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// Filter by name/description/category, then group by category in the fixed order.
|
||||
const groupedReports = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
const matches = reports.value.filter(report => {
|
||||
if (!term) return true
|
||||
return (
|
||||
(report.name || '').toLowerCase().includes(term) ||
|
||||
(report.description || '').toLowerCase().includes(term) ||
|
||||
(report.category || '').toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
|
||||
const byCategory = {}
|
||||
for (const report of matches) {
|
||||
const category = report.category || 'other'
|
||||
if (!byCategory[category]) byCategory[category] = []
|
||||
byCategory[category].push(report)
|
||||
}
|
||||
|
||||
const categories = Object.keys(byCategory).sort((a, b) => {
|
||||
const ai = categoryOrder.indexOf(a)
|
||||
const bi = categoryOrder.indexOf(b)
|
||||
if (ai !== -1 || bi !== -1) {
|
||||
return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi)
|
||||
}
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
return categories.map(category => ({ category, reports: byCategory[category] }))
|
||||
})
|
||||
|
||||
function openReport(report) {
|
||||
// Cards with a dedicated frontend page carry a route; navigate to it.
|
||||
if (report.route) {
|
||||
router.push(report.route)
|
||||
return
|
||||
}
|
||||
// PC Relationships has its own dedicated page.
|
||||
if (report.id === 'pc-relationships') {
|
||||
router.push('/reports/pc-relationships')
|
||||
return
|
||||
}
|
||||
// Inline reports live in the URL query so browser back/forward works:
|
||||
// back pops the query and returns to the card grid.
|
||||
router.push({ query: { report: report.id } })
|
||||
}
|
||||
|
||||
// The query param is the source of truth for the open inline report.
|
||||
watch(() => route.query.report, applyQuery)
|
||||
|
||||
function applyQuery(id) {
|
||||
if (!id) {
|
||||
currentReport.value = null
|
||||
reportData.value = null
|
||||
return
|
||||
}
|
||||
const report = reports.value.find(r => r.id === id)
|
||||
if (report) {
|
||||
resetFilters()
|
||||
loadFilterOptions(reportFilterFields[id] || [])
|
||||
runReport(report)
|
||||
}
|
||||
}
|
||||
|
||||
async function runReport(report) {
|
||||
currentReport.value = report
|
||||
loading.value = true
|
||||
reportData.value = null
|
||||
window.scrollTo(0, 0)
|
||||
|
||||
const params = filterParams()
|
||||
try {
|
||||
let response
|
||||
switch (report.id) {
|
||||
case 'equipment-by-type':
|
||||
response = await reportsApi.equipmentByType(params)
|
||||
break
|
||||
case 'assets-by-status':
|
||||
response = await reportsApi.assetsByStatus(params)
|
||||
break
|
||||
case 'kb-popularity':
|
||||
response = await reportsApi.kbPopularity(params)
|
||||
break
|
||||
case 'software-compliance':
|
||||
response = await reportsApi.softwareCompliance(params)
|
||||
break
|
||||
case 'asset-inventory':
|
||||
response = await reportsApi.assetInventory(params)
|
||||
break
|
||||
default:
|
||||
console.error('Unknown report:', report.id)
|
||||
return
|
||||
}
|
||||
reportData.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error running report:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
if (!currentReport.value) return
|
||||
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
|
||||
window.open(`/api/reports/${currentReport.value.id}?${params}`, '_blank')
|
||||
}
|
||||
|
||||
function clearReport() {
|
||||
// Drop the query param; the watcher clears the panel state.
|
||||
router.push({ query: {} })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reports-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.report-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.report-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-card p {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-light);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.report-results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.report-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.report-content h3 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-content table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.limit-input {
|
||||
max-width: 140px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="page-header">
|
||||
<h1>Toner Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,6 +119,26 @@ const filteredPrinters = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function exportCSV() {
|
||||
// one row per supply, honoring the active filter
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const rows = [['printer', 'assetnumber', 'location', 'ipaddress', 'supply', 'level', 'status']]
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
rows.push([
|
||||
printer.printername || '', printer.assetnumber || '', printer.location || '',
|
||||
printer.ipaddress || '', supply.name || '', supply.level, supply.status || ''
|
||||
])
|
||||
}
|
||||
}
|
||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||
link.download = 'toner_report.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printersApi.lowSupplies()
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Warranty Report</h1>
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
@@ -71,6 +74,26 @@ function assetLink(a) {
|
||||
return (map[a.assettypename] || '/assets/') + a.assetid
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
// one row per warranty, covered assets joined with ;
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const rows = [['bucket', 'vendor', 'servicelevel', 'enddate', 'assets']]
|
||||
for (const b of bucketOrder) {
|
||||
for (const w of buckets.value[b.key] || []) {
|
||||
rows.push([
|
||||
b.label, w.vendor || '', w.servicelevel || '', w.enddate || '',
|
||||
(w.assets || []).map(a => a.assetnumber).join('; ')
|
||||
])
|
||||
}
|
||||
}
|
||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||
link.download = 'warranty_report.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await warrantyApi.report()
|
||||
@@ -100,5 +123,6 @@ onMounted(async () => {
|
||||
background: var(--bg); border-radius: 12px; font-size: 0.8rem; text-decoration: none; color: var(--text);
|
||||
}
|
||||
.muted { color: var(--text-light); }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -292,6 +292,75 @@
|
||||
<small class="input-hint">Hex color for the primary accent. Leave blank to use the built-in palette.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Primary hover color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_primary_dark_color || '#000000'"
|
||||
@input="settings.brand_primary_dark_color = $event.target.value"
|
||||
@change="saveSetting('brand_primary_dark_color', settings.brand_primary_dark_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_primary_dark_color"
|
||||
placeholder="(blank = derived from primary)"
|
||||
@blur="saveSetting('brand_primary_dark_color', settings.brand_primary_dark_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Hover/active shade of the primary color. Leave blank to auto-darken the primary color ~15%.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Accent color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_accent_color || '#000000'"
|
||||
@input="settings.brand_accent_color = $event.target.value"
|
||||
@change="saveSetting('brand_accent_color', settings.brand_accent_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_accent_color"
|
||||
placeholder="(blank = built-in)"
|
||||
@blur="saveSetting('brand_accent_color', settings.brand_accent_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Accent for secondary buttons and badges. Leave blank to use the built-in palette.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Sidebar color</span>
|
||||
<div class="color-input-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.brand_sidebar_color || '#000000'"
|
||||
@input="settings.brand_sidebar_color = $event.target.value"
|
||||
@change="saveSetting('brand_sidebar_color', settings.brand_sidebar_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.brand_sidebar_color"
|
||||
placeholder="(blank = built-in)"
|
||||
@blur="saveSetting('brand_sidebar_color', settings.brand_sidebar_color)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</div>
|
||||
<small class="input-hint">Sidebar background color. Leave blank to use the built-in palette.</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -925,6 +994,9 @@ const settings = reactive({
|
||||
badge_logo: '',
|
||||
site_favicon: '',
|
||||
brand_primary_color: '',
|
||||
brand_primary_dark_color: '',
|
||||
brand_accent_color: '',
|
||||
brand_sidebar_color: '',
|
||||
// Printing and labels
|
||||
qr_target_printer: '',
|
||||
qr_target_usb: '',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shared settings navigation catalog.
|
||||
// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the
|
||||
// grouping lives in one place.
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact } from 'lucide-vue-next'
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Ruler } from 'lucide-vue-next'
|
||||
|
||||
export const settingsGroups = [
|
||||
{
|
||||
@@ -46,6 +46,12 @@ export const settingsGroups = [
|
||||
{ to: '/settings/machinetypes', icon: Monitor, title: 'Machine Types', description: 'Manage machine type categories' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Measuring Tools',
|
||||
cards: [
|
||||
{ to: '/settings/measuringtooltypes', icon: Ruler, title: 'Measuring Tool Types', description: 'Manage measuring-tool subtypes (caliper, micrometer, thread gage...) + map colors' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Locations & Organization',
|
||||
cards: [
|
||||
|
||||
@@ -14,25 +14,21 @@
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ device.alias || device.machinenumber }}</h1>
|
||||
<h1>{{ device.device_desc || device.device_id }}</h1>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg" :class="device.ischeckedout ? 'badge-warning' : 'badge-success'">
|
||||
{{ device.ischeckedout ? 'Checked Out' : 'Available' }}
|
||||
<span class="badge badge-lg" :class="device.status === 'checked-out' ? 'badge-warning' : 'badge-success'">
|
||||
{{ device.status === 'checked-out' ? 'Checked Out' : 'Available' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="device.serialnumber">
|
||||
<div class="hero-detail" v-if="device.device_id">
|
||||
<span class="hero-detail-label">Serial Number</span>
|
||||
<span class="hero-detail-value mono">{{ device.serialnumber }}</span>
|
||||
<span class="hero-detail-value mono">{{ device.device_id }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="device.vendorname">
|
||||
<span class="hero-detail-label">Vendor</span>
|
||||
<span class="hero-detail-value">{{ device.vendorname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="device.modelname">
|
||||
<span class="hero-detail-label">Model</span>
|
||||
<span class="hero-detail-value">{{ device.modelname }}</span>
|
||||
<div class="hero-detail" v-if="device.locker_location">
|
||||
<span class="hero-detail-label">Locker Location</span>
|
||||
<span class="hero-detail-value">{{ device.locker_location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +37,7 @@
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
v-if="!device.ischeckedout"
|
||||
v-if="device.status !== 'checked-out'"
|
||||
class="btn btn-primary btn-lg"
|
||||
@click="openCheckoutModal"
|
||||
>
|
||||
@@ -57,31 +53,27 @@
|
||||
</div>
|
||||
|
||||
<!-- Current Checkout Info -->
|
||||
<div class="section-card" v-if="device.currentcheckout">
|
||||
<div class="section-card" v-if="device.status === 'checked-out'">
|
||||
<h3 class="section-title">Current Checkout</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Checked Out By</span>
|
||||
<span class="info-value">{{ device.currentcheckout.sso }}</span>
|
||||
<span class="info-value">{{ device.current_holder_name || device.current_holder }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Checkout Time</span>
|
||||
<span class="info-value">{{ formatDate(device.currentcheckout.checkouttime) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="device.currentcheckout.checkoutreason">
|
||||
<span class="info-label">Reason</span>
|
||||
<span class="info-value">{{ device.currentcheckout.checkoutreason }}</span>
|
||||
<span class="info-value">{{ formatDate(device.checkout_time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkout History -->
|
||||
<!-- Check-in/out Log -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Checkout History</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="!device.checkouthistory?.length" class="empty-state">
|
||||
<div v-if="!device.checkinoutlog?.length" class="empty-state">
|
||||
No checkout history
|
||||
</div>
|
||||
|
||||
@@ -89,23 +81,21 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User SSO</th>
|
||||
<th>Checkout Time</th>
|
||||
<th>Check-in Time</th>
|
||||
<th>Wiped</th>
|
||||
<th>Reason</th>
|
||||
<th>Action</th>
|
||||
<th>User</th>
|
||||
<th>Time</th>
|
||||
<th>Sanitized</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="checkout in device.checkouthistory" :key="checkout.checkoutid">
|
||||
<td>{{ checkout.sso }}</td>
|
||||
<td>{{ formatDate(checkout.checkouttime) }}</td>
|
||||
<td>{{ checkout.checkintime ? formatDate(checkout.checkintime) : 'Still out' }}</td>
|
||||
<tr v-for="entry in device.checkinoutlog" :key="entry.log_id">
|
||||
<td>{{ entry.action === 'check-in' ? 'Check In' : 'Check Out' }}</td>
|
||||
<td>{{ entry.badge_name || entry.badge_number }}</td>
|
||||
<td>{{ formatDate(entry.timestamp) }}</td>
|
||||
<td>
|
||||
<span v-if="checkout.checkintime">{{ checkout.waswiped ? 'Yes' : 'No' }}</span>
|
||||
<span v-if="entry.action === 'check-in'">{{ entry.sanitized ? 'Yes' : 'No' }}</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ checkout.checkoutreason || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -215,7 +205,11 @@ function closeModals() {
|
||||
|
||||
async function doCheckout() {
|
||||
try {
|
||||
await usbApi.checkout(device.value.machineid, checkoutForm.value)
|
||||
// The SSO the operator enters is the badge; API resolves the name.
|
||||
await usbApi.checkout(device.value.device_id, {
|
||||
badge: checkoutForm.value.sso,
|
||||
reason: checkoutForm.value.reason
|
||||
})
|
||||
closeModals()
|
||||
await loadDevice()
|
||||
} catch (error) {
|
||||
@@ -226,7 +220,12 @@ async function doCheckout() {
|
||||
|
||||
async function doCheckin() {
|
||||
try {
|
||||
await usbApi.checkin(device.value.machineid, checkinForm.value)
|
||||
// Check the current holder back in; sanitized mirrors the wiped checkbox.
|
||||
await usbApi.checkin(device.value.device_id, {
|
||||
badge: device.value.current_holder,
|
||||
sanitized: checkinForm.value.waswiped,
|
||||
notes: checkinForm.value.notes
|
||||
})
|
||||
closeModals()
|
||||
await loadDevice()
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,102 +9,42 @@
|
||||
|
||||
<form v-else @submit.prevent="saveDevice">
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number *</label>
|
||||
<label for="device_id">Serial Number *</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
id="device_id"
|
||||
v-model="form.device_id"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
maxlength="100"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="displayname">Display Name *</label>
|
||||
<label for="device_desc">Description</label>
|
||||
<input
|
||||
id="displayname"
|
||||
v-model="form.displayname"
|
||||
id="device_desc"
|
||||
v-model="form.device_desc"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
maxlength="100"
|
||||
placeholder="e.g., USB Flash Drive #1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="label">Label</label>
|
||||
<label for="locker_location">Locker Location</label>
|
||||
<input
|
||||
id="label"
|
||||
v-model="form.label"
|
||||
id="locker_location"
|
||||
v-model="form.locker_location"
|
||||
type="text"
|
||||
class="form-control"
|
||||
maxlength="100"
|
||||
placeholder="Physical label on device"
|
||||
maxlength="200"
|
||||
placeholder="Where the device is stored"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="usbtypeid">Device Type</label>
|
||||
<select
|
||||
id="usbtypeid"
|
||||
v-model="form.usbtypeid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">-- Select Type --</option>
|
||||
<option
|
||||
v-for="type in types"
|
||||
:key="type.usbtypeid"
|
||||
:value="type.usbtypeid"
|
||||
>
|
||||
{{ type.typename }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="capacitygb">Capacity (GB)</label>
|
||||
<input
|
||||
id="capacitygb"
|
||||
v-model.number="form.capacitygb"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor</label>
|
||||
<select
|
||||
id="vendorid"
|
||||
v-model="form.vendorid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">-- Select Vendor --</option>
|
||||
<option
|
||||
v-for="vendor in vendors"
|
||||
:key="vendor.vendorid"
|
||||
:value="vendor.vendorid"
|
||||
>
|
||||
{{ vendor.vendorname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-actions">
|
||||
@@ -121,7 +61,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usbApi, vendorsApi } from '@/api'
|
||||
import { usbApi } from '@/api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -133,42 +73,23 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const types = ref([])
|
||||
const vendors = ref([])
|
||||
|
||||
const form = ref({
|
||||
serialnumber: '',
|
||||
displayname: '',
|
||||
label: '',
|
||||
usbtypeid: '',
|
||||
capacitygb: null,
|
||||
vendorid: '',
|
||||
notes: ''
|
||||
device_id: '',
|
||||
device_desc: '',
|
||||
locker_location: ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load types and vendors
|
||||
const [typesRes, vendorsRes] = await Promise.all([
|
||||
usbApi.types.list(),
|
||||
vendorsApi.list({ perpage: 1000 })
|
||||
])
|
||||
types.value = typesRes.data.data || []
|
||||
vendors.value = vendorsRes.data.data || []
|
||||
|
||||
// Load device if editing
|
||||
if (isEdit.value) {
|
||||
const response = await usbApi.get(route.params.id)
|
||||
const device = response.data.data
|
||||
|
||||
form.value = {
|
||||
serialnumber: device.serialnumber || '',
|
||||
displayname: device.displayname || '',
|
||||
label: device.label || '',
|
||||
usbtypeid: device.usbtypeid || '',
|
||||
capacitygb: device.capacitygb || null,
|
||||
vendorid: device.vendorid || '',
|
||||
notes: device.notes || ''
|
||||
device_id: device.device_id || '',
|
||||
device_desc: device.device_desc || '',
|
||||
locker_location: device.locker_location || ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -184,19 +105,16 @@ async function saveDevice() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// Create needs device_id; update keys off the path id so it is omitted.
|
||||
const data = {
|
||||
serialnumber: form.value.serialnumber,
|
||||
displayname: form.value.displayname,
|
||||
label: form.value.label || null,
|
||||
usbtypeid: form.value.usbtypeid || null,
|
||||
capacitygb: form.value.capacitygb || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
notes: form.value.notes || null
|
||||
device_desc: form.value.device_desc || null,
|
||||
locker_location: form.value.locker_location || null
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await usbApi.update(route.params.id, data)
|
||||
} else {
|
||||
data.device_id = form.value.device_id
|
||||
await usbApi.create(data)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,27 +36,27 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in devices" :key="device.machineid">
|
||||
<tr v-for="device in devices" :key="device.device_id">
|
||||
<td>
|
||||
<strong>{{ device.alias || device.machinenumber }}</strong>
|
||||
<div v-if="device.modelname" class="text-muted">{{ device.modelname }}</div>
|
||||
<strong>{{ device.device_desc || device.device_id }}</strong>
|
||||
<div v-if="device.locker_location" class="text-muted">{{ device.locker_location }}</div>
|
||||
</td>
|
||||
<td class="mono">{{ device.serialnumber || '-' }}</td>
|
||||
<td class="mono">{{ device.device_id || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="device.ischeckedout ? 'badge-warning' : 'badge-success'">
|
||||
{{ device.ischeckedout ? 'Checked Out' : 'Available' }}
|
||||
<span class="badge" :class="device.status === 'checked-out' ? 'badge-warning' : 'badge-success'">
|
||||
{{ device.status === 'checked-out' ? 'Checked Out' : 'Available' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="device.currentcheckout">
|
||||
{{ device.currentcheckout.checkoutname || device.currentcheckout.sso }}
|
||||
<div class="text-muted">{{ formatDate(device.currentcheckout.checkouttime) }}</div>
|
||||
<template v-if="device.status === 'checked-out'">
|
||||
{{ device.current_holder_name || device.current_holder }}
|
||||
<div class="text-muted">{{ formatDate(device.checkout_time) }}</div>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
v-if="!device.ischeckedout"
|
||||
v-if="device.status !== 'checked-out'"
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="openCheckoutModal(device)"
|
||||
>
|
||||
@@ -70,7 +70,7 @@
|
||||
Check In
|
||||
</button>
|
||||
<router-link
|
||||
:to="`/usb/${device.machineid}`"
|
||||
:to="`/usb/${device.device_id}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
@@ -102,7 +102,7 @@
|
||||
<template #header>
|
||||
<h3>Checkout USB Device</h3>
|
||||
</template>
|
||||
<p>Checking out: <strong>{{ selectedDevice?.alias || selectedDevice?.machinenumber }}</strong></p>
|
||||
<p>Checking out: <strong>{{ selectedDevice?.device_desc || selectedDevice?.device_id }}</strong></p>
|
||||
<div class="form-group">
|
||||
<label>Employee *</label>
|
||||
<EmployeeSearch v-model="selectedEmployee" placeholder="Search by name..." />
|
||||
@@ -122,7 +122,7 @@
|
||||
<template #header>
|
||||
<h3>Check In USB Device</h3>
|
||||
</template>
|
||||
<p>Checking in: <strong>{{ selectedDevice?.alias || selectedDevice?.machinenumber }}</strong></p>
|
||||
<p>Checking in: <strong>{{ selectedDevice?.device_desc || selectedDevice?.device_id }}</strong></p>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="checkinForm.waswiped" />
|
||||
@@ -235,9 +235,9 @@ async function doCheckout() {
|
||||
if (!selectedEmployee.value) return
|
||||
|
||||
try {
|
||||
await usbApi.checkout(selectedDevice.value.machineid, {
|
||||
sso: selectedEmployee.value.sso,
|
||||
name: selectedEmployee.value.name,
|
||||
// API resolves the holder name from the badge; send badge = employee SSO.
|
||||
await usbApi.checkout(selectedDevice.value.device_id, {
|
||||
badge: selectedEmployee.value.sso,
|
||||
reason: checkoutForm.value.reason
|
||||
})
|
||||
closeModals()
|
||||
@@ -250,7 +250,12 @@ async function doCheckout() {
|
||||
|
||||
async function doCheckin() {
|
||||
try {
|
||||
await usbApi.checkin(selectedDevice.value.machineid, checkinForm.value)
|
||||
// Check the current holder back in; sanitized mirrors the wiped checkbox.
|
||||
await usbApi.checkin(selectedDevice.value.device_id, {
|
||||
badge: selectedDevice.value.current_holder,
|
||||
sanitized: checkinForm.value.waswiped,
|
||||
notes: checkinForm.value.notes
|
||||
})
|
||||
closeModals()
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user