Buildings and levels for the floor map, and make every identifier searchable
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s

The map was one picture of one floor. A second floor was added, the blueprint
changed size, and machines moved, so a position now records WHICH DRAWING its
coordinates belong to.

Buildings and levels (ADR-017). Each level owns its blueprint per theme and its
own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the
site. A position whose level is unknown renders "level unknown" and is never
drawn on the default level, because a marker on the wrong floor plan looks
entirely correct while pointing at the wrong place.

Repositioning in bulk: filter by unplaced, needs-review or level, search, place,
confirm. Landmark recalibration solves the transform PER AXIS from landmark
pairs and never from image dimensions - the canvas grew taller without
rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong
everywhere. It defaults to a dry run, reports what would land off the drawing,
snapshots before applying, and clears mapverifiedat because a transform is a
guess awaiting review. Snapshots restore, including the level and the review
state, and a restore snapshots first so an undo is undoable.

Search: gaugelabreference was matched only for measuring tools and
maintenancereference was matched nowhere at all, for any asset type, while
Settings happily offers both identifiers on machines and PCs. A tag an operator
is told to record has to be findable or it is a write-only field. USB devices
and printed items were unreachable from search entirely - neither is an asset,
so the generic asset search could not see them and no searcher existed; they
now match on serial, asset tag, label, bin code and gage-lab tag, honouring
isactive, with Settings toggles and result labels to match.

The retired-application rule was half a rule: GET /api/knowledgebase hid
articles whose topic application is retired while global search still returned
them and printed the retired application as the subject. A filter is only real
if every path that reaches the row applies it.

Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location
gained levelid, and resolve_asset_position returns the levelid belonging to
whichever source supplied the coordinates. The five plugins that write a map
position are re-pinned. The install-list text format gained levelid as a NINTH
field, appended, because the shipped Pascal installer reads fields 0-7 by index.

That installer still compiles in one drawing's dimensions and bundles one
blueprint, so its map is accurate for the default level only; /api/maplevels is
deliberately unauthenticated so it can read both at runtime once rebuilt.
Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps.

Migration 7d33 converts an existing single-map site into one building and one
default level carrying the old map_* settings, then assigns every placed asset
and location to it. Nothing moves on screen. Old settings rows are kept so a
rollback still finds them. Verified end to end on MySQL 5.6 from a
production-shaped database.
This commit is contained in:
cproudlock
2026-08-17 12:55:51 -04:00
parent 7d9a54ca0f
commit 3324dbd91e
60 changed files with 5313 additions and 895 deletions

View File

