Buildings and levels for the floor map, and make every identifier searchable
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:
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
144
frontend/src/composables/mapConfig.spec.js
Normal file
144
frontend/src/composables/mapConfig.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user