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

@@ -86,6 +86,8 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
const props = defineProps({
machines: { type: Array, default: () => [] },
@@ -119,10 +121,10 @@ const filters = ref({
search: ''
})
// Map dimensions (matching old system)
const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Map dimensions - facility blueprint size, loaded from settings before
// initMap runs (mutable so the loaded values replace the fallback defaults).
let MAP_WIDTH = mapConfig.width
let MAP_HEIGHT = mapConfig.height
// Asset type colors (for unified map mode) - normalized lookup
const assetTypeColorsMap = {
@@ -140,21 +142,10 @@ function getAssetTypeColor(assettype) {
return assetTypeColorsMap[normalized] || '#BDBDBD'
}
// Asset type labels for display (case-insensitive lookup)
const assetTypeLabelsMap = {
'equipment': 'Equipment',
'computer': 'Computers',
'printer': 'Printers',
'network device': 'Network Devices',
'network_device': 'Network Devices'
}
// Asset-type display labels come from the shared util (single source of truth).
const assetTypeLabels = new Proxy({}, {
get(target, prop) {
if (typeof prop === 'string') {
return assetTypeLabelsMap[prop.toLowerCase()] || prop
}
return prop
return typeof prop === 'string' ? assetTypeLabel(prop) : prop
}
})
@@ -232,7 +223,8 @@ const visibleAssetTypes = computed(() => {
// Get subtype ID from asset based on asset type
function getSubtypeId(asset) {
if (!asset.typedata) return null
const typeLower = asset.assettype?.toLowerCase() || ''
// Normalize network_device -> network device so the subtype id resolves.
const typeLower = (asset.assettype || '').toLowerCase().replace(/_/g, ' ')
if (typeLower === 'equipment') return asset.typedata.equipmenttypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
@@ -271,11 +263,8 @@ function initMap() {
renderer: canvasRenderer
})
const blueprintUrl = props.theme === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
imageOverlay = L.imageOverlay(blueprintUrl, bounds)
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme), bounds)
imageOverlay.addTo(map)
// Set initial view - zoom out to show full floor plan
@@ -390,7 +379,8 @@ function renderMarkers() {
color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD'
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
} else {
color = getAssetTypeColor(item.assettype)
// Prefer the stored AssetType.color; fall back to the built-in map.
color = item.assettypecolor || getAssetTypeColor(item.assettype)
typeName = item.assettype || ''
}
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
@@ -517,33 +507,9 @@ function renderMarkers() {
applyFilters()
}
// Get detail route for unified asset format
// Get detail route for unified asset format (shared util = single source).
function getAssetDetailRoute(asset) {
const assetType = (asset.assettype || '').toLowerCase()
const routeMap = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'network device': '/network'
}
const basePath = routeMap[assetType] || '/machines'
// Get the plugin-specific ID from typedata
let id = asset.assetid // fallback
if (asset.typedata) {
if (assetType === 'equipment' && asset.typedata.equipmentid) {
id = asset.typedata.equipmentid
} else if (assetType === 'computer' && asset.typedata.computerid) {
id = asset.typedata.computerid
} else if (assetType === 'printer' && asset.typedata.printerid) {
id = asset.typedata.printerid
} else if ((assetType === 'network_device' || assetType === 'network device') && asset.typedata.networkdeviceid) {
id = asset.typedata.networkdeviceid
}
}
return `${basePath}/${id}`
return assetDetailRoute(asset)
}
function applyFilters() {
@@ -581,14 +547,16 @@ watch(() => props.machines, (newVal, oldVal) => {
watch(() => props.theme, (newTheme) => {
if (imageOverlay && map) {
const blueprintUrl = newTheme === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
imageOverlay.setUrl(blueprintUrl)
imageOverlay.setUrl(blueprintUrlFor(newTheme))
}
})
onMounted(() => {
onMounted(async () => {
// Load this facility's blueprint + dimensions before building the map so
// bounds and coordinate math use the right size. Falls back to defaults.
await loadMapConfig()
MAP_WIDTH = mapConfig.width
MAP_HEIGHT = mapConfig.height
initMap()
})