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.
709 lines
21 KiB
Vue
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>
|