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:
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "shopdb-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shopdb-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
@@ -522,6 +523,14 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/core": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/vue3": "^6.1.20",
|
||||
|
||||
@@ -646,9 +646,6 @@ export const usbApi = {
|
||||
update(id, data) {
|
||||
return api.put(`/usb/${id}`, data)
|
||||
},
|
||||
delete(id) {
|
||||
return api.delete(`/usb/${id}`)
|
||||
},
|
||||
checkout(id, data) {
|
||||
return api.post(`/usb/${id}/checkout`, data)
|
||||
},
|
||||
@@ -658,25 +655,10 @@ export const usbApi = {
|
||||
getHistory(id, params = {}) {
|
||||
return api.get(`/usb/${id}/history`, { params })
|
||||
},
|
||||
getAvailable() {
|
||||
return api.get('/usb/available')
|
||||
},
|
||||
getCheckedOut() {
|
||||
return api.get('/usb/checkedout')
|
||||
},
|
||||
getUserCheckouts(userId) {
|
||||
return api.get(`/usb/user/${userId}`)
|
||||
},
|
||||
dashboardSummary() {
|
||||
return api.get('/usb/dashboard/summary')
|
||||
},
|
||||
types: {
|
||||
list() {
|
||||
return api.get('/usb/types')
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/usb/types', data)
|
||||
}
|
||||
// check-out log rows for one badge; activeonly = only currently-held devices
|
||||
getUserCheckouts(badge, activeonly = true) {
|
||||
const path = activeonly ? '/usb/checkouts/active' : '/usb/checkouts'
|
||||
return api.get(path, { params: { badge } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,9 +676,6 @@ export const reportsApi = {
|
||||
kbPopularity(params = {}) {
|
||||
return api.get('/reports/kb-popularity', { params })
|
||||
},
|
||||
warrantyStatus(params = {}) {
|
||||
return api.get('/reports/warranty-status', { params })
|
||||
},
|
||||
softwareCompliance(params = {}) {
|
||||
return api.get('/reports/software-compliance', { params })
|
||||
},
|
||||
@@ -767,6 +746,10 @@ export const pluginsApi = {
|
||||
list() {
|
||||
return api.get('/plugins')
|
||||
},
|
||||
// Flat array of enabled plugin names. jwt-optional, safe for kiosk routes.
|
||||
enabled() {
|
||||
return api.get('/plugins/enabled')
|
||||
},
|
||||
setEnabled(name, enabled) {
|
||||
return api.put(`/plugins/${name}`, { enabled })
|
||||
}
|
||||
@@ -1026,3 +1009,46 @@ export const warrantyApi = {
|
||||
return api.get('/warranty/report')
|
||||
}
|
||||
}
|
||||
|
||||
// Measuring tools API (plugin)
|
||||
export const measuringtoolsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/measuringtools', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/measuringtools/${id}`)
|
||||
},
|
||||
getByAsset(assetid) {
|
||||
return api.get(`/measuringtools/by-asset/${assetid}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/measuringtools', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/measuringtools/${id}`, data)
|
||||
},
|
||||
remove(id) {
|
||||
return api.delete(`/measuringtools/${id}`)
|
||||
},
|
||||
calibrationReport() {
|
||||
return api.get('/measuringtools/report/calibration')
|
||||
},
|
||||
// Measuring-tool types
|
||||
types: {
|
||||
list(params = {}) {
|
||||
return api.get('/measuringtools/types', { params })
|
||||
},
|
||||
get(id) {
|
||||
return api.get(`/measuringtools/types/${id}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/measuringtools/types', data)
|
||||
},
|
||||
update(id, data) {
|
||||
return api.put(`/measuringtools/types/${id}`, data)
|
||||
},
|
||||
remove(id) {
|
||||
return api.delete(`/measuringtools/types/${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Reset and base styles */
|
||||
@import url('https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap');
|
||||
/* Inter is bundled locally via @fontsource-variable/inter (imported in main.js) */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -95,7 +95,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-family: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
@@ -370,6 +370,8 @@ th, td {
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
border-top: 1px solid var(--border);
|
||||
/* line up digits in columns (Inter supports tabular figures) */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Cap a long free-text column (e.g. Description) so it truncates with an
|
||||
|
||||
42
frontend/src/composables/enabledPlugins.js
Normal file
42
frontend/src/composables/enabledPlugins.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Enabled-plugin gating for the router. Fetches the enabled plugin-name list
|
||||
// ONCE (cached promise) so a disabled backend plugin's frontend routes become
|
||||
// unreachable by direct URL. Fail-open: any fetch error treats every plugin as
|
||||
// enabled, so an API blip can never brick navigation.
|
||||
import { pluginsApi } from '../api'
|
||||
|
||||
let enabledPromise = null
|
||||
let enabledSet = null // resolved Set<string>, or null while pending / on failure
|
||||
|
||||
// Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this
|
||||
// works on unauthenticated kiosk routes (e.g. /tv) too.
|
||||
export function loadEnabledPlugins() {
|
||||
if (!enabledPromise) {
|
||||
enabledPromise = pluginsApi.enabled()
|
||||
.then(response => {
|
||||
const names = response?.data?.data
|
||||
// Only trust a real array; anything else -> fail open (null).
|
||||
enabledSet = Array.isArray(names) ? new Set(names) : null
|
||||
return enabledSet
|
||||
})
|
||||
.catch(() => {
|
||||
// Fail open: leave enabledSet null so isPluginEnabled returns true.
|
||||
enabledSet = null
|
||||
return null
|
||||
})
|
||||
}
|
||||
return enabledPromise
|
||||
}
|
||||
|
||||
// True if the plugin is enabled OR the list is unknown (fail-open). A null
|
||||
// enabledSet means we never got a trustworthy answer, so allow everything.
|
||||
export function isPluginEnabled(name) {
|
||||
if (!name) return true
|
||||
if (enabledSet === null) return true
|
||||
return enabledSet.has(name)
|
||||
}
|
||||
|
||||
// Test/hot-reload hook: forget the cached result so the next call refetches.
|
||||
export function resetEnabledPlugins() {
|
||||
enabledPromise = null
|
||||
enabledSet = null
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
// Locally-bundled Inter (air-gap safe, no Google Fonts fetch)
|
||||
import '@fontsource-variable/inter'
|
||||
|
||||
// Initialize theme on app load
|
||||
import './stores/theme'
|
||||
import { applyBranding } from './utils/siteSettings'
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useAuthStore } from '../stores/auth'
|
||||
import AppLayout from '../views/AppLayout.vue'
|
||||
import SettingsLayout from '../views/settings/SettingsLayout.vue'
|
||||
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState'
|
||||
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
|
||||
import { useToast } from '../composables/toast'
|
||||
|
||||
// Auto-discover all route modules from routes/ directory
|
||||
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
|
||||
@@ -59,7 +61,8 @@ const routes = [
|
||||
{
|
||||
path: '/tv',
|
||||
name: 'tv',
|
||||
component: () => import('../views/TVDashboard.vue')
|
||||
component: () => import('../views/TVDashboard.vue'),
|
||||
meta: { plugin: 'slides' }
|
||||
},
|
||||
// Print pages (standalone, no sidebar/header)
|
||||
{
|
||||
@@ -70,17 +73,20 @@ const routes = [
|
||||
{
|
||||
path: '/print/printer-qr',
|
||||
name: 'print-printer-qr-batch',
|
||||
component: () => import('../views/print/PrinterQRBatch.vue')
|
||||
component: () => import('../views/print/PrinterQRBatch.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: '/print/printer-qr/:id',
|
||||
name: 'print-printer-qr-single',
|
||||
component: () => import('../views/print/PrinterQRSingle.vue')
|
||||
component: () => import('../views/print/PrinterQRSingle.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: '/print/usb-labels',
|
||||
name: 'print-usb-labels',
|
||||
component: () => import('../views/print/USBLabelBatch.vue')
|
||||
component: () => import('../views/print/USBLabelBatch.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
@@ -108,6 +114,17 @@ router.beforeEach(async (to, from, next) => {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
// Plugin gating: a disabled backend plugin's frontend routes are dead ends.
|
||||
// The enabled list is fetched once and cached; fail-open on error so a blip
|
||||
// cannot brick navigation. Works unauthenticated (endpoint is jwt-optional).
|
||||
if (to.meta.plugin) {
|
||||
await loadEnabledPlugins()
|
||||
if (!isPluginEnabled(to.meta.plugin)) {
|
||||
useToast().info(`The ${to.meta.plugin} feature is not enabled.`)
|
||||
return next('/')
|
||||
}
|
||||
}
|
||||
|
||||
// First-run: steer a fresh admin into the setup wizard (once, until finished).
|
||||
if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') {
|
||||
if (!isSetupLoaded()) {
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
/**
|
||||
* Computers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'pcs',
|
||||
name: 'pcs',
|
||||
component: () => import('../../views/pcs/PCsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'pcs/new',
|
||||
name: 'pc-new',
|
||||
component: () => import('../../views/pcs/PCForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id',
|
||||
name: 'pc-detail',
|
||||
component: () => import('../../views/pcs/PCDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id/edit',
|
||||
name: 'pc-edit',
|
||||
component: () => import('../../views/pcs/PCForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// Computer-specific settings
|
||||
{
|
||||
path: 'settings/pctypes',
|
||||
name: 'pctypes',
|
||||
component: () => import('../../views/settings/PCTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/operatingsystems',
|
||||
name: 'operatingsystems',
|
||||
component: () => import('../../views/settings/OperatingSystemsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/accessprotocols',
|
||||
name: 'access-protocols',
|
||||
component: () => import('../../views/settings/AccessProtocolsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Computers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'pcs',
|
||||
name: 'pcs',
|
||||
component: () => import('../../views/pcs/PCsList.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/new',
|
||||
name: 'pc-new',
|
||||
component: () => import('../../views/pcs/PCForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id',
|
||||
name: 'pc-detail',
|
||||
component: () => import('../../views/pcs/PCDetail.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id/edit',
|
||||
name: 'pc-edit',
|
||||
component: () => import('../../views/pcs/PCForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
// Computer-specific settings
|
||||
{
|
||||
path: 'settings/pctypes',
|
||||
name: 'pctypes',
|
||||
component: () => import('../../views/settings/PCTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/operatingsystems',
|
||||
name: 'operatingsystems',
|
||||
component: () => import('../../views/settings/OperatingSystemsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/accessprotocols',
|
||||
name: 'access-protocols',
|
||||
component: () => import('../../views/settings/AccessProtocolsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -31,17 +31,20 @@ export default [
|
||||
{
|
||||
path: 'reports/pc-relationships',
|
||||
name: 'report-pc-relationships',
|
||||
component: () => import('../../views/reports/PCRelationshipsReport.vue')
|
||||
component: () => import('../../views/reports/PCRelationshipsReport.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'reports/toner',
|
||||
name: 'toner-report',
|
||||
component: () => import('../../views/reports/TonerReport.vue')
|
||||
component: () => import('../../views/reports/TonerReport.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'employees/:sso',
|
||||
name: 'employee-detail',
|
||||
component: () => import('../../views/employees/EmployeeDetail.vue')
|
||||
component: () => import('../../views/employees/EmployeeDetail.vue'),
|
||||
meta: { plugin: 'employees' }
|
||||
},
|
||||
// Settings
|
||||
{
|
||||
@@ -102,7 +105,7 @@ export default [
|
||||
path: 'settings/slides',
|
||||
name: 'slide-manager',
|
||||
component: () => import('../../views/settings/SlideManager.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'slides' }
|
||||
},
|
||||
{
|
||||
path: 'settings/equipmenttypes',
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* Equipment plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'machines',
|
||||
name: 'machines',
|
||||
component: () => import('../../views/machines/MachinesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'machines/new',
|
||||
name: 'machine-new',
|
||||
component: () => import('../../views/machines/MachineForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'machines/:id',
|
||||
name: 'machine-detail',
|
||||
component: () => import('../../views/machines/MachineDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'machines/:id/edit',
|
||||
name: 'machine-edit',
|
||||
component: () => import('../../views/machines/MachineForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Equipment plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'machines',
|
||||
name: 'machines',
|
||||
component: () => import('../../views/machines/MachinesList.vue'),
|
||||
meta: { plugin: 'equipment' }
|
||||
},
|
||||
{
|
||||
path: 'machines/new',
|
||||
name: 'machine-new',
|
||||
component: () => import('../../views/machines/MachineForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'equipment' }
|
||||
},
|
||||
{
|
||||
path: 'machines/:id',
|
||||
name: 'machine-detail',
|
||||
component: () => import('../../views/machines/MachineDetail.vue'),
|
||||
meta: { plugin: 'equipment' }
|
||||
},
|
||||
{
|
||||
path: 'machines/:id/edit',
|
||||
name: 'machine-edit',
|
||||
component: () => import('../../views/machines/MachineForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'equipment' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* Knowledge Base routes (core feature)
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'knowledgebase',
|
||||
name: 'knowledgebase',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue')
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/new',
|
||||
name: 'knowledgebase-new',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id',
|
||||
name: 'knowledgebase-detail',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id/edit',
|
||||
name: 'knowledgebase-edit',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Knowledge Base plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'knowledgebase',
|
||||
name: 'knowledgebase',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue'),
|
||||
meta: { plugin: 'knowledgebase' }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/new',
|
||||
name: 'knowledgebase-new',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'knowledgebase' }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id',
|
||||
name: 'knowledgebase-detail',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue'),
|
||||
meta: { plugin: 'knowledgebase' }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id/edit',
|
||||
name: 'knowledgebase-edit',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'knowledgebase' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
/**
|
||||
* Network plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'network',
|
||||
name: 'network',
|
||||
component: () => import('../../views/network/NetworkDevicesList.vue')
|
||||
},
|
||||
{
|
||||
path: 'network/new',
|
||||
name: 'network-new',
|
||||
component: () => import('../../views/network/NetworkDeviceForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'network/:id',
|
||||
name: 'network-detail',
|
||||
component: () => import('../../views/network/NetworkDeviceDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'network/:id/edit',
|
||||
name: 'network-edit',
|
||||
component: () => import('../../views/network/NetworkDeviceForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// Network-specific settings
|
||||
{
|
||||
path: 'settings/vlans',
|
||||
name: 'vlans',
|
||||
component: () => import('../../views/settings/VLANsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/subnets',
|
||||
name: 'subnets',
|
||||
component: () => import('../../views/settings/SubnetsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Network plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'network',
|
||||
name: 'network',
|
||||
component: () => import('../../views/network/NetworkDevicesList.vue'),
|
||||
meta: { plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'network/new',
|
||||
name: 'network-new',
|
||||
component: () => import('../../views/network/NetworkDeviceForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'network/:id',
|
||||
name: 'network-detail',
|
||||
component: () => import('../../views/network/NetworkDeviceDetail.vue'),
|
||||
meta: { plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'network/:id/edit',
|
||||
name: 'network-edit',
|
||||
component: () => import('../../views/network/NetworkDeviceForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'network' }
|
||||
},
|
||||
// Network-specific settings
|
||||
{
|
||||
path: 'settings/vlans',
|
||||
name: 'vlans',
|
||||
component: () => import('../../views/settings/VLANsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'settings/subnets',
|
||||
name: 'subnets',
|
||||
component: () => import('../../views/settings/SubnetsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
/**
|
||||
* Notifications plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('../../views/notifications/NotificationsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'notifications/new',
|
||||
name: 'notification-new',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/notificationtypes',
|
||||
name: 'notification-types',
|
||||
component: () => import('../../views/notifications/NotificationTypesList.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id',
|
||||
name: 'notification-detail',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue')
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id/edit',
|
||||
name: 'notification-edit',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('../../views/CalendarView.vue')
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Notifications plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('../../views/notifications/NotificationsList.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/new',
|
||||
name: 'notification-new',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'settings/notificationtypes',
|
||||
name: 'notification-types',
|
||||
component: () => import('../../views/notifications/NotificationTypesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id',
|
||||
name: 'notification-detail',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id/edit',
|
||||
name: 'notification-edit',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('../../views/CalendarView.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
/**
|
||||
* Printers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'printers',
|
||||
name: 'printers',
|
||||
component: () => import('../../views/printers/PrintersList.vue')
|
||||
},
|
||||
{
|
||||
path: 'printers/new',
|
||||
name: 'printer-new',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'printers/:id',
|
||||
name: 'printer-detail',
|
||||
component: () => import('../../views/printers/PrinterDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'printers/:id/edit',
|
||||
name: 'printer-edit',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// printer-specific settings
|
||||
{
|
||||
path: 'settings/modelsupplies',
|
||||
name: 'model-supplies',
|
||||
component: () => import('../../views/settings/ModelSuppliesList.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printerdrivers',
|
||||
name: 'printer-drivers',
|
||||
component: () => import('../../views/settings/PrinterDriversList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* Printers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'printers',
|
||||
name: 'printers',
|
||||
component: () => import('../../views/printers/PrintersList.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers/new',
|
||||
name: 'printer-new',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers/:id',
|
||||
name: 'printer-detail',
|
||||
component: () => import('../../views/printers/PrinterDetail.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers/:id/edit',
|
||||
name: 'printer-edit',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printers' }
|
||||
},
|
||||
// printer-specific settings
|
||||
{
|
||||
path: 'settings/modelsupplies',
|
||||
name: 'model-supplies',
|
||||
component: () => import('../../views/settings/ModelSuppliesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printerdrivers',
|
||||
name: 'printer-drivers',
|
||||
component: () => import('../../views/settings/PrinterDriversList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
/**
|
||||
* USB plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'usb',
|
||||
name: 'usb',
|
||||
component: () => import('../../views/usb/USBList.vue')
|
||||
},
|
||||
{
|
||||
path: 'usb/new',
|
||||
name: 'usb-new',
|
||||
component: () => import('../../views/usb/USBForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'usb/:id',
|
||||
name: 'usb-detail',
|
||||
component: () => import('../../views/usb/USBDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'usb/:id/edit',
|
||||
name: 'usb-edit',
|
||||
component: () => import('../../views/usb/USBForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
/**
|
||||
* USB plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'usb',
|
||||
name: 'usb',
|
||||
component: () => import('../../views/usb/USBList.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: 'usb/new',
|
||||
name: 'usb-new',
|
||||
component: () => import('../../views/usb/USBForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: 'usb/:id',
|
||||
name: 'usb-detail',
|
||||
component: () => import('../../views/usb/USBDetail.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: 'usb/:id/edit',
|
||||
name: 'usb-edit',
|
||||
component: () => import('../../views/usb/USBForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'usb' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -6,11 +6,12 @@ export default [
|
||||
path: 'warranties',
|
||||
name: 'warranties',
|
||||
component: () => import('../../views/warranty/WarrantiesList.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
meta: { plugin: 'warranty' }
|
||||
},
|
||||
{
|
||||
path: 'reports/warranty',
|
||||
name: 'warranty-report',
|
||||
component: () => import('../../views/reports/WarrantyReport.vue')
|
||||
component: () => import('../../views/reports/WarrantyReport.vue'),
|
||||
meta: { plugin: 'warranty' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -65,6 +65,35 @@ export async function getBrandPrimaryColor() {
|
||||
return getSetting('brand_primary_color', '')
|
||||
}
|
||||
|
||||
// Primary hover/active color (--primary-dark). Empty = derive from primary.
|
||||
export async function getBrandPrimaryDarkColor() {
|
||||
return getSetting('brand_primary_dark_color', '')
|
||||
}
|
||||
|
||||
// Accent color (--secondary). Empty = built-in palette from style.css.
|
||||
export async function getBrandAccentColor() {
|
||||
return getSetting('brand_accent_color', '')
|
||||
}
|
||||
|
||||
// Sidebar background color (--sidebar-bg). Empty = built-in palette.
|
||||
export async function getBrandSidebarColor() {
|
||||
return getSetting('brand_sidebar_color', '')
|
||||
}
|
||||
|
||||
// Darken a #rrggbb hex color by the given fraction (0.15 = 15% darker).
|
||||
// Returns null for anything that is not a 6-digit hex so callers can skip it.
|
||||
function darkenHexColor(hex, fraction) {
|
||||
const match = /^#([0-9a-fA-F]{6})$/.exec(hex)
|
||||
if (!match) return null
|
||||
const num = parseInt(match[1], 16)
|
||||
const scale = 1 - fraction
|
||||
const red = Math.round(((num >> 16) & 0xff) * scale)
|
||||
const green = Math.round(((num >> 8) & 0xff) * scale)
|
||||
const blue = Math.round((num & 0xff) * scale)
|
||||
const toHex = (channel) => channel.toString(16).padStart(2, '0')
|
||||
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`
|
||||
}
|
||||
|
||||
// ServiceNow ticket-link config. Returns the enabled flag, incident/change URL
|
||||
// templates, and the global-search URL template (all with a {ticket}
|
||||
// placeholder). Defaults mirror the previously-hardcoded GE ServiceNow URLs.
|
||||
@@ -104,8 +133,25 @@ export async function applyBranding() {
|
||||
}
|
||||
link.href = favicon
|
||||
}
|
||||
const rootStyle = document.documentElement.style
|
||||
const primaryColor = await getBrandPrimaryColor()
|
||||
if (primaryColor) {
|
||||
document.documentElement.style.setProperty('--primary', primaryColor)
|
||||
rootStyle.setProperty('--primary', primaryColor)
|
||||
}
|
||||
// Hover color: use the explicit value, else darken primary ~15%.
|
||||
const primaryDarkColor = await getBrandPrimaryDarkColor()
|
||||
if (primaryDarkColor) {
|
||||
rootStyle.setProperty('--primary-dark', primaryDarkColor)
|
||||
} else if (primaryColor) {
|
||||
const derived = darkenHexColor(primaryColor, 0.15)
|
||||
if (derived) rootStyle.setProperty('--primary-dark', derived)
|
||||
}
|
||||
const accentColor = await getBrandAccentColor()
|
||||
if (accentColor) {
|
||||
rootStyle.setProperty('--secondary', accentColor)
|
||||
}
|
||||
const sidebarColor = await getBrandSidebarColor()
|
||||
if (sidebarColor) {
|
||||
rootStyle.setProperty('--sidebar-bg', sidebarColor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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