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:
cproudlock
2026-07-11 10:01:47 -04:00
parent b8c22244a1
commit 22e623c1f6
85 changed files with 3274 additions and 972 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}`)
}
}
}

View File

@@ -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

View 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
}

View File

@@ -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'

View File

@@ -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()) {

View File

@@ -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' }
}
]

View File

@@ -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',

View File

@@ -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' }
}
]

View File

@@ -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' }
}
]

View File

@@ -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' }
}
]

View File

@@ -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' }
}
]

View File

@@ -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' }
}
]

View File

@@ -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' }
}
]

View File

@@ -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' }
}
]

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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()

View File

@@ -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>

View File

@@ -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: '',

View File

@@ -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: [

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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) {