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

@@ -33,7 +33,7 @@
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationshiptypename }}</span>
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
@@ -65,7 +65,7 @@
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationshiptypename }}</span>
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
@@ -175,6 +175,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api'
import { colorStyle } from '@/utils/colorStyle'
import { useAuthStore } from '../stores/auth'
const props = defineProps({
@@ -199,6 +200,11 @@ const lookupFailed = ref(false)
const outgoing = ref([])
const incoming = ref([])
const relationshipTypes = ref([])
function colorForType(name) {
const t = relationshipTypes.value.find(rt => rt.relationshiptype === name)
return t?.color || null
}
const showAddModal = ref(false)
// New relationship form

View File

@@ -0,0 +1,101 @@
<template>
<div class="color-swatch-picker">
<div class="swatches">
<button
v-for="c in palette"
:key="c"
type="button"
class="swatch"
:class="{ active: modelValue === c }"
:style="{ backgroundColor: c }"
:title="c"
@click="$emit('update:modelValue', c)"
></button>
</div>
<div class="custom">
<input
type="color"
:value="isHex(modelValue) ? modelValue : '#000000'"
@input="$emit('update:modelValue', $event.target.value)"
/>
<input
type="text"
:value="modelValue"
placeholder="#RRGGBB"
maxlength="20"
@input="$emit('update:modelValue', $event.target.value)"
/>
<span class="preview" :style="colorStyle(modelValue)">Aa</span>
</div>
</div>
</template>
<script setup>
import { PALETTE, colorStyle } from '@/utils/colorStyle'
defineProps({ modelValue: { type: String, default: '' } })
defineEmits(['update:modelValue'])
const palette = PALETTE
function isHex(c) {
return typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c)
}
</script>
<style scoped>
.color-swatch-picker {
display: flex;
flex-direction: column;
gap: 8px;
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.swatch {
width: 26px;
height: 26px;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
}
.swatch.active {
border-color: var(--bg);
box-shadow: 0 0 0 2px var(--text);
}
.custom {
display: flex;
align-items: center;
gap: 8px;
}
.custom input[type="text"] {
width: 120px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-family: monospace;
}
.custom input[type="color"] {
width: 34px;
height: 34px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: none;
cursor: pointer;
}
.preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 6px;
font-weight: 700;
border: 1px solid var(--border);
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div v-if="fields.length" class="custom-fields">
<div class="form-group" v-for="f in fields" :key="f.fieldid">
<label>{{ f.label }}</label>
<select v-if="f.datatype === 'select'" v-model="local[f.fieldid]" class="form-control">
<option value="">-- none --</option>
<option v-for="opt in f.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<label v-else-if="f.datatype === 'boolean'" class="checkbox-label">
<input type="checkbox"
:checked="local[f.fieldid] === 'true'"
@change="local[f.fieldid] = $event.target.checked ? 'true' : 'false'" />
Yes
</label>
<input v-else-if="f.datatype === 'date'" type="date" v-model="local[f.fieldid]" class="form-control" />
<input v-else-if="f.datatype === 'number'" type="number" v-model="local[f.fieldid]" class="form-control" />
<input v-else type="text" v-model="local[f.fieldid]" class="form-control" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { customFieldsApi } from '../api'
const props = defineProps({
// Asset type whose fields to render (needed even for a brand-new asset).
assettypeid: { type: [Number, String], default: null },
// Existing asset id, if editing - used to preload stored values.
assetid: { type: [Number, String], default: null },
})
const fields = ref([])
const local = reactive({})
async function load() {
fields.value = []
Object.keys(local).forEach(k => delete local[k])
if (!props.assettypeid) return
try {
// Definitions for this asset type (form-visible, active).
const defsResponse = await customFieldsApi.list({ assettypeid: props.assettypeid })
const defs = (defsResponse.data.data || []).filter(f => f.showonform)
fields.value = defs
// Seed blanks, then overlay stored values if editing.
for (const f of defs) local[f.fieldid] = f.datatype === 'boolean' ? 'false' : ''
if (props.assetid) {
const valResponse = await customFieldsApi.forAsset(props.assetid)
for (const f of (valResponse.data.data || [])) {
if (f.value != null) local[f.fieldid] = String(f.value)
}
}
} catch (err) {
console.error('Error loading custom field defs:', err)
}
}
// Persist current values against an asset id (parent calls after asset save).
async function save(assetid) {
if (!assetid || !fields.value.length) return
const values = {}
for (const f of fields.value) values[f.fieldid] = local[f.fieldid]
await customFieldsApi.saveForAsset(assetid, values)
}
onMounted(load)
watch(() => [props.assettypeid, props.assetid], load)
defineExpose({ save, hasFields: () => fields.value.length > 0 })
</script>
<style scoped>
.custom-fields {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<!-- Only render the card when there is at least one field with a value to show -->
<div class="section-card" v-if="visibleFields.length">
<h3 class="section-title">{{ title }}</h3>
<div class="info-list">
<div class="info-row" v-for="f in visibleFields" :key="f.fieldid">
<span class="info-label">{{ f.label }}</span>
<span class="info-value">{{ displayValue(f) }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { customFieldsApi } from '../api'
const props = defineProps({
assetid: { type: [Number, String], default: null },
title: { type: String, default: 'Additional Details' },
})
const fields = ref([])
const visibleFields = computed(() =>
fields.value.filter(f => f.showondetail && f.value != null && String(f.value).trim() !== ''))
function displayValue(f) {
if (f.datatype === 'boolean') {
return ['true', '1', 'yes'].includes(String(f.value).toLowerCase()) ? 'Yes' : 'No'
}
return f.value
}
async function load() {
if (!props.assetid) { fields.value = []; return }
try {
const response = await customFieldsApi.forAsset(props.assetid)
fields.value = response.data.data || []
} catch (err) {
console.error('Error loading custom fields:', err)
fields.value = []
}
}
onMounted(load)
watch(() => props.assetid, load)
</script>

View File

@@ -7,6 +7,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
const props = defineProps({
left: { type: Number, default: null },
@@ -19,14 +20,17 @@ const mapContainer = ref(null)
let map = null
let marker = null
// Map dimensions
const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Map dimensions - facility blueprint size, loaded from settings.
let MAP_WIDTH = mapConfig.width
let MAP_HEIGHT = mapConfig.height
function initMap() {
if (!mapContainer.value || props.left === null || props.top === null) return
MAP_WIDTH = mapConfig.width
MAP_HEIGHT = mapConfig.height
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
map = L.map(mapContainer.value, {
crs: L.CRS.Simple,
minZoom: -3,
@@ -35,11 +39,7 @@ function initMap() {
zoomControl: true
})
const blueprintUrl = currentTheme.value === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
L.imageOverlay(blueprintUrl, bounds).addTo(map)
L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
// Convert database coordinates to Leaflet (y is inverted)
const leafletY = MAP_HEIGHT - props.top
@@ -71,7 +71,8 @@ function initMap() {
map.setMaxBounds(bounds)
}
onMounted(() => {
onMounted(async () => {
await loadMapConfig()
initMap()
})

View File

@@ -43,6 +43,11 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
// Fetch this facility's blueprint + dimensions once; computeds below react
// when it loads.
loadMapConfig()
const props = defineProps({
left: { type: Number, default: null },
@@ -58,29 +63,22 @@ const isOverTooltip = ref(false)
const zoom = ref(1)
const imageLoaded = ref(false)
// Map dimensions
const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const hasPosition = computed(() => {
return props.left !== null && props.top !== null
})
const blueprintUrl = computed(() => {
// Force re-evaluation when theme changes by including theme in the computed
const theme = currentTheme.value
return theme === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
// Reading currentTheme keeps this reactive to theme changes.
return blueprintUrlFor(currentTheme.value)
})
// Calculate marker position as percentage
// Calculate marker position as percentage of the facility blueprint size
const markerX = computed(() => {
return (props.left / MAP_WIDTH) * 100
return (props.left / mapConfig.width) * 100
})
const markerY = computed(() => {
return (props.top / MAP_HEIGHT) * 100
return (props.top / mapConfig.height) * 100
})
// Marker style with counter-scale to maintain constant size

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

View File

@@ -0,0 +1,75 @@
<template>
<div class="section-card" v-if="warranties.length || showEmpty">
<h3 class="section-title">Warranty</h3>
<div v-if="warranties.length" class="warranty-list">
<div v-for="w in warranties" :key="w.warrantyid" class="warranty-item">
<div class="warranty-top">
<span class="warranty-vendor">{{ w.vendor }}</span>
<span class="status-badge" :style="colorStyle(w.statuscolor)">{{ statusLabel(w.status) }}</span>
</div>
<div class="warranty-meta">
<span v-if="w.servicelevel">{{ w.servicelevel }}</span>
<span v-if="w.enddate">Ends {{ formatDate(w.enddate) }}</span>
<span v-if="w.servicetag" class="mono">Tag {{ w.servicetag }}</span>
</div>
</div>
<router-link to="/warranties" class="warranty-manage">Manage warranties</router-link>
</div>
<div v-else class="warranty-empty">
<span class="muted">No warranty on record.</span>
<router-link to="/warranties" class="warranty-manage">Add one</router-link>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi } from '../api'
const props = defineProps({
assetid: { type: [Number, String], default: null },
// When true, render the card even with no warranties (shows an "Add one" link).
showEmpty: { type: Boolean, default: false },
})
const warranties = ref([])
function statusLabel(status) {
return { active: 'Active', expiring: 'Expiring Soon', expired: 'Expired', unknown: 'Unknown' }[status] || status
}
function formatDate(d) {
if (!d) return '-'
return new Date(d + 'T00:00:00').toLocaleDateString()
}
async function load() {
if (!props.assetid) { warranties.value = []; return }
try {
const response = await warrantyApi.forAsset(props.assetid)
warranties.value = response.data.data || []
} catch (err) {
console.error('Error loading warranties:', err)
warranties.value = []
}
}
onMounted(load)
watch(() => props.assetid, load)
</script>
<style scoped>
.warranty-list { display: flex; flex-direction: column; gap: 0.75rem; }
.warranty-item { padding: 0.6rem 0.75rem; background: var(--bg); border-radius: 6px; }
.warranty-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.warranty-vendor { font-weight: 600; color: var(--text); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.warranty-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
.warranty-empty { display: flex; align-items: center; gap: 0.6rem; }
.warranty-manage { font-size: 0.82rem; }
.mono { font-family: monospace; }
.muted { color: var(--text-light); }
</style>