Files
shopdb-flask/frontend/src/views/MapView.vue
cproudlock 48d3160bc5
All checks were successful
CI / backend (push) Successful in 23s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Rename the equipment domain to machines; retype the models catalog (ADR-011)
The equipment plugin is now the machines plugin, ending the UI-vs-code
vocabulary split while the contract is pre-1.0 and nothing external
depends on the old names.

- plugins/equipment -> plugins/machines: manifest, class, /api/machines,
  machines.* permissions, registry key (with an auto-migrating load shim
  for existing installs).
- Tables: equipment -> machines (equipmentid -> machineid) and
  equipmenttypes -> machinetypes, renamed in the plugin's own migration
  chain (machines0002rename), idempotent for both upgrading and fresh
  installs.
- The legacy core machinetypes lookup actually types the vendor MODELS
  catalog, so it is renamed losslessly to modeltypes
  (models.modeltypeid, /api/modeltypes, Model Types settings page)
  rather than collapsed, freeing the machinetypes name. Core migration
  7d17_machines_rename also flips data in place: assettypes row
  equipment -> machine, auditlog entitytype, identifier_/search_
  settings keys, permission rows, and renames alembic_version_equipment.
- Frontend: machinesApi/modeltypesApi, item.machine response shape,
  assettype value compares 'equipment' -> 'machine' (map, search,
  custom fields, relationships), routes machines.js with plugin gating
  retagged, /print/machine-badge, Machine Types (subtypes) and Model
  Types (catalog) settings pages, machines-by-type report id.
- Docs swept; ADRs left as history per the authoring rule.

Upgrade: flask db upgrade then flask plugin upgrade-all.

Verified: dev DB flipped live (262 machines, 35 modeltypes, 95 models
retyped, zero equipment tables remain); fresh scratch-MySQL install
produces the new names; 341 tests green; naming/style green; frontend
builds; live E2E on machines list/detail, PC relationships, map,
reports, and both settings pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 15:17:42 -04:00

391 lines
11 KiB
Vue

