Files
shopdb-flask/frontend/src/views/MapView.vue
cproudlock f34b9ca710
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
Carry the level everywhere a position is drawn, and gate it per occurrence
The hover mini-map said "This asset has a position (2835, 1410) but no level"
for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid
prop, NONE of its seven call sites were taught to pass one - printer, machine and
PC detail pages, the toner report, enforcement reports, the warranty chip and the
dashboard cards - so the component correctly reported a missing level and the
preview never drew. Two payloads behind those views also emitted mapx/mapy with
no level: the toner report and the enforcement report.

The map PDF export had the ORIGINAL bug still in it: it plotted every filtered
asset onto the sheet, so exporting the ground floor printed second-floor markers
on it. Worse than on screen, because nobody can correct a sheet once it has been
printed and carried onto the floor. It now exports only the level being viewed.

The legacy import loader sent mapleft/maptop with no level at three call sites.
That loader is the one still to run against production, and every marker it
created would have been undrawable. It now resolves the site's default level -
the legacy schema predates levels and has one floor plan, so that is what its
coordinates mean.

THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid',
not whether each position does: one module emitted 'mapx' six times and 'levelid'
once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/
and plugins/, and fails any Vue file that binds tooltip coordinates without
:levelid. Both new rules were confirmed to fail the build against planted
violations before being relied on.

Printer QR labels: the asset number is no longer printed. A label now reads name
(8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber
because that is where sites actually keep it - every printer here has an empty
name field, so preferring the Windows queue name alone would have printed a blank
line on every label.
2026-08-18 09:36:45 -04:00

448 lines
14 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-if="levelChoices.length > 1"
v-model.number="shownLevelId"
@change="onLevelChange"
title="Which floor plan to show"
>
<option v-for="level in levelChoices" :key="level.levelid" :value="level.levelid">
{{ level.label }}
</option>
</select>
<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
:levelid="shownLevelId"
: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, blueprintUrlFor, dimensionsFor, levelName, levelOptions,
setCurrentLevel, state as mapConfig } from '@/composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
import { getSubtypeId } from '../utils/mapColors'
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('')
// Which floor plan is on screen. Every marker coordinate is pixels of ONE level
// (ADR-017), so this decides both the drawing and which markers belong on it.
const shownLevelId = ref(null)
const levelChoices = computed(() => levelOptions())
function onLevelChange() {
// Keep the shared composable in step, so a map-position picker opened later
// starts on the level being looked at rather than the site default.
setCurrentLevel(shownLevelId.value)
}
// A search whose only matches are on another floor would otherwise show an empty
// map: the assets matched, the filter counted them, and nothing was drawn.
// Follow the results to the level that actually holds them - the level with the
// most matches, so a search matching several floors lands on the best one.
function followSearchAcrossLevels() {
if (!searchQuery.value.trim()) return
const matches = filteredAssets.value.filter(a => a.mapx != null && a.mapy != null)
if (!matches.length) return
if (matches.some(a => (a.levelid ?? null) === shownLevelId.value)) return
const tally = new Map()
matches.forEach(asset => {
const levelid = asset.levelid ?? null
if (levelid === null) return
tally.set(levelid, (tally.get(levelid) || 0) + 1)
})
if (!tally.size) return
const best = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
shownLevelId.value = best
setCurrentLevel(best)
}
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',
'measuring tool': 'All Tool Types'
}
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
})
// Auto colors for subtypes that have not been given one. Every step clears 3:1
// against BOTH map surfaces (white blueprint and the dark navy card), so none
// washes out; the old list ran through pale yellows and limes (#FFEB3B was
// 1.1:1 on white) that were invisible on the light blueprint. Ordered by
// max-min perceptual separation, so the fewer subtypes are on screen the
// further apart their colors sit. Past roughly the fifth entry no palette can
// keep every pair distinguishable under color-blind simulation - the legend and
// the hover tooltip carry identity from there.
const subtypeColorPalette = [
'#7c4dff', '#ef6c00', '#0097a7', '#c2185b', '#1565c0',
'#e03131', '#e0479e', '#00897b', '#827717', '#1976d2',
'#d81b60', '#388e3c', '#9c4dcc', '#2e7d32'
]
// 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. Uses the shared getSubtypeId helper so this stays in
// step with the marker coloring - a local copy of the per-type id lookup went
// stale and silently dropped every measuring tool.
if (selectedSubtype.value) {
const subtypeId = parseInt(selectedSubtype.value)
result = result.filter(a => getSubtypeId(a) === subtypeId)
}
// 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({
// Only this level's markers. The sheet is one drawing, so a marker
// positioned against another level would be printed on the wrong floor
// plan - the same failure the on-screen map had, in a form nobody can
// correct after it is printed and carried onto the floor.
assets: filteredAssets.value.filter(
asset => (asset.levelid ?? null) === shownLevelId.value),
// blueprintUrlFor applies withBase - the raw setting value is a
// root-relative /api path, which 404s under a subpath mount like /ops.
// The PDF always uses the light blueprint: it prints on white paper.
blueprintUrl: blueprintUrlFor('light', shownLevelId.value),
mapWidth: dimensionsFor(shownLevelId.value).width,
mapHeight: dimensionsFor(shownLevelId.value).height,
// Named on the sheet, because a floor plan with no level on it is not
// identifiable once it is printed and carried to the floor.
levelname: levelName(shownLevelId.value),
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 () => {
await loadMapConfig()
shownLevelId.value = mapConfig.currentlevelid ?? mapConfig.defaultlevelid ?? null
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(() => {
followSearchAcrossLevels()
updateMapLayers()
}, 300)
}
function handleMarkerClick(asset) {
router.push(assetDetailRoute(asset))
}
</script>
<style scoped>
.map-page {
display: flex;
flex-direction: column;
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
}
.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>