@@ -863,6 +863,63 @@ export const setupApi = {
}
}
// Buildings and the levels within them (ADR-017). Reads are public - the
// printer installer map draws a blueprint before anyone logs in.
export const mapLevelsApi = {
list() {
return api.get('/maplevels')
},
get(levelid) {
return api.get(`/maplevels/${levelid}`)
},
createBuilding(payload) {
return api.post('/maplevels/buildings', payload)
},
updateBuilding(buildingid, payload) {
return api.patch(`/maplevels/buildings/${buildingid}`, payload)
},
create(payload) {
return api.post('/maplevels', payload)
},
update(levelid, payload) {
return api.patch(`/maplevels/${levelid}`, payload)
},
remove(levelid) {
return api.delete(`/maplevels/${levelid}`)
},
// Returns the image's real pixel size alongside the stored dimensions. On an
// empty level the server adopts them; on a populated one it refuses and says
// so, because changing the coordinate space moves every marker on it.
uploadBlueprint(levelid, theme, file) {
const form = new FormData()
form.append('file', file)
form.append('theme', theme)
return api.post(`/maplevels/${levelid}/blueprint`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
}
// Bulk marker positions: the landmark transform, bulk placement, review state
// and undo. Every write snapshots first.
export const mapPositionsApi = {
setPositions(positions, verified = true) {
return api.post('/mappositions/positions', { positions, verified })
},
transform(payload) {
return api.post('/mappositions/transform', payload)
},
verify(assetids, unverify = false) {
return api.post('/mappositions/verify', { assetids, unverify })
},
snapshots() {
return api.get('/mappositions/snapshots')
},
restore(snapshotid) {
return api.post(`/mappositions/snapshots/${snapshotid}/restore`)
},
}
export const settingsApi = {
list(params = {}) {
return api.get('/settings', { params })

View File

@@ -1,141 +1,148 @@
<template>
<div class="embedded-map" ref="mapContainer"></div>
</template>
<script setup>
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 },
top: { type: Number, default: null },
markerColor: { type: String, default: '#ff0000' },
markerLabel: { type: String, default: '' }
})
const mapContainer = ref(null)
let map = null
let marker = null
// 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,
maxZoom: 2,
attributionControl: false,
zoomControl: true
})
L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
// Convert database coordinates to Leaflet (y is inverted)
const leafletY = MAP_HEIGHT - props.top
const leafletX = props.left
// Create marker
const icon = L.divIcon({
html: `<div class="location-marker-dot" style="background: ${props.markerColor};"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10],
className: 'location-marker'
})
marker = L.marker([leafletY, leafletX], { icon })
if (props.markerLabel) {
marker.bindTooltip(props.markerLabel, {
permanent: true,
direction: 'top',
offset: [0, -10],
className: 'location-label'
})
}
marker.addTo(map)
// Center on marker with appropriate zoom
map.setView([leafletY, leafletX], -1)
map.setMaxBounds(bounds)
}
onMounted(async () => {
await loadMapConfig()
initMap()
})
onUnmounted(() => {
if (map) {
map.remove()
map = null
}
})
watch([() => props.left, () => props.top], () => {
if (map) {
map.remove()
map = null
}
initMap()
})
</script>
<style scoped>
.embedded-map {
width: 100%;
height: 300px;
border-radius: 8px;
overflow: hidden;
background: var(--bg);
}
:deep(.location-marker) {
background: transparent !important;
border: none !important;
}
:deep(.location-marker-dot) {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
}
50% {
box-shadow: 0 0 0 6px rgba(255,0,0,0.2), 0 2px 8px rgba(0,0,0,0.4);
}
}
:deep(.location-label) {
background: rgba(0, 0, 0, 0.85);
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.875rem;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
:deep(.location-label::before) {
border-top-color: rgba(0, 0, 0, 0.85);
}
</style>
<template>
<div class="embedded-map" ref="mapContainer"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel } from '../composables/mapConfig'
const props = defineProps({
left: { type: Number, default: null },
top: { type: Number, default: null },
markerColor: { type: String, default: '#ff0000' },
markerLabel: { type: String, default: '' },
// Which drawing left/top belong to (ADR-017). Without it there is no honest
// blueprint to draw, so the map is not initialised at all.
levelid: { type: Number, default: null },
})
const mapContainer = ref(null)
let map = null
let marker = null
// This LEVEL's native size, which is what its marker coordinates mean.
let MAP_WIDTH = 0
let MAP_HEIGHT = 0
function initMap() {
if (!mapContainer.value || props.left === null || props.top === null) return
// No level, no drawing. Rendering the default blueprint under these
// coordinates would look right and be wrong; an empty box is honest.
if (!hasLevel(props.levelid)) return
const dimensions = dimensionsFor(props.levelid)
MAP_WIDTH = dimensions.width
MAP_HEIGHT = dimensions.height
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
map = L.map(mapContainer.value, {
crs: L.CRS.Simple,
minZoom: -3,
maxZoom: 2,
attributionControl: false,
zoomControl: true
})
L.imageOverlay(blueprintUrlFor(currentTheme.value, props.levelid), bounds).addTo(map)
// Convert database coordinates to Leaflet (y is inverted)
const leafletY = MAP_HEIGHT - props.top
const leafletX = props.left
// Create marker
const icon = L.divIcon({
html: `<div class="location-marker-dot" style="background: ${props.markerColor};"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10],
className: 'location-marker'
})
marker = L.marker([leafletY, leafletX], { icon })
if (props.markerLabel) {
marker.bindTooltip(props.markerLabel, {
permanent: true,
direction: 'top',
offset: [0, -10],
className: 'location-label'
})
}
marker.addTo(map)
// Center on marker with appropriate zoom
map.setView([leafletY, leafletX], -1)
map.setMaxBounds(bounds)
}
onMounted(async () => {
await loadMapConfig()
initMap()
})
onUnmounted(() => {
if (map) {
map.remove()
map = null
}
})
watch([() => props.left, () => props.top], () => {
if (map) {
map.remove()
map = null
}
initMap()
})
</script>
<style scoped>
.embedded-map {
width: 100%;
height: 300px;
border-radius: 8px;
overflow: hidden;
background: var(--bg);
}
:deep(.location-marker) {
background: transparent !important;
border: none !important;
}
:deep(.location-marker-dot) {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
}
50% {
box-shadow: 0 0 0 6px rgba(255,0,0,0.2), 0 2px 8px rgba(0,0,0,0.4);
}
}
:deep(.location-label) {
background: rgba(0, 0, 0, 0.85);
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.875rem;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
:deep(.location-label::before) {
border-top-color: rgba(0, 0, 0, 0.85);
}
</style>

View File

@@ -12,7 +12,15 @@
@wheel.prevent="onWheel"
>
<div class="map-tooltip-content">
<div class="map-preview" ref="mapPreview">
<div v-if="levelUnknown" class="map-level-unknown">
<strong>Level unknown</strong>
<span>
This asset has a position ({{ props.left }}, {{ props.top }}) but no
level, so there is no drawing to show it on. Set its level on the
asset, or place it in the map editor.
</span>
</div>
<div v-else class="map-preview" ref="mapPreview">
<div
class="map-transform"
:style="transformStyle"
@@ -43,7 +51,7 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, levelName, state as mapConfig } from '../composables/mapConfig'
// Fetch this facility's blueprint + dimensions once; computeds below react
// when it loads.
@@ -52,7 +60,11 @@ loadMapConfig()
const props = defineProps({
left: { type: Number, default: null },
top: { type: Number, default: null },
machineName: { type: String, default: '' }
machineName: { type: String, default: '' },
// Which drawing left/top are pixels of (ADR-017). A position without one
// cannot be rendered: the same coordinates land somewhere different on every
// level, so this shows what is missing instead of guessing the default.
levelid: { type: Number, default: null },
})
const visible = ref(false)
@@ -67,18 +79,29 @@ const hasPosition = computed(() => {
return props.left !== null && props.top !== null
})
const blueprintUrl = computed(() => {
// Reading currentTheme keeps this reactive to theme changes.
return blueprintUrlFor(currentTheme.value)
// A position we cannot place: coordinates but no level, or a level this instance
// does not know. Rendering the default blueprint here would look correct and be
// wrong, so the tooltip says so instead.
const levelUnknown = computed(() => {
return hasPosition.value && !hasLevel(props.levelid)
})
// Calculate marker position as percentage of the facility blueprint size
const levelLabel = computed(() => levelName(props.levelid))
const blueprintUrl = computed(() => {
// Reading currentTheme keeps this reactive to theme changes.
return blueprintUrlFor(currentTheme.value, props.levelid)
})
// Marker position as a percentage of THIS LEVEL's native size. Percentages of
// the wrong level's dimensions is precisely how a marker ends up plausibly
// placed and wrong.
const markerX = computed(() => {
return (props.left / mapConfig.width) * 100
return (props.left / dimensionsFor(props.levelid).width) * 100
})
const markerY = computed(() => {
return (props.top / mapConfig.height) * 100
return (props.top / dimensionsFor(props.levelid).height) * 100
})
// Marker style with counter-scale to maintain constant size
@@ -197,6 +220,20 @@ watch(currentTheme, () => {
.location-tooltip-wrapper:hover {
color: var(--primary, #1976d2);
}
.map-level-unknown {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem;
max-width: 18rem;
font-size: 0.8rem;
color: var(--text-light);
}
.map-level-unknown strong {
color: var(--warning);
}
</style>
<style>

View File

@@ -95,7 +95,7 @@
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 { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, state as mapConfig } from '../composables/mapConfig'
import { assetTypeLabel } from '../utils/assetTypes'
import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
import api from '../api'
@@ -111,7 +111,11 @@ const props = defineProps({
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
subtypeNames: { type: Object, default: () => ({}) }, // Map of subtype ID to name
// Which level this map is drawing (ADR-017). Defaults to the current level in
// the shared config, so an existing caller that has not been taught about
// levels still renders the level the user is looking at rather than nothing.
levelid: { type: Number, default: null }
})
const emit = defineEmits(['markerClick', 'positionPicked'])
@@ -139,10 +143,15 @@ const filters = ref({
search: ''
})
// 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
// The drawn level's native size, which is what its marker coordinates mean.
// Mutable because the levels load after this module does, and because switching
// level changes them.
function drawnLevelId() {
return props.levelid ?? mapConfig.currentlevelid
}
let MAP_WIDTH = dimensionsFor(drawnLevelId()).width
let MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
// Asset type colors (for unified map mode) - normalized lookup
const assetTypeColorsMap = {
@@ -279,7 +288,7 @@ function initMap() {
})
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme), bounds)
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme, drawnLevelId()), bounds)
imageOverlay.addTo(map)
// Set initial view - zoom out to show full floor plan
@@ -584,7 +593,7 @@ watch(() => props.machines, (newVal, oldVal) => {
watch(() => props.theme, (newTheme) => {
if (imageOverlay && map) {
imageOverlay.setUrl(blueprintUrlFor(newTheme))
imageOverlay.setUrl(blueprintUrlFor(newTheme, drawnLevelId()))
// Marker rings are keyed on the surface, so they have to be redrawn too.
renderMarkers()
}
@@ -594,8 +603,8 @@ 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
MAP_WIDTH = dimensionsFor(drawnLevelId()).width
MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
initMap()
loadOverlays()
})

View File

@@ -1,42 +1,59 @@
// Facility floor-map blueprint config, read from the settings table so each
// site instance (ADR-004) renders its own floor plan instead of a hardcoded
// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height.
// Missing keys fall back to the generic placeholder so a fresh or offline
// install still renders; each site uploads its own blueprint in Settings.
// Which drawing renders a marker, and at what native size (ADR-017).
//
// This used to hold ONE blueprint and ONE pixel size, read from four settings,
// because a site had one floor map. It now holds every level of every building,
// because `assets.mapx`/`mapy` are pixels in a specific level's space and the
// same coordinates mean different places on different drawings.
//
// THE RULE THIS FILE ENFORCES: a position without a level is not rendered on the
// default level. `blueprintUrlFor(theme, levelid)` returns null for an unknown
// level, and every caller must show "level unknown" rather than draw something.
// Falling back would put one building's ground floor behind a marker positioned
// for another building's mezzanine - it renders perfectly and points at the
// wrong place, which is worse than rendering nothing.
import { reactive } from 'vue'
import { settingsApi } from '../api'
import { mapLevelsApi } from '../api'
import { withBase } from '../utils/basePath'
// Fallback defaults - match the seeded map_blueprint_* setting defaults.
const DEFAULTS = {
blueprintLight: '/static/images/floorplan-placeholder.svg',
blueprintDark: '/static/images/floorplan-placeholder.svg',
width: 3300,
height: 2550
}
// Used until the levels load, and on a fresh install with none configured, so a
// map still draws something rather than breaking.
const PLACEHOLDER = '/static/images/floorplan-placeholder.svg'
const FALLBACK_WIDTH = 3300
const FALLBACK_HEIGHT = 2550
// Shared reactive config. Import as `state` to read width/height/blueprint.
export const state = reactive({ ...DEFAULTS, loaded: false })
export const state = reactive({
buildings: [],
// Flat index by levelid, because every hover preview resolves an arbitrary
// asset's level and has no idea which building it is in.
levels: {},
defaultlevelid: null,
currentlevelid: null,
loaded: false,
})
let inflight = null
function applySetting(key, value) {
if (value === null || value === undefined || value === '') return
if (key === 'map_blueprint_light') state.blueprintLight = value
else if (key === 'map_blueprint_dark') state.blueprintDark = value
else if (key === 'map_width') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.width = n
} else if (key === 'map_height') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.height = n
}
function levelFor(levelid) {
if (levelid === null || levelid === undefined) return null
return state.levels[levelid] || null
}
function fetchConfig() {
inflight = settingsApi.list({ category: 'map' })
function fetchLevels() {
inflight = mapLevelsApi.list()
.then(({ data }) => {
;(data.data || []).forEach(s => applySetting(s.key, s.value))
const payload = data.data || {}
state.buildings = payload.buildings || []
state.levels = {}
state.buildings.forEach(building => {
;(building.levels || []).forEach(level => {
state.levels[level.levelid] = { ...level, buildingname: building.buildingname }
})
})
state.defaultlevelid = payload.defaultlevelid || null
if (!state.currentlevelid || !state.levels[state.currentlevelid]) {
state.currentlevelid = state.defaultlevelid
}
state.loaded = true
})
.catch(() => { state.loaded = true })
@@ -44,26 +61,95 @@ function fetchConfig() {
return inflight
}
// Fetch the map config once (shared across all map components). Returns a
// promise that resolves when state is populated, so a caller can await it
// before initializing a Leaflet map that needs the dimensions.
// Fetch once, shared across every map component. Await it before initialising a
// Leaflet map, which needs the dimensions to set its bounds.
export function loadMapConfig() {
if (state.loaded) return Promise.resolve()
if (inflight) return inflight
return fetchConfig()
return fetchLevels()
}
// Re-read config from the server after a map setting changes.
// Re-read after the levels admin changes something.
export function reloadMapConfig() {
return fetchConfig()
return fetchLevels()
}
// Blueprint image URL for the given theme ('light' | 'dark').
export function blueprintUrlFor(theme) {
return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark)
export function setCurrentLevel(levelid) {
if (state.levels[levelid]) state.currentlevelid = levelid
}
/**
* Blueprint URL for one level in one theme, or null when the level is unknown.
*
* Falls back to the OTHER theme's image before giving up, because a site that
* uploaded only a light blueprint should still render in dark mode - a
* hard-to-read floor plan beats no floor plan.
*/
export function blueprintUrlFor(theme, levelid) {
const level = levelFor(levelid === undefined ? state.currentlevelid : levelid)
if (!level) return null
const wanted = theme === 'light' ? level.blueprintlight : level.blueprintdark
const other = theme === 'light' ? level.blueprintdark : level.blueprintlight
const chosen = wanted || other
return withBase(chosen || PLACEHOLDER)
}
/**
* Native pixel size of a level, which is what its marker coordinates mean.
*
* Returns the fallback for an unknown level so arithmetic does not divide by
* undefined, but callers deciding WHETHER to draw must ask `hasLevel` - these
* numbers are a safe default, not evidence the level exists.
*/
export function dimensionsFor(levelid) {
const level = levelFor(levelid === undefined ? state.currentlevelid : levelid)
return {
width: level?.mapwidth || FALLBACK_WIDTH,
height: level?.mapheight || FALLBACK_HEIGHT,
}
}
export function hasLevel(levelid) {
return !!levelFor(levelid)
}
export function levelName(levelid) {
const level = levelFor(levelid)
if (!level) return null
// Qualified by building only when there is more than one, so a single-building
// site is not made to read "Main / Ground floor" everywhere.
return state.buildings.length > 1
? `${level.buildingname} / ${level.levelname}`
: level.levelname
}
// Every level flat, in building then level order, for a selector.
export function levelOptions() {
const options = []
state.buildings.forEach(building => {
;(building.levels || []).forEach(level => {
options.push({
levelid: level.levelid,
levelname: level.levelname,
buildingname: building.buildingname,
label: state.buildings.length > 1
? `${building.buildingname} / ${level.levelname}`
: level.levelname,
})
})
})
return options
}
export function useMapConfig() {
loadMapConfig()
return { state, blueprintUrlFor }
return {
state,
blueprintUrlFor,
dimensionsFor,
hasLevel,
levelName,
levelOptions,
setCurrentLevel,
}
}

View File

@@ -0,0 +1,144 @@
// The invariant the whole map model rests on: a position without a level is not
// drawn on the default level.
//
// Nothing else catches this. Rewriting the composable left four components
// reading a property that no longer existed, which is `undefined` rather than a
// compile error - Vite built it happily and every marker would have been
// positioned at NaN percent. A build proves the code parses, not that a marker
// lands anywhere.
import { describe, it, expect, vi, beforeEach } from 'vitest'
const list = vi.fn()
vi.mock('../api', () => ({ mapLevelsApi: { list: (...args) => list(...args) } }))
const {
state, loadMapConfig, reloadMapConfig, blueprintUrlFor, dimensionsFor,
hasLevel, levelName, levelOptions, setCurrentLevel,
} = await import('./mapConfig')
// Two buildings, three levels, sized like the real before and after: the ground
// floor at 3300x2550 and a second floor at 3308x4000.
const PAYLOAD = {
data: {
data: {
buildings: [
{
buildingid: 1, buildingname: 'Main', levels: [
{ levelid: 1, levelname: 'Ground floor', sortorder: 0, mapwidth: 3300,
mapheight: 2550, blueprintlight: '/x/g-light.png',
blueprintdark: '/x/g-dark.png', isdefault: true },
{ levelid: 2, levelname: 'Second floor', sortorder: 1, mapwidth: 3308,
mapheight: 4000, blueprintlight: '/x/2-light.png',
blueprintdark: null, isdefault: false },
],
},
{
buildingid: 2, buildingname: 'Annex', levels: [
{ levelid: 3, levelname: 'Ground floor', sortorder: 0, mapwidth: 1200,
mapheight: 900, blueprintlight: '/x/a-light.png',
blueprintdark: '/x/a-dark.png', isdefault: false },
],
},
],
defaultlevelid: 1,
},
},
}
beforeEach(async () => {
list.mockReset()
list.mockResolvedValue(PAYLOAD)
state.loaded = false
state.currentlevelid = null
await reloadMapConfig()
})
describe('loading', () => {
it('indexes every level of every building and adopts the default', () => {
expect(Object.keys(state.levels)).toEqual(['1', '2', '3'])
expect(state.defaultlevelid).toBe(1)
expect(state.currentlevelid).toBe(1)
})
it('fetches once across concurrent callers', async () => {
list.mockClear()
state.loaded = false
await Promise.all([loadMapConfig(), loadMapConfig(), loadMapConfig()])
expect(list).toHaveBeenCalledTimes(1)
})
it('survives an unreachable server without hanging every map on the page', async () => {
list.mockReset()
list.mockRejectedValue(new Error('network'))
state.loaded = false
await reloadMapConfig()
expect(state.loaded).toBe(true)
})
})
describe('dimensions belong to the level, not the site', () => {
it('returns each level its own native size', () => {
expect(dimensionsFor(1)).toEqual({ width: 3300, height: 2550 })
expect(dimensionsFor(2)).toEqual({ width: 3308, height: 4000 })
expect(dimensionsFor(3)).toEqual({ width: 1200, height: 900 })
})
it('does not report one level size for another', () => {
// The bug this guards: a marker on level 2 measured against level 1's
// height renders at 2550/4000 of the way down - plausible, and wrong.
expect(dimensionsFor(2).height).not.toBe(dimensionsFor(1).height)
})
})
describe('a position without a level is not drawn', () => {
it('has no blueprint for a null level', () => {
expect(blueprintUrlFor('light', null)).toBeNull()
expect(hasLevel(null)).toBe(false)
})
it('has no blueprint for a level this instance does not know', () => {
expect(blueprintUrlFor('light', 99)).toBeNull()
expect(hasLevel(99)).toBe(false)
})
it('never substitutes the default level for a missing one', () => {
const groundfloor = blueprintUrlFor('light', 1)
expect(groundfloor).toContain('g-light.png')
// The failure mode: returning the default blueprint for an unknown level.
expect(blueprintUrlFor('light', null)).not.toBe(groundfloor)
expect(blueprintUrlFor('light', 99)).not.toBe(groundfloor)
})
})
describe('blueprints', () => {
it('serves the theme asked for', () => {
expect(blueprintUrlFor('light', 1)).toContain('g-light.png')
expect(blueprintUrlFor('dark', 1)).toContain('g-dark.png')
})
it('falls back to the other theme rather than showing nothing', () => {
// Level 2 has no dark blueprint. A hard-to-read floor plan beats no floor
// plan, and a site that uploaded one image should still work in both themes.
expect(blueprintUrlFor('dark', 2)).toContain('2-light.png')
})
})
describe('naming and selection', () => {
it('qualifies a level by building only when there is more than one', () => {
// Both buildings have a 'Ground floor', so the name alone is ambiguous.
expect(levelName(1)).toBe('Main / Ground floor')
expect(levelName(3)).toBe('Annex / Ground floor')
})
it('offers every level in building then level order', () => {
expect(levelOptions().map(option => option.levelid)).toEqual([1, 2, 3])
})
it('refuses to make an unknown level current', () => {
setCurrentLevel(99)
expect(state.currentlevelid).toBe(1)
setCurrentLevel(2)
expect(state.currentlevelid).toBe(2)
})
})

View File

@@ -52,7 +52,9 @@ export const searchDomains = [
{ key: 'network_device', label: 'Network Devices' },
{ key: 'measuring_tool', label: 'Measuring Tools' },
{ key: 'notification', label: 'Notifications' },
{ key: 'subnet', label: 'Subnets' }
{ key: 'subnet', label: 'Subnets' },
{ key: 'usb_device', label: 'USB Devices' },
{ key: 'printed_item', label: 'Printed Items' }
]
export function useSystemSettings() {

View File

@@ -3,6 +3,8 @@
<div class="page-header">
<h2>Map Editor</h2>
<div class="header-actions">
<button class="btn btn-secondary" @click="openTransform">Recalibrate level</button>
<button class="btn btn-secondary" @click="openSnapshots">Undo history</button>
<router-link to="/map" class="btn btn-secondary">Back to Map</router-link>
</div>
</div>
@@ -12,6 +14,13 @@
<div class="asset-panel">
<div class="panel-header">
<h3>Assets</h3>
<select v-if="levelOptions().length > 1" v-model.number="editingLevelId"
class="form-control" title="Which drawing you are placing on">
<option v-for="option in levelOptions()" :key="option.levelid"
:value="option.levelid">{{ option.label }}</option>
</select>
<input v-model="search" class="form-control" type="search"
placeholder="Search name or asset number" />
<select v-model="filterType" class="form-control">
<option value="">All Types</option>
<option value="machine">Machines</option>
@@ -25,7 +34,15 @@
<div class="asset-filter">
<label class="filter-checkbox">
<input type="checkbox" v-model="showUnplacedOnly" />
Show unplaced only
Unplaced only
</label>
<label class="filter-checkbox" title="Positions a transform moved, which nobody has confirmed against the current drawing yet">
<input type="checkbox" v-model="showUnverifiedOnly" />
Needs review ({{ unverifiedCount }})
</label>
<label class="filter-checkbox" title="Only assets on the level you are editing">
<input type="checkbox" v-model="thisLevelOnly" />
This level only
</label>
</div>
@@ -47,6 +64,15 @@
<div class="asset-meta">
<span class="badge badge-sm">{{ asset.assettype }}</span>
<span v-if="asset.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span>
<span v-if="asset.levelid && asset.levelid !== editingLevelId"
class="badge badge-sm badge-other-level"
:title="'On ' + levelName(asset.levelid)">{{ levelName(asset.levelid) }}</span>
<span v-if="asset.mapx && !asset.levelid" class="badge badge-sm badge-warning"
title="Has a position but no level, so it cannot be drawn">no level</span>
<button v-if="asset.mapx && asset.levelid && !asset.mapverifiedat"
class="badge badge-sm badge-review" type="button"
title="This position came from a transform. Click to confirm it is right."
@click.stop="markReviewed(asset)">confirm</button>
</div>
</div>
</div>
@@ -96,19 +122,114 @@
:theme="currentTheme"
:pickerMode="!!selectedAsset"
:initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
:levelid="editingLevelId"
@positionPicked="handlePositionPicked"
@markerClick="handleMarkerClick"
/>
</div>
</div>
<!-- Recalibrate: move every marker on this level by a transform read off two
landmarks. Deriving it from the image dimensions instead would stretch
one axis and be wrong everywhere, so the numbers come from features
visible on both drawings. -->
<div v-if="showTransform" class="modal-overlay">
<div class="modal modal-wide">
<div class="modal-header"><h3>Recalibrate {{ levelName(editingLevelId) }}</h3></div>
<div class="modal-body">
<p class="input-hint">
Pick two features present on both the old and the new drawing. A
building corner plus something central beats two corners: a long
baseline makes the derived scale more forgiving.
</p>
<table class="data-table">
<thead>
<tr><th>Landmark</th><th>Old X</th><th>Old Y</th><th>New X</th><th>New Y</th></tr>
</thead>
<tbody>
<tr v-for="(mark, index) in landmarks" :key="index">
<td>{{ index + 1 }}</td>
<td><input v-model.number="mark.fromx" type="number" class="form-control" /></td>
<td><input v-model.number="mark.fromy" type="number" class="form-control" /></td>
<td><input v-model.number="mark.tox" type="number" class="form-control" /></td>
<td><input v-model.number="mark.toy" type="number" class="form-control" /></td>
</tr>
</tbody>
</table>
<button class="btn btn-small btn-secondary" @click="landmarks.push({})">
Add a third landmark
</button>
<div v-if="preview" class="transform-preview">
<h4>Preview</h4>
<p class="mono">
X: scale {{ preview.transform.scalex.toFixed(4) }}, offset {{ Math.round(preview.transform.offsetx) }}<br />
Y: scale {{ preview.transform.scaley.toFixed(4) }}, offset {{ Math.round(preview.transform.offsety) }}
</p>
<p>
{{ preview.assetcount }} marker(s) would move.
<strong v-if="preview.outofboundscount" class="warn">
{{ preview.outofboundscount }} would land outside the drawing.
</strong>
</p>
<p class="input-hint">
A scale near 1 means the drawing shifted rather than rescaled. A
scale far from 1 on an axis that only gained canvas is the sign of a
bad landmark pair.
</p>
</div>
<div v-if="transformError" class="error-message">{{ transformError }}</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showTransform = false">Cancel</button>
<button class="btn btn-secondary" @click="runTransform(true)" :disabled="working">Preview</button>
<button class="btn btn-primary" @click="runTransform(false)" :disabled="working || !preview">
Move {{ preview ? preview.assetcount : 0 }} marker(s)
</button>
</div>
</div>
</div>
<!-- Undo. Every bulk write snapshots first, and a restore snapshots too, so a
second attempt is always possible. -->
<div v-if="showSnapshots" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>Undo history</h3></div>
<div class="modal-body">
<table class="data-table">
<thead><tr><th>When</th><th>What</th><th>Markers</th><th></th></tr></thead>
<tbody>
<tr v-for="snapshot in snapshots" :key="snapshot.snapshotid">
<td class="mono">{{ (snapshot.createddate || '').slice(0, 16).replace('T', ' ') }}</td>
<td>{{ snapshot.reason }}</td>
<td>{{ snapshot.assetcount }}</td>
<td>
<button class="btn btn-small" @click="restore(snapshot)" :disabled="working">
{{ snapshot.restoredat ? 'Restore again' : 'Restore' }}
</button>
</td>
</tr>
<tr v-if="!snapshots.length">
<td colspan="4" class="empty">Nothing to undo yet.</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showSnapshots = false">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { assetsApi, mapPositionsApi } from '../api'
import { loadMapConfig, levelOptions, levelName, setCurrentLevel,
state as mapConfig } from '../composables/mapConfig'
import { currentTheme } from '../stores/theme'
import { useToast } from '../composables/toast'
const toast = useToast()
@@ -119,6 +240,25 @@ const selectedAsset = ref(null)
const pickedPosition = ref(null)
const filterType = ref('')
const showUnplacedOnly = ref(false)
// A transform is a guess: it moves markers but cannot know which machines
// actually moved, so everything it touches needs confirming against the drawing.
// This is the working queue for that.
const showUnverifiedOnly = ref(false)
const thisLevelOnly = ref(true)
const search = ref('')
// Which drawing is being placed on. Every position saved from here belongs to it.
const editingLevelId = ref(null)
// Recalibration state. Two landmarks is the minimum that determines both axes;
// a third is offered because points picked by eye carry a few pixels of error and
// three let it cancel rather than accumulate.
const showTransform = ref(false)
const landmarks = ref([{}, {}])
const preview = ref(null)
const transformError = ref('')
const showSnapshots = ref(false)
const snapshots = ref([])
const working = ref(false)
const filteredAssets = computed(() => {
let result = assets.value
@@ -131,17 +271,48 @@ const filteredAssets = computed(() => {
result = result.filter(a => !a.mapx || !a.mapy)
}
if (showUnverifiedOnly.value) {
// Placed, on a level, and never confirmed. An unplaced asset is not
// "unreviewed" - it is simply not on the map yet.
result = result.filter(a => a.mapx && a.levelid && !a.mapverifiedat)
}
if (thisLevelOnly.value && editingLevelId.value) {
// Unplaced assets stay visible whatever the level filter says: they are the
// ones you are here to place, and they belong to no level yet.
result = result.filter(a => !a.mapx || a.levelid === editingLevelId.value)
}
const term = search.value.trim().toLowerCase()
if (term) {
result = result.filter(a =>
(a.name || '').toLowerCase().includes(term) ||
(a.assetnumber || '').toLowerCase().includes(term))
}
return result
})
// Only markers on the level being drawn. Markers from another level would appear
// at coordinates that mean nothing on this one.
const placedAssets = computed(() => {
return assets.value.filter(a => a.mapx && a.mapy)
return assets.value.filter(a => a.mapx && a.mapy &&
a.levelid === editingLevelId.value)
})
const unverifiedCount = computed(() =>
assets.value.filter(a => a.mapx && a.levelid && !a.mapverifiedat).length)
onMounted(async () => {
await loadMapConfig()
editingLevelId.value = mapConfig.defaultlevelid
await loadAssets()
})
watch(editingLevelId, (levelid) => {
if (levelid) setCurrentLevel(levelid)
})
async function loadAssets() {
loading.value = true
try {
@@ -154,6 +325,80 @@ async function loadAssets() {
}
}
function openTransform() {
preview.value = null
transformError.value = ''
landmarks.value = [{}, {}]
showTransform.value = true
}
async function runTransform(dryrun) {
working.value = true
transformError.value = ''
try {
const { data } = await mapPositionsApi.transform({
levelid: editingLevelId.value,
landmarks: landmarks.value.filter(mark =>
[mark.fromx, mark.fromy, mark.tox, mark.toy].every(
value => value !== undefined && value !== null && value !== '')),
dryrun,
})
if (dryrun) {
preview.value = data.data
} else {
showTransform.value = false
preview.value = null
await loadAssets()
// Straight into the review queue: every moved marker is now unconfirmed,
// and that is the work the transform created.
showUnverifiedOnly.value = true
toast.success(data.message || 'Markers moved')
}
} catch (err) {
transformError.value = err?.response?.data?.data?.error?.message
|| 'The transform could not be applied'
} finally {
working.value = false
}
}
async function openSnapshots() {
showSnapshots.value = true
try {
const { data } = await mapPositionsApi.snapshots()
snapshots.value = data.data || []
} catch (err) {
snapshots.value = []
}
}
async function restore(snapshot) {
if (!confirm(`Restore ${snapshot.assetcount} marker position(s) from ` +
`"${snapshot.reason}"? The current positions are snapshotted first.`)) return
working.value = true
try {
const { data } = await mapPositionsApi.restore(snapshot.snapshotid)
await loadAssets()
await openSnapshots()
toast.success(data.message || 'Positions restored')
} catch (err) {
toast.error('Could not restore that snapshot')
} finally {
working.value = false
}
}
// Confirm a marker is in the right place without moving it - the common case in
// a review pass, and the reason the queue empties.
async function markReviewed(asset) {
try {
await mapPositionsApi.verify([asset.assetid])
asset.mapverifiedat = new Date().toISOString()
} catch (err) {
toast.error('Could not mark that reviewed')
}
}
function getTypeIcon(assettype) {
const icons = {
'machine': Cog,
@@ -184,16 +429,23 @@ async function savePosition() {
if (!selectedAsset.value || !pickedPosition.value) return
try {
await assetsApi.update(selectedAsset.value.assetid, {
// Through the bulk endpoint, which requires a level and stamps the review
// state: placing a marker by hand IS the confirmation, and a snapshot is
// taken so the placement can be undone.
await mapPositionsApi.setPositions([{
assetid: selectedAsset.value.assetid,
mapx: Math.round(pickedPosition.value.left),
mapy: Math.round(pickedPosition.value.top)
})
mapy: Math.round(pickedPosition.value.top),
levelid: editingLevelId.value,
}])
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapx = Math.round(pickedPosition.value.left)
asset.mapy = Math.round(pickedPosition.value.top)
asset.levelid = editingLevelId.value
asset.mapverifiedat = new Date().toISOString()
}
selectedAsset.value = null
@@ -409,4 +661,31 @@ function cancelEdit() {
text-align: center;
color: var(--text-light);
}
.badge-review {
background: var(--warning);
color: #1a1a1a;
border: none;
cursor: pointer;
}
.badge-other-level {
background: var(--bg);
color: var(--text-light);
border: 1px solid var(--border);
}
.transform-preview {
margin-top: 1rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
}
.transform-preview h4 { margin: 0 0 0.5rem; }
.mono { font-family: monospace; }
.warn { color: var(--warning); }
.modal-wide { min-width: 44rem; }
.input-hint { display: block; color: var(--text-light); font-size: 0.8rem; }
</style>

View File

@@ -82,7 +82,7 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
import { loadMapConfig, blueprintUrlFor, dimensionsFor, levelName, state as mapConfig } from '@/composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
import { getSubtypeId } from '../utils/mapColors'
@@ -245,9 +245,12 @@ async function exportPdf() {
// 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'),
mapWidth: mapConfig.width,
mapHeight: mapConfig.height,
blueprintUrl: blueprintUrlFor('light', mapConfig.currentlevelid),
mapWidth: dimensionsFor(mapConfig.currentlevelid).width,
mapHeight: dimensionsFor(mapConfig.currentlevelid).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(mapConfig.currentlevelid),
selectedType: selectedType.value,
subtypeColors: subtypeColorMap.value,
subtypeNames: subtypeNameMap.value,

View File

@@ -1,479 +1,485 @@
<template>
<div class="search-results">
<div class="page-header">
<h2>Search Results</h2>
<span v-if="results.length" class="results-count">
{{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}"
</span>
</div>
<div class="search-box">
<input
v-model="searchInput"
type="text"
class="form-control"
placeholder="Search machines, applications, knowledge base, IPs, hostnames..."
@keyup.enter="performSearch"
/>
<button class="btn btn-primary" @click="performSearch">Search</button>
</div>
<div v-if="results.length" class="filter-buttons">
<button
v-for="filter in filterList"
:key="filter.key"
class="filter-btn"
:class="{ active: activeFilter === filter.key }"
@click="activeFilter = filter.key"
>
{{ filter.label }}
<span class="filter-count">{{ getFilterCount(filter.key) }}</span>
</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Searching...</div>
<template v-else-if="query">
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
No {{ activeFilter }} results for "{{ query }}"
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
</div>
<div v-else-if="results.length === 0" class="no-results">
No results found for "{{ query }}"
</div>
<div v-else class="results-list">
<div
v-for="result in filteredResults"
:key="`${result.type}-${result.id}`"
:id="`result-${result.type}-${result.id}`"
class="result-item"
:class="{ highlighted: highlightId === `${result.type}-${result.id}` }"
>
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
<div class="result-content">
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
{{ result.title }}
</router-link>
<a
v-else
href="#"
class="result-title"
@click.prevent="openKBArticle(result)"
>
{{ result.title }}
</a>
<div class="result-meta">
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
<span v-if="result.location" class="result-location">{{ result.location }}</span>
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
</div>
</div>
<button
class="share-btn"
@click="shareResult(result)"
:title="copiedId === `${result.type}-${result.id}` ? 'Copied!' : 'Copy link'"
>
{{ copiedId === `${result.type}-${result.id}` ? 'Copied' : 'Share' }}
</button>
</div>
</div>
</template>
<div v-else class="no-results">
Enter a search term to find machines, applications, printers, knowledge base articles, IPs, and more.
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const results = ref([])
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
const typeCounts = ref({})
const totalAll = ref(0)
const highlightId = ref(null)
const copiedId = ref(null)
const typeLabels = {
machine: 'Machine',
pc: 'PC',
computer: 'PC',
application: 'App',
knowledgebase: 'KB',
printer: 'Printer',
network_device: 'Network',
measuring_tool: 'Measuring Tool',
employee: 'Employee',
notification: 'Notice',
subnet: 'Subnet'
}
const filterTypeMap = {
all: null,
machines: ['machine'],
computers: ['computer'],
printers: ['printer'],
network: ['network_device', 'subnet'],
measuringtools: ['measuring_tool'],
applications: ['application'],
knowledgebase: ['knowledgebase'],
notifications: ['notification'],
employees: ['employee']
}
const filterList = [
{ key: 'all', label: 'All' },
{ key: 'machines', label: 'Machines' },
{ key: 'computers', label: 'PCs' },
{ key: 'printers', label: 'Printers' },
{ key: 'network', label: 'Network' },
{ key: 'measuringtools', label: 'Measuring Tools' },
{ key: 'applications', label: 'Apps' },
{ key: 'knowledgebase', label: 'KB' },
{ key: 'notifications', label: 'Notices' },
{ key: 'employees', label: 'Employees' }
]
function typeLabel(type) {
return typeLabels[type] || type
}
function getFilterCount(filterKey) {
if (filterKey === 'all') return totalAll.value || results.value.length
const types = filterTypeMap[filterKey]
if (!types) return 0
return types.reduce((sum, t) => sum + (typeCounts.value[t] || 0), 0)
}
const filteredResults = computed(() => {
if (activeFilter.value === 'all') return results.value
const types = filterTypeMap[activeFilter.value]
if (!types) return results.value
return results.value.filter(r => types.includes(r.type))
})
async function search(q) {
if (!q || q.length < 2) {
results.value = []
return
}
loading.value = true
activeFilter.value = 'all'
try {
const response = await searchApi.search(q)
const data = response.data.data
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
window.open(data.redirect.url, '_blank')
query.value = q
results.value = []
loading.value = false
return
}
// Smart redirect - auto-navigate to exact match
if (data?.redirect) {
router.replace(data.redirect.url)
return
}
results.value = data?.results || []
typeCounts.value = data?.counts || {}
totalAll.value = data?.total_all || results.value.length
query.value = q
} catch (error) {
console.error('Search error:', error)
results.value = []
} finally {
loading.value = false
}
}
function performSearch() {
if (searchInput.value.trim()) {
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
}
}
async function openKBArticle(result) {
try {
await knowledgebaseApi.trackClick(result.id)
if (result.linkurl) {
window.open(result.linkurl, '_blank')
} else {
router.push(result.url)
}
} catch (error) {
console.error('Error tracking click:', error)
if (result.linkurl) {
window.open(result.linkurl, '_blank')
} else {
router.push(result.url)
}
}
}
function shareResult(result) {
const url = new URL(window.location.href)
url.searchParams.set('highlight', `${result.type}-${result.id}`)
navigator.clipboard.writeText(url.toString()).then(() => {
copiedId.value = `${result.type}-${result.id}`
setTimeout(() => { copiedId.value = null }, 2000)
})
}
function scrollToHighlight() {
if (highlightId.value) {
nextTick(() => {
const el = document.getElementById(`result-${highlightId.value}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setTimeout(() => { highlightId.value = null }, 3000)
}
})
}
}
onMounted(() => {
const q = route.query.q
const hl = route.query.highlight
if (hl) highlightId.value = hl
if (q) {
searchInput.value = q
search(q)
}
})
watch(() => route.query.q, (newQ) => {
if (newQ) {
searchInput.value = newQ
const hl = route.query.highlight
if (hl) highlightId.value = hl
search(newQ)
}
})
watch(results, () => {
scrollToHighlight()
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.page-header h2 {
margin: 0;
}
.results-count {
color: var(--text-light);
font-size: 0.9rem;
}
.search-box {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.search-box input {
flex: 1;
}
.filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 1rem;
}
.filter-btn {
padding: 0.3rem 0.6rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.filter-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.filter-btn:hover:not(.active) {
border-color: var(--primary);
}
.filter-count {
background: rgba(128, 128, 128, 0.15);
padding: 0.1rem 0.35rem;
border-radius: 8px;
font-size: 0.75rem;
min-width: 1.25rem;
text-align: center;
}
.filter-btn.active .filter-count {
background: rgba(255, 255, 255, 0.25);
}
.no-results {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
.results-list {
display: flex;
flex-direction: column;
}
.result-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
transition: background 0.3s ease;
}
.result-item:last-child {
border-bottom: none;
}
.result-item.highlighted {
background: rgba(65, 129, 255, 0.08);
border-left: 3px solid var(--primary);
}
.result-type {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.2rem 0.45rem;
border-radius: 4px;
min-width: 65px;
text-align: center;
flex-shrink: 0;
}
/* Per-domain badge palette. Values live in CSS variables on the container so
the dark theme overrides them in one place (below) instead of restating
every selector. Each badge rule just references its pair. */
.search-results {
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
}
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
.result-type.pc,
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
.result-content {
flex: 1;
min-width: 0;
}
.result-title {
color: var(--link);
text-decoration: none;
font-weight: 500;
}
.result-title:hover {
text-decoration: underline;
}
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
.result-subtitle {
color: var(--text-light);
}
.result-ticket {
font-family: monospace;
font-size: 0.75rem;
}
.share-btn {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
color: var(--text-light);
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
flex-shrink: 0;
}
.share-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
@media (prefers-color-scheme: dark) {
.search-results {
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
}
}
</style>
<template>
<div class="search-results">
<div class="page-header">
<h2>Search Results</h2>
<span v-if="results.length" class="results-count">
{{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}"
</span>
</div>
<div class="search-box">
<input
v-model="searchInput"
type="text"
class="form-control"
placeholder="Search machines, applications, knowledge base, IPs, hostnames..."
@keyup.enter="performSearch"
/>
<button class="btn btn-primary" @click="performSearch">Search</button>
</div>
<div v-if="results.length" class="filter-buttons">
<button
v-for="filter in filterList"
:key="filter.key"
class="filter-btn"
:class="{ active: activeFilter === filter.key }"
@click="activeFilter = filter.key"
>
{{ filter.label }}
<span class="filter-count">{{ getFilterCount(filter.key) }}</span>
</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Searching...</div>
<template v-else-if="query">
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
No {{ activeFilter }} results for "{{ query }}"
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
</div>
<div v-else-if="results.length === 0" class="no-results">
No results found for "{{ query }}"
</div>
<div v-else class="results-list">
<div
v-for="result in filteredResults"
:key="`${result.type}-${result.id}`"
:id="`result-${result.type}-${result.id}`"
class="result-item"
:class="{ highlighted: highlightId === `${result.type}-${result.id}` }"
>
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
<div class="result-content">
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
{{ result.title }}
</router-link>
<a
v-else
href="#"
class="result-title"
@click.prevent="openKBArticle(result)"
>
{{ result.title }}
</a>
<div class="result-meta">
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
<span v-if="result.location" class="result-location">{{ result.location }}</span>
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
</div>
</div>
<button
class="share-btn"
@click="shareResult(result)"
:title="copiedId === `${result.type}-${result.id}` ? 'Copied!' : 'Copy link'"
>
{{ copiedId === `${result.type}-${result.id}` ? 'Copied' : 'Share' }}
</button>
</div>
</div>
</template>
<div v-else class="no-results">
Enter a search term to find machines, applications, printers, knowledge base articles, IPs, and more.
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const results = ref([])
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
const typeCounts = ref({})
const totalAll = ref(0)
const highlightId = ref(null)
const copiedId = ref(null)
const typeLabels = {
machine: 'Machine',
pc: 'PC',
computer: 'PC',
application: 'App',
knowledgebase: 'KB',
printer: 'Printer',
network_device: 'Network',
measuring_tool: 'Measuring Tool',
employee: 'Employee',
notification: 'Notice',
subnet: 'Subnet',
usb_device: 'USB',
printed_item: 'Printed Part'
}
const filterTypeMap = {
all: null,
machines: ['machine'],
computers: ['computer'],
printers: ['printer'],
network: ['network_device', 'subnet'],
measuringtools: ['measuring_tool'],
applications: ['application'],
knowledgebase: ['knowledgebase'],
notifications: ['notification'],
employees: ['employee'],
usb: ['usb_device'],
printedparts: ['printed_item']
}
const filterList = [
{ key: 'all', label: 'All' },
{ key: 'machines', label: 'Machines' },
{ key: 'computers', label: 'PCs' },
{ key: 'printers', label: 'Printers' },
{ key: 'network', label: 'Network' },
{ key: 'measuringtools', label: 'Measuring Tools' },
{ key: 'applications', label: 'Apps' },
{ key: 'knowledgebase', label: 'KB' },
{ key: 'notifications', label: 'Notices' },
{ key: 'employees', label: 'Employees' },
{ key: 'usb', label: 'USB' },
{ key: 'printedparts', label: 'Printed Parts' }
]
function typeLabel(type) {
return typeLabels[type] || type
}
function getFilterCount(filterKey) {
if (filterKey === 'all') return totalAll.value || results.value.length
const types = filterTypeMap[filterKey]
if (!types) return 0
return types.reduce((sum, t) => sum + (typeCounts.value[t] || 0), 0)
}
const filteredResults = computed(() => {
if (activeFilter.value === 'all') return results.value
const types = filterTypeMap[activeFilter.value]
if (!types) return results.value
return results.value.filter(r => types.includes(r.type))
})
async function search(q) {
if (!q || q.length < 2) {
results.value = []
return
}
loading.value = true
activeFilter.value = 'all'
try {
const response = await searchApi.search(q)
const data = response.data.data
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
window.open(data.redirect.url, '_blank')
query.value = q
results.value = []
loading.value = false
return
}
// Smart redirect - auto-navigate to exact match
if (data?.redirect) {
router.replace(data.redirect.url)
return
}
results.value = data?.results || []
typeCounts.value = data?.counts || {}
totalAll.value = data?.total_all || results.value.length
query.value = q
} catch (error) {
console.error('Search error:', error)
results.value = []
} finally {
loading.value = false
}
}
function performSearch() {
if (searchInput.value.trim()) {
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
}
}
async function openKBArticle(result) {
try {
await knowledgebaseApi.trackClick(result.id)
if (result.linkurl) {
window.open(result.linkurl, '_blank')
} else {
router.push(result.url)
}
} catch (error) {
console.error('Error tracking click:', error)
if (result.linkurl) {
window.open(result.linkurl, '_blank')
} else {
router.push(result.url)
}
}
}
function shareResult(result) {
const url = new URL(window.location.href)
url.searchParams.set('highlight', `${result.type}-${result.id}`)
navigator.clipboard.writeText(url.toString()).then(() => {
copiedId.value = `${result.type}-${result.id}`
setTimeout(() => { copiedId.value = null }, 2000)
})
}
function scrollToHighlight() {
if (highlightId.value) {
nextTick(() => {
const el = document.getElementById(`result-${highlightId.value}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setTimeout(() => { highlightId.value = null }, 3000)
}
})
}
}
onMounted(() => {
const q = route.query.q
const hl = route.query.highlight
if (hl) highlightId.value = hl
if (q) {
searchInput.value = q
search(q)
}
})
watch(() => route.query.q, (newQ) => {
if (newQ) {
searchInput.value = newQ
const hl = route.query.highlight
if (hl) highlightId.value = hl
search(newQ)
}
})
watch(results, () => {
scrollToHighlight()
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.page-header h2 {
margin: 0;
}
.results-count {
color: var(--text-light);
font-size: 0.9rem;
}
.search-box {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.search-box input {
flex: 1;
}
.filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 1rem;
}
.filter-btn {
padding: 0.3rem 0.6rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.filter-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.filter-btn:hover:not(.active) {
border-color: var(--primary);
}
.filter-count {
background: rgba(128, 128, 128, 0.15);
padding: 0.1rem 0.35rem;
border-radius: 8px;
font-size: 0.75rem;
min-width: 1.25rem;
text-align: center;
}
.filter-btn.active .filter-count {
background: rgba(255, 255, 255, 0.25);
}
.no-results {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
.results-list {
display: flex;
flex-direction: column;
}
.result-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
transition: background 0.3s ease;
}
.result-item:last-child {
border-bottom: none;
}
.result-item.highlighted {
background: rgba(65, 129, 255, 0.08);
border-left: 3px solid var(--primary);
}
.result-type {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.2rem 0.45rem;
border-radius: 4px;
min-width: 65px;
text-align: center;
flex-shrink: 0;
}
/* Per-domain badge palette. Values live in CSS variables on the container so
the dark theme overrides them in one place (below) instead of restating
every selector. Each badge rule just references its pair. */
.search-results {
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
}
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
.result-type.pc,
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
.result-content {
flex: 1;
min-width: 0;
}
.result-title {
color: var(--link);
text-decoration: none;
font-weight: 500;
}
.result-title:hover {
text-decoration: underline;
}
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
.result-subtitle {
color: var(--text-light);
}
.result-ticket {
font-family: monospace;
font-size: 0.75rem;
}
.share-btn {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
color: var(--text-light);
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
flex-shrink: 0;
}
.share-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
@media (prefers-color-scheme: dark) {
.search-results {
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
}
}
</style>

View File

@@ -1,101 +1,416 @@
<template>
<div>
<div class="page-header">
<h2>Floor Map</h2>
<h2>Floor Maps</h2>
<button class="btn btn-secondary" @click="showAddBuilding = true">Add building</button>
</div>
<div class="section-card">
<div class="setting-group">
<h3>Facility Blueprint</h3>
<p class="setting-description">
The floor-plan image and its pixel dimensions for this facility. Map
markers are positioned against these dimensions, so the width and
height must match the native size of the blueprint image. Leave the
image paths at their defaults to use the bundled sitemap.
</p>
<p class="setting-description">
Each level is one drawing with its own blueprint and its own pixel size.
Marker positions are pixels in the level's own space, so the level id below
is what a position belongs to - it is worth knowing when you run a
transform or ask about a marker that is in the wrong place.
</p>
<div class="setting-row">
<label>
<span>Blueprint image (light theme)</span>
<input
type="text"
v-model="settings.map_blueprint_light"
placeholder="/static/images/sitemap2025-light.png"
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_light" :src="withBase(settings.map_blueprint_light)" class="map-thumb" alt="light blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
</label>
<div v-if="loading" class="empty">Loading...</div>
<div v-for="building in buildings" :key="building.buildingid" class="section-card">
<div class="building-header">
<input
v-model="building.buildingname"
class="building-name"
@blur="renameBuilding(building)"
:disabled="saving"
/>
<span class="muted">{{ building.levels.length }} level(s)</span>
<button class="btn btn-small" @click="startAddLevel(building)">Add level</button>
</div>
<table class="data-table">
<thead>
<tr>
<th title="What a marker position on this level refers to">Level id</th>
<th>Name</th>
<th>Order</th>
<th>Native size</th>
<th>Markers</th>
<th>Light</th>
<th>Dark</th>
<th>Default</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="level in building.levels" :key="level.levelid">
<td><code class="levelid">{{ level.levelid }}</code></td>
<td>
<input
v-model="level.levelname"
class="form-control"
@blur="renameLevel(level)"
:disabled="saving"
/>
</td>
<td>
<input
v-model.number="level.sortorder"
type="number"
class="form-control order-input"
@blur="saveLevel(level, { sortorder: level.sortorder })"
:disabled="saving"
title="Lower sorts first. Ground 0, first floor 1, mezzanine 5 between them later."
/>
</td>
<td>
<span class="mono">{{ level.mapwidth }} x {{ level.mapheight }}</span>
<small v-if="level.assetcount" class="input-hint">
fixed while {{ level.assetcount }} marker(s) are placed
</small>
<input
v-else
v-model.number="level.mapwidth"
type="number"
class="form-control size-input"
@blur="saveLevel(level, { mapwidth: level.mapwidth, mapheight: level.mapheight })"
:disabled="saving"
/>
</td>
<td>
<span :class="{ 'muted': !level.assetcount }">{{ level.assetcount }}</span>
</td>
<td>
<img v-if="level.blueprintlight" :src="withBase(level.blueprintlight)"
class="map-thumb" alt="light blueprint" />
<input type="file" accept="image/*" class="file-input"
@change="upload(level, 'light', $event)" :disabled="uploading" />
</td>
<td>
<img v-if="level.blueprintdark" :src="withBase(level.blueprintdark)"
class="map-thumb map-thumb-dark" alt="dark blueprint" />
<input type="file" accept="image/*" class="file-input"
@change="upload(level, 'dark', $event)" :disabled="uploading" />
</td>
<td>
<input type="radio" :checked="level.isdefault" name="defaultlevel"
@change="saveLevel(level, { isdefault: true })"
title="Where an asset with no level lands, and what the map opens on" />
</td>
<td class="actions">
<button class="btn btn-small btn-danger" @click="remove(level)"
:disabled="saving">Remove</button>
</td>
</tr>
<tr v-if="!building.levels.length">
<td colspan="9" class="empty">No levels yet. Add one, then upload its blueprint.</td>
</tr>
</tbody>
</table>
</div>
<!-- Add building -->
<div v-if="showAddBuilding" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>Add building</h3></div>
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input v-model="newBuilding" class="form-control" placeholder="Annex"
@keyup.enter="addBuilding" />
</div>
</div>
<div class="setting-row">
<label>
<span>Blueprint image (dark theme)</span>
<input
type="text"
v-model="settings.map_blueprint_dark"
placeholder="/static/images/sitemap2025-dark.png"
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_dark" :src="withBase(settings.map_blueprint_dark)" class="map-thumb map-thumb-dark" alt="dark blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
</label>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showAddBuilding = false">Cancel</button>
<button class="btn btn-primary" @click="addBuilding" :disabled="!newBuilding.trim()">Add</button>
</div>
</div>
</div>
<div class="setting-row">
<label>
<span>Blueprint width (pixels)</span>
<input
type="number"
v-model="settings.map_width"
min="1"
placeholder="3300"
@blur="saveSetting('map_width', settings.map_width)"
:disabled="saving"
>
<small class="input-hint">Native pixel width of the blueprint image</small>
</label>
<!-- Add level -->
<div v-if="addLevelFor" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>Add level to {{ addLevelFor.buildingname }}</h3></div>
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input v-model="newLevel.levelname" class="form-control"
placeholder="Second floor" @keyup.enter="addLevel" />
<small class="input-hint">
Whatever the building calls it. Basement, Ground, Mezzanine, Roof.
</small>
</div>
<div class="form-group">
<label>Sort order</label>
<input v-model.number="newLevel.sortorder" type="number" class="form-control" />
<small class="input-hint">
Lower sorts first, and gaps are fine - leaving room lets a mezzanine
slot in later without renumbering anything.
</small>
</div>
<p class="input-hint">
Upload the blueprint after creating it. An empty level takes its pixel
size from the image, so there is nothing to measure by hand.
</p>
</div>
<div class="setting-row">
<label>
<span>Blueprint height (pixels)</span>
<input
type="number"
v-model="settings.map_height"
min="1"
placeholder="2550"
@blur="saveSetting('map_height', settings.map_height)"
:disabled="saving"
>
<small class="input-hint">Native pixel height of the blueprint image</small>
</label>
<div class="modal-footer">
<button class="btn btn-secondary" @click="addLevelFor = null">Cancel</button>
<button class="btn btn-primary" @click="addLevel"
:disabled="!newLevel.levelname.trim()">Add</button>
</div>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="settings-success">{{ success }}</div>
<div v-if="notice" class="settings-success">{{ notice }}</div>
</div>
</template>
<script setup>
import { withBase } from '../../utils/basePath'
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
// Buildings and levels admin (ADR-017). This page replaced four site-wide
// settings that described a single blueprint, which could not express a second
// level drawn at a different size, let alone a second building.
//
// The level id is deliberately on screen. It is what `assets.levelid` points at,
// what a landmark transform takes as an argument, and the first thing worth
// knowing when a marker draws on the wrong drawing.
import { ref, onMounted } from 'vue'
const {
settings, saving, mapUploading, error, success,
loadSettings, saveSetting, uploadBlueprint,
} = useSystemSettings()
import { mapLevelsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import { reloadMapConfig } from '@/composables/mapConfig'
onMounted(loadSettings)
const buildings = ref([])
const loading = ref(true)
const saving = ref(false)
const uploading = ref(false)
const error = ref('')
const notice = ref('')
const showAddBuilding = ref(false)
const newBuilding = ref('')
const addLevelFor = ref(null)
const newLevel = ref({ levelname: '', sortorder: 0 })
onMounted(load)
async function load() {
loading.value = true
try {
const { data } = await mapLevelsApi.list()
buildings.value = data.data.buildings || []
} catch (err) {
error.value = message(err, 'Could not load the levels')
} finally {
loading.value = false
}
}
function message(err, fallback) {
return err?.response?.data?.data?.error?.message || fallback
}
function report(text) {
notice.value = text
error.value = ''
// Long enough to read a sentence about what did not happen.
setTimeout(() => { notice.value = '' }, 8000)
}
async function addBuilding() {
const name = newBuilding.value.trim()
if (!name) return
saving.value = true
try {
await mapLevelsApi.createBuilding({
buildingname: name,
sortorder: buildings.value.length,
})
showAddBuilding.value = false
newBuilding.value = ''
await load()
report(`Building "${name}" added. Add its levels next.`)
} catch (err) {
error.value = message(err, 'Could not add the building')
} finally {
saving.value = false
}
}
function startAddLevel(building) {
addLevelFor.value = building
// Default to one past the last, so the common case needs no thought and the
// uncommon one is still editable.
newLevel.value = {
levelname: '',
sortorder: (building.levels.at(-1)?.sortorder ?? -1) + 1,
}
}
async function addLevel() {
const name = newLevel.value.levelname.trim()
if (!name || !addLevelFor.value) return
saving.value = true
try {
const { data } = await mapLevelsApi.create({
buildingid: addLevelFor.value.buildingid,
levelname: name,
sortorder: newLevel.value.sortorder,
})
addLevelFor.value = null
await load()
report(`"${name}" created as level ${data.data.levelid}. Upload its blueprint to set its size.`)
} catch (err) {
error.value = message(err, 'Could not add the level')
} finally {
saving.value = false
}
}
async function renameBuilding(building) {
const name = (building.buildingname || '').trim()
if (!name) {
await load()
return
}
saving.value = true
try {
await mapLevelsApi.updateBuilding(building.buildingid, { buildingname: name })
report(`Building renamed to "${name}".`)
} catch (err) {
error.value = message(err, 'Could not rename the building')
await load()
} finally {
saving.value = false
}
}
async function renameLevel(level) {
const name = (level.levelname || '').trim()
if (!name) {
await load()
return
}
await saveLevel(level, { levelname: name })
}
async function saveLevel(level, payload) {
saving.value = true
try {
const { data } = await mapLevelsApi.update(level.levelid, payload)
// The server reports when a change leaves existing positions in an old
// coordinate space. Surfacing that verbatim matters more than a tidy
// message: it names the markers that are now wrong.
const warnings = data.data?.warnings
await load()
await reloadMapConfig()
report(warnings?.length ? warnings.join(' ') : 'Saved.')
} catch (err) {
error.value = message(err, 'Could not save the level')
await load()
} finally {
saving.value = false
}
}
async function upload(level, theme, event) {
const file = event.target.files?.[0]
if (!file) return
uploading.value = true
try {
const { data } = await mapLevelsApi.uploadBlueprint(level.levelid, theme, file)
await load()
await reloadMapConfig()
// sizenote is the interesting case: the image disagrees with the stored
// dimensions AND markers are already placed, so the server refused to
// change the coordinate space out from under them.
report(data.data.sizenote
|| `Blueprint uploaded (${data.data.detectedwidth} x ${data.data.detectedheight}).`)
} catch (err) {
error.value = message(err, 'Could not upload the blueprint')
} finally {
uploading.value = false
event.target.value = ''
}
}
async function remove(level) {
const placed = level.assetcount
? ` It has ${level.assetcount} marker(s) on it, which the server will refuse.`
: ''
if (!confirm(`Remove "${level.levelname}" (level ${level.levelid})?${placed}`)) return
saving.value = true
try {
await mapLevelsApi.remove(level.levelid)
await load()
report(`Level ${level.levelid} removed.`)
} catch (err) {
error.value = message(err, 'Could not remove the level')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.setting-description {
color: var(--text-light);
max-width: 60rem;
margin-bottom: 1rem;
}
.building-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.building-name {
font-size: 1.05rem;
font-weight: 600;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
padding: 0.25rem 0.4rem;
color: var(--text);
}
.building-name:hover,
.building-name:focus {
border-color: var(--border);
background: var(--bg);
}
.levelid {
font-family: monospace;
font-size: 0.95rem;
padding: 0.1rem 0.4rem;
background: var(--bg);
border-radius: 3px;
}
.mono { font-family: monospace; }
.order-input { width: 4.5rem; }
.size-input { width: 6rem; }
.file-input { display: block; margin-top: 0.25rem; font-size: 0.75rem; }
.map-thumb {
max-width: 5rem;
max-height: 3rem;
border: 1px solid var(--border);
display: block;
}
.map-thumb-dark { background: #222; }
.muted { color: var(--text-light); }
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
.input-hint { display: block; color: var(--text-light); font-size: 0.75rem; }
</style>