<template>
<div class="map-page">
<div class="page-header">
<h2>Shop Floor Map</h2>
<router-link v-if="authStore.isAuthenticated" to="/map/editor" class="btn btn-primary">
Edit Map
</router-link>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<!-- Filter Controls -->
<div class="map-filters">
<select v-model="selectedType" @change="onTypeChange">
<option value="">All Asset Types</option>
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
{{ formatTypeName(t.assettype) }} ({{ getTypeCount(t.assettype) }})
</option>
</select>
<select v-model="selectedSubtype" @change="updateMapLayers" :disabled="!selectedType || !currentSubtypes.length">
<option value="">{{ subtypeLabel }}</option>
<option v-for="st in currentSubtypes" :key="st.id" :value="st.id">
{{ st.name }}
</option>
</select>
<select v-model="selectedBusinessUnit" @change="updateMapLayers">
<option value="">All Business Units</option>
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
<select v-model="selectedStatus" @change="updateMapLayers">
<option value="">All Statuses</option>
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
{{ s.status }}
</option>
</select>
<input
type="text"
v-model="searchQuery"
placeholder="Search assets..."
@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>
<ShopFloorMap
:machines="filteredAssets"
:machinetypes="[]"
:businessunits="businessunits"
:statuses="statuses"
:assetTypeMode="true"
:theme="currentTheme"
:selectedAssetType="selectedType"
:subtypeColors="subtypeColorMap"
:subtypeNames="subtypeNameMap"
@markerClick="handleMarkerClick"
/>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
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'
import { useToast } from '../composables/toast'
const toast = useToast()
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const assets = ref([])
const assetTypes = ref([])
const businessunits = ref([])
const statuses = ref([])
const subtypes = ref({})
// Filter state
const selectedType = ref('')
const selectedSubtype = ref('')
const selectedBusinessUnit = ref('')
const selectedStatus = ref('')
const searchQuery = ref('')
const exporting = ref(false)
let searchTimeout = null
// 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]
const norm = typeName.toLowerCase().replace(/_/g, ' ')
for (const [key, value] of Object.entries(subtypes.value)) {
if (key.toLowerCase().replace(/_/g, ' ') === norm) return value
}
return []
}
const currentSubtypes = computed(() => {
if (!selectedType.value) return []
return getSubtypesForType(selectedType.value)
})
const subtypeLabel = computed(() => {
if (!selectedType.value) return 'Select type first'
if (!currentSubtypes.value.length) return 'No subtypes'
const labels = {
'machine': 'All Machine Types',
'computer': 'All Computer Types',
'network device': 'All Device Types',
'printer': 'All Printer Types'
}
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
})
// Generate distinct colors for subtypes
const subtypeColorPalette = [
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
]
// 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] = st.color || subtypeColorPalette[index % subtypeColorPalette.length]
})
return colorMap
})
// Map subtype IDs to names
const subtypeNameMap = computed(() => {
const nameMap = {}
currentSubtypes.value.forEach(st => {
nameMap[st.id] = st.name
})
return nameMap
})
const filteredAssets = computed(() => {
let result = assets.value
// Filter by asset type (case-insensitive)
if (selectedType.value) {
const selectedLower = selectedType.value.toLowerCase()
result = result.filter(a => a.assettype && a.assettype.toLowerCase() === selectedLower)
}
// Filter by subtype (normalize network_device -> network device)
if (selectedSubtype.value) {
const subtypeId = parseInt(selectedSubtype.value)
const typeLower = (selectedType.value || '').toLowerCase().replace(/_/g, ' ')
result = result.filter(a => {
if (!a.typedata) return false
// Check different ID fields based on asset type
if (typeLower === 'machine') {
return a.typedata.machinetypeid === subtypeId
} else if (typeLower === 'computer') {
return a.typedata.computertypeid === subtypeId
} else if (typeLower === 'network device') {
return a.typedata.networkdevicetypeid === subtypeId
} else if (typeLower === 'printer') {
return a.typedata.printertypeid === subtypeId
}
return false
})
}
// Filter by business unit
if (selectedBusinessUnit.value) {
result = result.filter(a => a.businessunitid === parseInt(selectedBusinessUnit.value))
}
// Filter by status
if (selectedStatus.value) {
result = result.filter(a => a.statusid === parseInt(selectedStatus.value))
}
// Filter by search query
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(a =>
(a.assetnumber && a.assetnumber.toLowerCase().includes(q)) ||
(a.name && a.name.toLowerCase().includes(q)) ||
(a.displayname && a.displayname.toLowerCase().includes(q)) ||
(a.serialnumber && a.serialnumber.toLowerCase().includes(q))
)
}
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)
toast.error('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 || {}
assets.value = data.assets || []
assetTypes.value = data.filters?.assettypes || []
businessunits.value = data.filters?.businessunits || []
statuses.value = data.filters?.statuses || []
subtypes.value = data.filters?.subtypes || {}
} catch (error) {
console.error('Failed to load map data:', error)
} finally {
loading.value = false
}
})
function formatTypeName(assettype) {
return assetTypeLabel(assettype)
}
function getTypeCount(assettype) {
if (!assettype) return 0
const lowerType = assettype.toLowerCase()
return assets.value.filter(a => a.assettype && a.assettype.toLowerCase() === lowerType).length
}
function onTypeChange() {
// Reset subtype when type changes
selectedSubtype.value = ''
updateMapLayers()
}
function updateMapLayers() {
// Filter is reactive via computed property
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
updateMapLayers()
}, 300)
}
function handleMarkerClick(asset) {
router.push(assetDetailRoute(asset))
}
</script>
<style scoped>
.map-page {
display: flex;
flex-direction: column;
height: calc(100vh - 2rem);
}
.map-page .page-header {
flex-shrink: 0;
}
.map-filters {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.map-filters select,
.map-filters input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-size: 0.875rem;
}
.map-filters select {
min-width: 160px;
}
.map-filters select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.map-filters input {
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;
}
.map-page :deep(.shopfloor-map) {
flex: 1;
}
</style>