Migrate frontend to plugin-based asset architecture

- Add equipmentApi and computersApi to replace legacy machinesApi
- Add controller vendor/model fields to Equipment model and forms
- Fix map marker navigation to use plugin-specific IDs (equipmentid,
  computerid, printerid, networkdeviceid) instead of assetid
- Fix search to use unified Asset table with correct plugin IDs
- Remove legacy printer search that used non-existent field names
- Enable optional JWT auth for detail endpoints (public read access)
- Clean up USB plugin models (remove unused checkout model)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-01-29 16:07:41 -05:00
parent c8b325d2e7
commit 180e6a0915
28 changed files with 4123 additions and 3454 deletions

View File

@@ -6,6 +6,7 @@
import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { currentTheme } from '../stores/theme'
const props = defineProps({
left: { type: Number, default: null },
@@ -23,11 +24,6 @@ const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Detect system color scheme
function getTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function initMap() {
if (!mapContainer.value || props.left === null || props.top === null) return
@@ -39,8 +35,7 @@ function initMap() {
zoomControl: true
})
const theme = getTheme()
const blueprintUrl = theme === 'light'
const blueprintUrl = currentTheme.value === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'

View File

@@ -41,7 +41,8 @@
</template>
<script setup>
import { ref, computed, nextTick, watch, onMounted, onUnmounted } from 'vue'
import { ref, computed, nextTick, watch } from 'vue'
import { currentTheme } from '../stores/theme'
const props = defineProps({
left: { type: Number, default: null },
@@ -49,22 +50,6 @@ const props = defineProps({
machineName: { type: String, default: '' }
})
// Auto-detect system theme with reactive updates
const systemTheme = ref(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
function handleThemeChange(e) {
systemTheme.value = e.matches ? 'dark' : 'light'
}
onMounted(() => {
mediaQuery.addEventListener('change', handleThemeChange)
})
onUnmounted(() => {
mediaQuery.removeEventListener('change', handleThemeChange)
})
const visible = ref(false)
const tooltipRef = ref(null)
const mapPreview = ref(null)
@@ -82,7 +67,9 @@ const hasPosition = computed(() => {
})
const blueprintUrl = computed(() => {
return systemTheme.value === 'light'
// 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'
})
@@ -196,6 +183,11 @@ watch(visible, (newVal) => {
zoom.value = 1
}
})
// Reset imageLoaded when theme changes to force reload
watch(currentTheme, () => {
imageLoaded.value = false
})
</script>
<style scoped>

View File

@@ -1,6 +1,7 @@
<template>
<div class="shopfloor-map">
<div class="map-controls" v-if="!pickerMode">
<!-- Controls for legacy machine mode only (MapView handles filters for asset mode) -->
<div class="map-controls" v-if="!pickerMode && !assetTypeMode">
<div class="filters">
<select v-model="filters.machinetype" @change="applyFilters">
<option value="">All Types</option>
@@ -43,6 +44,32 @@
</div>
</div>
<!-- Legend for asset type mode - shows subtypes when a type is selected -->
<div class="map-legend" v-if="!pickerMode && assetTypeMode">
<!-- Show subtype legend when a specific type is selected -->
<template v-if="selectedAssetType && Object.keys(visibleSubtypes).length">
<span
v-for="(color, subtypeId) in visibleSubtypes"
:key="subtypeId"
class="legend-item"
>
<span class="legend-dot" :style="{ background: color }"></span>
{{ subtypeNames[subtypeId] || `Type ${subtypeId}` }}
</span>
</template>
<!-- Show asset type legend when no type is selected -->
<template v-else>
<span
v-for="(color, assetType) in visibleAssetTypes"
:key="assetType"
class="legend-item"
>
<span class="legend-dot" :style="{ background: color }"></span>
{{ assetTypeLabels[assetType] || assetType }}
</span>
</template>
</div>
<div class="picker-controls" v-if="pickerMode">
<span class="picker-message">Click on the map to set location</span>
<span v-if="pickedPosition" class="picker-coords">
@@ -68,7 +95,10 @@ const props = defineProps({
theme: { type: String, default: 'dark' },
pickerMode: { type: Boolean, default: false },
initialPosition: { type: Object, default: null }, // { left, top }
assetTypeMode: { type: Boolean, default: false } // When true, use unified asset format
assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format
selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter
subtypeColors: { type: Object, default: () => ({}) }, // Map of subtype ID to color
subtypeNames: { type: Object, default: () => ({}) } // Map of subtype ID to name
})
const emit = defineEmits(['markerClick', 'positionPicked'])
@@ -92,14 +122,40 @@ const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Asset type colors (for unified map mode)
const assetTypeColors = {
// Asset type colors (for unified map mode) - normalized lookup
const assetTypeColorsMap = {
'equipment': '#F44336', // Red
'computer': '#2196F3', // Blue
'printer': '#4CAF50', // Green
'network_device': '#FF9800' // Orange
'network device': '#FF9800', // Orange
'network_device': '#FF9800' // Orange (alternate key)
}
// Get asset type color with case-insensitive lookup
function getAssetTypeColor(assettype) {
if (!assettype) return '#BDBDBD'
const normalized = assettype.toLowerCase()
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'
}
const assetTypeLabels = new Proxy({}, {
get(target, prop) {
if (typeof prop === 'string') {
return assetTypeLabelsMap[prop.toLowerCase()] || prop
}
return prop
}
})
// Type colors - distinct colors for each machine type
const typeColors = {
// Machining
@@ -161,6 +217,44 @@ const visibleTypes = computed(() => {
return props.machinetypes.filter(t => typeIds.has(t.machinetypeid))
})
// Get unique visible asset types (for asset mode)
const visibleAssetTypes = computed(() => {
const types = new Set(props.machines.map(m => m.assettype).filter(Boolean))
const result = {}
for (const t of types) {
result[t] = getAssetTypeColor(t)
}
return result
})
// Get subtype ID from asset based on asset type
function getSubtypeId(asset) {
if (!asset.typedata) return null
const typeLower = asset.assettype?.toLowerCase() || ''
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
}
// Get visible subtypes when a type is selected
const visibleSubtypes = computed(() => {
if (!props.selectedAssetType) return {}
const subtypeIds = new Set(
props.machines
.map(m => getSubtypeId(m))
.filter(id => id != null)
)
const result = {}
for (const id of subtypeIds) {
if (props.subtypeColors[id]) {
result[id] = props.subtypeColors[id]
}
}
return result
})
function initMap() {
if (!mapContainer.value) return
@@ -284,9 +378,15 @@ function renderMarkers() {
let color, typeName, displayName, detailRoute
if (props.assetTypeMode) {
// Unified asset mode
color = assetTypeColors[item.assettype] || '#BDBDBD'
typeName = item.assettype || ''
// Unified asset mode - use subtype colors when a type is selected
if (props.selectedAssetType) {
const subtypeId = getSubtypeId(item)
color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD'
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
} else {
color = getAssetTypeColor(item.assettype)
typeName = item.assettype || ''
}
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
detailRoute = getAssetDetailRoute(item)
} else {
@@ -299,9 +399,9 @@ function renderMarkers() {
const icon = L.divIcon({
html: `<div class="machine-marker-dot" style="background: ${color};"></div>`,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12],
iconSize: [12, 12],
iconAnchor: [6, 6],
popupAnchor: [0, -6],
className: 'machine-marker'
})
@@ -312,13 +412,7 @@ function renderMarkers() {
if (props.assetTypeMode) {
// Asset mode tooltips
const assetTypeLabel = {
'equipment': 'Equipment',
'computer': 'Computer',
'printer': 'Printer',
'network_device': 'Network Device'
}
tooltipLines.push(`<span style="color: #888;">${assetTypeLabel[item.assettype] || item.assettype}</span>`)
tooltipLines.push(`<span style="color: #888;">${item.assettype || 'Unknown'}</span>`)
if (item.primaryip) {
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.primaryip}</span>`)
@@ -421,19 +515,30 @@ function renderMarkers() {
// Get detail route for unified asset format
function getAssetDetailRoute(asset) {
const assetType = (asset.assettype || '').toLowerCase()
const routeMap = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network'
'network_device': '/network',
'network device': '/network'
}
const basePath = routeMap[asset.assettype] || '/machines'
const basePath = routeMap[assetType] || '/machines'
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
return `/network/${asset.typedata.networkdeviceid}`
// 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
}
}
const id = asset.typedata?.machineid || asset.assetid
return `${basePath}/${id}`
}
@@ -556,6 +661,19 @@ onUnmounted(() => {
color: var(--text);
}
.map-legend-bar {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
font-size: 1rem;
color: var(--text);
padding: 0.5rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.5rem;
}
.legend-item {
display: flex;
align-items: center;
@@ -645,11 +763,11 @@ onUnmounted(() => {
}
:deep(.machine-marker-dot) {
width: 24px;
height: 24px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 3px solid var(--border);
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
border: 2px solid rgba(255,255,255,0.8);
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
}
:deep(.picker-marker-dot) {
@@ -676,4 +794,18 @@ onUnmounted(() => {
:deep(.marker-tooltip::before) {
border-top-color: rgba(0, 0, 0, 0.92);
}
/* Legend bar for asset type mode (no filters - parent handles them) */
.map-legend {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
font-size: 0.875rem;
color: var(--text);
}
</style>