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:
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user