Add custom fields + warranty plugin, rework settings into two-pane shell

Feature work from the 2026-07 session:

Settings IA
- Replace the flat 27-card settings hub with a persistent two-pane shell
  (SettingsLayout.vue): grouped, searchable left rail + content pane.
- Nest all settings/* routes under the shell via router post-processing;
  shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers,
  Equipment, Network) so per-type settings stop scattering.

Custom fields (core)
- customfields + customfieldvalues tables (migration 7d14), CRUD API at
  /api/customfields, per-asset value get/save.
- Settings management page + reusable CustomFieldsSection (detail) and
  CustomFieldsInputs (form) wired into all four asset types.

Warranty (new plugin)
- plugins/warranty: warranties + warrantyassets (migration 7d15), derived
  coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs).
- API CRUD + per-asset panel + report buckets; WarrantyPanel on all four
  detail pages; Warranties management page; Warranty report + Reports card.
- Seed warranty.* permissions.

Printer drivers
- printerdrivers table (migration 7d13) linked to printer models; drivers now
  surface on the matching printer's detail page.

Other
- PCDetail rebalanced (Network + Status + Warranty + custom fields on the right).
- Rename PCs list "Features" column to "Remote Access"; fix badge hover underline.
- Drop equipment islocationonly field.
- Centralize asset-type label/route maps into utils/assetTypes.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 15:37:21 -04:00
parent 419f26107d
commit 78a0ee8d83
154 changed files with 9479 additions and 1098 deletions

View File

@@ -0,0 +1,60 @@
// Single source of truth for asset-type display labels + detail routing.
// The map and shopfloor views used to each hardcode these identical maps.
// Keys are lowercase; both 'network_device' and 'network device' are accepted
// because the API sends the underscore form and some map data the spaced form.
const ASSET_TYPE_LABELS = {
'equipment': 'Equipment',
'computer': 'Computers',
'printer': 'Printers',
'network_device': 'Network Devices',
'network device': 'Network Devices',
}
const ASSET_TYPE_ROUTES = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'network device': '/network',
}
// Plugin-specific id field inside asset.typedata for each asset type.
const ASSET_TYPE_ID_KEYS = {
'equipment': 'equipmentid',
'computer': 'computerid',
'printer': 'printerid',
'network_device': 'networkdeviceid',
'network device': 'networkdeviceid',
}
function titleCase(text) {
return String(text || '')
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase())
}
// Plural, human-friendly label for an asset type. Falls back to title-cased
// input so an unknown/new type still reads sensibly.
export function assetTypeLabel(type) {
if (!type) return type
return ASSET_TYPE_LABELS[String(type).toLowerCase()] || titleCase(type)
}
// Base list route for an asset type (e.g. 'computer' -> '/pcs').
export function assetTypeRoute(type) {
return ASSET_TYPE_ROUTES[String(type || '').toLowerCase()] || '/machines'
}
// Full detail route for a unified-format asset, preferring the plugin-specific
// id in typedata and falling back to the asset id.
export function assetDetailRoute(asset) {
const type = (asset.assettype || '').toLowerCase()
const base = assetTypeRoute(type)
const idKey = ASSET_TYPE_ID_KEYS[type]
let id = asset.assetid
if (asset.typedata && idKey && asset.typedata[idKey]) {
id = asset.typedata[idKey]
}
return `${base}/${id}`
}

View File

@@ -0,0 +1,39 @@
// Data-driven badge colors. A record stores a hex color; the UI renders it with
// an auto-picked readable text color, so any color stays legible. Pickers should
// offer PALETTE (curated, distinct, accessible) with custom hex as a fallback.
// Curated categorical palette - distinct + accessible. ~12 is the practical
// ceiling for at-a-glance distinguishability, which is plenty per category.
export const PALETTE = [
'#f5365c', // red
'#fb6340', // orange
'#ff8800', // amber
'#ffc107', // gold
'#2dce89', // green
'#04b962', // emerald
'#11cdef', // cyan
'#14abef', // blue
'#0d6efd', // royal blue
'#7934f3', // purple
'#e83e8c', // pink
'#6c757d', // gray
]
// Black or white text for a given background, by perceived brightness.
export function readableText(bg) {
if (!bg || typeof bg !== 'string') return '#ffffff'
let hex = bg.trim().replace('#', '')
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('')
if (hex.length !== 6) return '#ffffff'
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return brightness > 0.6 ? '#1a1a1a' : '#ffffff'
}
// Style object for a badge/pill from a stored color (with a neutral fallback).
export function colorStyle(color, fallback = '#6c757d') {
const backgroundColor = color || fallback
return { backgroundColor, color: readableText(backgroundColor) }
}

View File

@@ -0,0 +1,51 @@
// Marker color logic for the shop-floor map, shared so the PDF export renders
// the exact same colors the map shows on screen. Mirrors the maps defined in
// ShopFloorMap.vue - keep the two in sync if the palette changes.
export const assetTypeColorsMap = {
equipment: '#F44336', // Red
computer: '#2196F3', // Blue
printer: '#4CAF50', // Green
'network device': '#FF9800', // Orange
network_device: '#FF9800' // Orange (alternate key)
}
const DEFAULT_COLOR = '#BDBDBD'
// Canonical form for comparing asset-type strings. The API sends the machine
// type as 'network_device' but subtype keys and labels use 'network device',
// so normalize underscores to spaces before any comparison. Without this,
// network-device subtypes silently fail to match (equipment/computer/printer
// are single words and were unaffected, which is why only network broke).
export function normalizeAssetType(assettype) {
return (assettype || '').toLowerCase().replace(/_/g, ' ')
}
// Color for an asset by its top-level asset type (used when no type filter is
// active, so every type is distinguished by color).
export function getAssetTypeColor(assettype) {
if (!assettype) return DEFAULT_COLOR
return assetTypeColorsMap[assettype.toLowerCase()] || DEFAULT_COLOR
}
// The subtype id for an asset, read from the plugin-specific typedata block.
// Returns null when the asset has no subtype.
export function getSubtypeId(asset) {
if (!asset || !asset.typedata) return null
const typeLower = normalizeAssetType(asset.assettype)
if (typeLower === 'equipment') return asset.typedata.equipmenttypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
if (typeLower === 'printer') return asset.typedata.printertypeid
return null
}
// Resolve the marker color for an asset the same way ShopFloorMap does: when a
// type is selected, color by subtype (grey fallback); otherwise by asset type.
export function resolveMarkerColor(asset, { selectedType, subtypeColors = {} }) {
if (selectedType) {
const id = getSubtypeId(asset)
return (id != null && subtypeColors[id]) || DEFAULT_COLOR
}
return getAssetTypeColor(asset.assettype)
}

View File

@@ -0,0 +1,135 @@
// Export the shop-floor map (current filtered assets on the facility blueprint)
// to a PDF, entirely client-side. The blueprint image is drawn full-size and
// each visible marker is placed at its scaled coordinate, so the PDF is a crisp
// vector-over-raster page rather than a screen capture.
import { jsPDF } from 'jspdf'
import { resolveMarkerColor, getSubtypeId, getAssetTypeColor } from './mapColors'
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('Failed to load blueprint image: ' + url))
img.src = url
})
}
function hexToRgb(hex) {
const h = (hex || '#BDBDBD').replace('#', '')
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)]
}
// Build the legend entries (color + label) for the assets present, matching the
// active coloring mode.
function buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) {
const seen = new Map()
for (const a of assets) {
let key, label, color
if (selectedType) {
const id = getSubtypeId(a)
key = id != null ? String(id) : 'none'
label = (id != null && subtypeNames[id]) || 'Unspecified'
color = (id != null && subtypeColors[id]) || '#BDBDBD'
} else {
key = (a.assettype || 'unknown').toLowerCase()
label = a.assettype || 'Unknown'
color = getAssetTypeColor(a.assettype)
}
if (!seen.has(key)) seen.set(key, { label, color, count: 0 })
seen.get(key).count += 1
}
return [...seen.values()].sort((a, b) => b.count - a.count)
}
export async function exportMapPdf(opts) {
const {
assets = [],
blueprintUrl,
mapWidth = 3300,
mapHeight = 2550,
selectedType = '',
subtypeColors = {},
subtypeNames = {},
filters = [],
facility = '',
title = 'Shop Floor Map'
} = opts
const img = await loadImage(blueprintUrl)
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4', compress: true })
const pageW = doc.internal.pageSize.getWidth()
const pageH = doc.internal.pageSize.getHeight()
const margin = 28
// ---- Header ----
doc.setTextColor('#111111')
doc.setFont('helvetica', 'bold')
doc.setFontSize(16)
doc.text(title, margin, margin + 6)
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor('#555555')
const stamp = new Date().toLocaleString()
const subParts = [facility, stamp, `${assets.length} asset${assets.length === 1 ? '' : 's'}`].filter(Boolean)
doc.text(subParts.join(' | '), margin, margin + 22)
const filterText = filters.length ? 'Filters: ' + filters.join(' ') : 'Filters: none (all assets)'
doc.text(filterText, margin, margin + 35)
// ---- Legend (wraps under the header, pushes the map down) ----
const legend = buildLegend(assets, { selectedType, subtypeColors, subtypeNames })
const swatch = 8
const legendY0 = margin + 50
const lineH = 15
let lx = margin
let ly = legendY0
doc.setFontSize(8.5)
for (const entry of legend) {
const label = `${entry.label} (${entry.count})`
const w = swatch + 4 + doc.getTextWidth(label) + 16
if (lx + w > pageW - margin) { lx = margin; ly += lineH }
const [r, g, b] = hexToRgb(entry.color)
doc.setFillColor(r, g, b)
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.4)
doc.rect(lx, ly - swatch + 1, swatch, swatch, 'F')
doc.setTextColor('#333333')
doc.text(label, lx + swatch + 4, ly)
lx += w
}
const legendBottom = legend.length ? ly + 6 : legendY0
// ---- Blueprint image, fit into the area below the legend ----
const aspect = mapWidth / mapHeight
const contentTop = legendBottom + 8
const availW = pageW - margin * 2
const availH = pageH - contentTop - margin
let imgW = availW
let imgH = availW / aspect
if (imgH > availH) { imgH = availH; imgW = availH * aspect }
const imgX = margin + (availW - imgW) / 2
const imgY = contentTop + (availH - imgH) / 2
doc.addImage(img, 'PNG', imgX, imgY, imgW, imgH, undefined, 'FAST')
doc.setDrawColor('#cccccc'); doc.setLineWidth(0.5)
doc.rect(imgX, imgY, imgW, imgH)
// ---- Markers (database Y is top-down, same origin as the drawn image) ----
const radius = 3.2
for (const a of assets) {
if (a.mapx == null || a.mapy == null) continue
const x = imgX + (a.mapx / mapWidth) * imgW
const y = imgY + (a.mapy / mapHeight) * imgH
if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue
const [r, g, b] = hexToRgb(resolveMarkerColor(a, { selectedType, subtypeColors }))
doc.setFillColor(r, g, b)
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.6)
doc.circle(x, y, radius, 'FD')
}
const dateSlug = new Date().toISOString().slice(0, 10)
doc.save(`shopfloor-map-${dateSlug}.pdf`)
}

View File

@@ -0,0 +1,37 @@
// Shared read-through for public site settings (site_base_url, facility_name).
// The settings GET is public (jwt optional), so the kiosk dashboard and the
// print views can read these without auth. Fetched once and cached per page.
import { settingsApi } from '@/api'
let settingsCache = null
async function loadSettings() {
if (settingsCache) return settingsCache
try {
const response = await settingsApi.list()
const items = response.data?.data || response.data || []
settingsCache = {}
for (const s of items) settingsCache[s.key] = s.value
} catch (err) {
console.error('Error loading site settings:', err)
settingsCache = {}
}
return settingsCache
}
export async function getSetting(key, fallback = '') {
const settings = await loadSettings()
const value = settings[key]
return (value === undefined || value === null || value === '') ? fallback : value
}
// Public base URL for QR codes / absolute links. Falls back to the current
// browsing origin when a site has not set one.
export async function getSiteBaseUrl() {
return getSetting('site_base_url', window.location.origin)
}
// Facility name shown on the shopfloor dashboard.
export async function getFacilityName() {
return getSetting('facility_name', 'West Jefferson')
}