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

@@ -47,6 +47,15 @@
@input="debouncedSearch"
/>
<button
class="btn btn-secondary export-btn"
@click="exportPdf"
:disabled="exporting || !filteredAssets.length"
:title="filteredAssets.length ? 'Export the filtered map to PDF' : 'No assets to export'"
>
{{ exporting ? 'Exporting...' : 'Export PDF' }}
</button>
<span class="result-count">{{ filteredAssets.length }} assets</span>
</div>
@@ -73,6 +82,9 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
import { loadMapConfig, state as mapConfig } from '../composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
const router = useRouter()
const authStore = useAuthStore()
@@ -89,18 +101,20 @@ const selectedSubtype = ref('')
const selectedBusinessUnit = ref('')
const selectedStatus = ref('')
const searchQuery = ref('')
const exporting = ref(false)
let searchTimeout = null
// Case-insensitive lookup helper for subtypes
// Lookup helper for subtypes. Normalizes case AND underscores-vs-spaces, since
// the asset-type value is 'network_device' but the subtypes key is
// 'Network Device' - without normalizing, network subtypes never match.
function getSubtypesForType(typeName) {
if (!typeName || !subtypes.value) return []
// Try exact match first
if (subtypes.value[typeName]) return subtypes.value[typeName]
// Try case-insensitive match
const lowerType = typeName.toLowerCase()
const norm = typeName.toLowerCase().replace(/_/g, ' ')
for (const [key, value] of Object.entries(subtypes.value)) {
if (key.toLowerCase() === lowerType) return value
if (key.toLowerCase().replace(/_/g, ' ') === norm) return value
}
return []
}
@@ -119,7 +133,7 @@ const subtypeLabel = computed(() => {
'network device': 'All Device Types',
'printer': 'All Printer Types'
}
return labels[selectedType.value.toLowerCase()] || 'All Subtypes'
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
})
// Generate distinct colors for subtypes
@@ -130,12 +144,13 @@ const subtypeColorPalette = [
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
]
// Map subtype IDs to colors
// Map subtype IDs to colors: prefer each subtype's stored color; fall back to
// the auto palette (by index) for subtypes that have not been given one.
const subtypeColorMap = computed(() => {
const colorMap = {}
const allSubtypes = currentSubtypes.value
allSubtypes.forEach((st, index) => {
colorMap[st.id] = subtypeColorPalette[index % subtypeColorPalette.length]
colorMap[st.id] = st.color || subtypeColorPalette[index % subtypeColorPalette.length]
})
return colorMap
})
@@ -158,10 +173,10 @@ const filteredAssets = computed(() => {
result = result.filter(a => a.assettype && a.assettype.toLowerCase() === selectedLower)
}
// Filter by subtype (case-insensitive type check)
// Filter by subtype (normalize network_device -> network device)
if (selectedSubtype.value) {
const subtypeId = parseInt(selectedSubtype.value)
const typeLower = selectedType.value?.toLowerCase() || ''
const typeLower = (selectedType.value || '').toLowerCase().replace(/_/g, ' ')
result = result.filter(a => {
if (!a.typedata) return false
// Check different ID fields based on asset type
@@ -202,7 +217,51 @@ const filteredAssets = computed(() => {
return result
})
// Human-readable labels for the filters currently applied, for the PDF header.
function activeFilterLabels() {
const labels = []
if (selectedType.value) labels.push(`Type: ${formatTypeName(selectedType.value)}`)
if (selectedSubtype.value) {
const st = currentSubtypes.value.find(s => String(s.id) === String(selectedSubtype.value))
if (st) labels.push(`Subtype: ${st.name}`)
}
if (selectedBusinessUnit.value) {
const bu = businessunits.value.find(b => String(b.businessunitid) === String(selectedBusinessUnit.value))
if (bu) labels.push(`Business Unit: ${bu.businessunit}`)
}
if (selectedStatus.value) {
const s = statuses.value.find(x => String(x.statusid) === String(selectedStatus.value))
if (s) labels.push(`Status: ${s.status}`)
}
if (searchQuery.value) labels.push(`Search: "${searchQuery.value}"`)
return labels
}
async function exportPdf() {
if (!filteredAssets.value.length) return
exporting.value = true
try {
await loadMapConfig()
await exportMapPdf({
assets: filteredAssets.value,
blueprintUrl: mapConfig.blueprintLight,
mapWidth: mapConfig.width,
mapHeight: mapConfig.height,
selectedType: selectedType.value,
subtypeColors: subtypeColorMap.value,
subtypeNames: subtypeNameMap.value,
filters: activeFilterLabels()
})
} catch (e) {
console.error('Map PDF export failed:', e)
alert('Failed to export map PDF. See console for details.')
} finally {
exporting.value = false
}
}
onMounted(async () => {
loadMapConfig()
try {
const response = await assetsApi.getMap()
const data = response.data.data || {}
@@ -220,15 +279,7 @@ onMounted(async () => {
})
function formatTypeName(assettype) {
if (!assettype) return assettype
const names = {
'equipment': 'Equipment',
'computer': 'Computers',
'printer': 'Printers',
'network device': 'Network Devices',
'network_device': 'Network Devices'
}
return names[assettype.toLowerCase()] || assettype
return assetTypeLabel(assettype)
}
function getTypeCount(assettype) {
@@ -255,33 +306,7 @@ function debouncedSearch() {
}
function handleMarkerClick(asset) {
// Route based on asset type (lowercase keys to match API data)
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
}
}
router.push(`${basePath}/${id}`)
router.push(assetDetailRoute(asset))
}
</script>
@@ -331,10 +356,30 @@ function handleMarkerClick(asset) {
min-width: 180px;
}
.export-btn {
margin-left: auto;
padding: 0.5rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
}
.export-btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.export-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-count {
color: var(--text-light);
font-size: 0.875rem;
margin-left: auto;
}
.map-page :deep(.shopfloor-map) {