Files
shopdb-flask/frontend/src/views/MapEditor.vue
cproudlock afd3dce493
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
Give the map editor's search box room for its own placeholder
The previous commit stopped the panel header from colliding with the "Assets"
heading, but left the search box sharing a row with the type dropdown: 139px of
input for a placeholder needing 216px, so it still read "Search name o". A
control whose own label does not fit is not a narrower control, it is an
unlabelled one.

One control per row in a 318px panel. Measured in the browser: the input is now
286px against 216px of text, so the placeholder reads in full.
2026-08-17 15:24:37 -04:00

709 lines
21 KiB
Vue

<template>
<div class="map-editor">
<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>
<div class="editor-layout">
<!-- Asset List Panel -->
<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>
<option value="computer">Computers</option>
<option value="printer">Printers</option>
<option value="network_device">Network Devices</option>
<option value="measuring_tool">Measuring Tools</option>
</select>
</div>
<div class="asset-filter">
<label class="filter-checkbox">
<input type="checkbox" v-model="showUnplacedOnly" />
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>
<div class="asset-list">
<div
v-for="asset in filteredAssets"
:key="asset.assetid"
class="asset-item"
:class="{
selected: selectedAsset?.assetid === asset.assetid,
placed: asset.mapx && asset.mapy,
unplaced: !asset.mapx || !asset.mapy
}"
@click="selectAsset(asset)"
>
<span class="asset-icon"><component :is="getTypeIcon(asset.assettype)" :size="16" /></span>
<div class="asset-info">
<div class="asset-name">{{ asset.name || asset.assetnumber }}</div>
<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>
<div v-if="filteredAssets.length === 0" class="empty">
No assets found.
</div>
</div>
</div>
<!-- Map Panel -->
<div class="map-panel">
<div class="map-toolbar" v-if="selectedAsset">
<span class="selected-info">
<strong>Selected:</strong> {{ selectedAsset.name || selectedAsset.assetnumber }}
</span>
<span class="position-info" v-if="pickedPosition">
Position: ({{ Math.round(pickedPosition.left) }}, {{ Math.round(pickedPosition.top) }})
</span>
<div class="toolbar-actions">
<button
class="btn btn-primary"
:disabled="!pickedPosition"
@click="savePosition"
>
Save Position
</button>
<button
class="btn btn-danger"
v-if="selectedAsset.mapx && selectedAsset.mapy"
@click="clearPosition"
>
Remove from Map
</button>
<button class="btn btn-secondary" @click="cancelEdit">Cancel</button>
</div>
</div>
<div class="map-toolbar" v-else>
<span class="instruction">Select an asset from the list to place it on the map</span>
</div>
<ShopFloorMap
ref="mapRef"
:machines="placedAssets"
:assetTypeMode="true"
: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, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
import ShopFloorMap from '../components/ShopFloorMap.vue'
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()
const assets = ref([])
const loading = ref(true)
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
if (filterType.value) {
result = result.filter(a => a.assettype === filterType.value)
}
if (showUnplacedOnly.value) {
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 &&
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 {
const response = await assetsApi.getMap()
assets.value = response.data.data?.assets || []
} catch (error) {
console.error('Failed to load assets:', error)
} finally {
loading.value = false
}
}
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,
'computer': Monitor,
'printer': Printer,
'network_device': Globe,
'measuring_tool': Ruler
}
return icons[assettype] || Package
}
function selectAsset(asset) {
selectedAsset.value = asset
pickedPosition.value = asset.mapx && asset.mapy
? { left: asset.mapx, top: asset.mapy }
: null
}
function handlePositionPicked(position) {
pickedPosition.value = position
}
function handleMarkerClick(asset) {
selectAsset(asset)
}
async function savePosition() {
if (!selectedAsset.value || !pickedPosition.value) return
try {
// 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),
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
pickedPosition.value = null
} catch (error) {
console.error('Failed to save position:', error)
toast.error('Failed to save position')
}
}
async function clearPosition() {
if (!selectedAsset.value) return
if (!confirm(`Remove ${selectedAsset.value.name || selectedAsset.value.assetnumber} from the map?`)) return
try {
await assetsApi.update(selectedAsset.value.assetid, {
mapx: null,
mapy: null
})
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapx = null
asset.mapy = null
}
selectedAsset.value = null
pickedPosition.value = null
} catch (error) {
console.error('Failed to clear position:', error)
toast.error('Failed to clear position')
}
}
function cancelEdit() {
selectedAsset.value = null
pickedPosition.value = null
}
</script>
<style scoped>
.map-editor {
display: flex;
flex-direction: column;
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.editor-layout {
display: flex;
flex: 1;
gap: 1rem;
min-height: 0;
}
.asset-panel {
width: 320px;
display: flex;
flex-direction: column;
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--border);
overflow: hidden;
}
.panel-header {
/* The heading takes its own row and the three controls share the next one(s).
All four on a single row inside a 320px panel squeezed the search box until
its placeholder read "Search na". */
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.panel-header h3 {
flex: 1 1 100%;
}
.panel-header .form-control {
/* One control per row. Two of them side by side in a 318px panel left the
search box 139px for a placeholder needing 216px, so it read "Search name
o" - a control whose own label does not fit is not a narrower control, it is
an unlabelled one. */
flex: 1 1 100%;
min-width: 0;
}
.panel-header h3 {
margin: 0;
font-size: 1rem;
}
.panel-header select {
width: auto;
padding: 0.375rem 0.5rem;
font-size: 0.8rem;
}
.asset-filter {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
.asset-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.asset-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.asset-item:hover {
background: var(--bg);
}
.asset-item.selected {
background: rgba(65, 129, 255, 0.2);
border: 1px solid var(--primary);
}
.asset-item.unplaced {
opacity: 0.7;
}
.asset-icon {
font-size: 1.5rem;
}
.asset-info {
flex: 1;
min-width: 0;
}
.asset-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.asset-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.badge-sm {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
}
.placed-indicator {
font-size: 0.875rem;
}
.map-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.map-toolbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--border);
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.selected-info {
font-size: 0.9rem;
}
.position-info {
font-size: 0.875rem;
color: var(--text-light);
font-family: monospace;
}
.toolbar-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.instruction {
color: var(--text-light);
font-style: italic;
}
.map-panel :deep(.shopfloor-map) {
flex: 1;
border-radius: 8px;
overflow: hidden;
}
.empty {
padding: 2rem;
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>