Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

@@ -0,0 +1,793 @@
<template>
<div class="relationships-section">
<div class="section-header">
<h3 class="section-title">Relationships</h3>
<button
v-if="authStore.isAuthenticated"
class="btn btn-sm btn-primary"
@click="showAddModal = true"
>
Add Relationship
</button>
</div>
<div v-if="loading" class="loading">Loading relationships...</div>
<div v-else-if="!hasRelationships" class="empty-state">
No relationships defined.
</div>
<template v-else>
<!-- Symmetric connections: one direction-blind entry per peer -->
<div v-if="connectedItems.length > 0" class="relationship-group">
<h4 class="group-title">Connected</h4>
<div class="relationship-list">
<div
v-for="item in connectedItems"
:key="item.key"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
<div class="rel-content">
<div class="rel-line">
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
</router-link>
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
</div>
<div class="rel-meta">
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
</div>
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
</div>
<button
v-if="authStore.isAuthenticated"
class="btn-icon delete"
@click="deleteItem(item)"
title="Remove relationship"
>
&times;
</button>
</div>
</div>
</div>
<!-- Directional relationships: natural per-row phrasing, no jargon -->
<div v-if="directionalItems.length > 0" class="relationship-group">
<div class="relationship-list">
<div
v-for="item in directionalItems"
:key="item.key"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
<div class="rel-content">
<div class="rel-line">
<template v-if="item.direction === 'outgoing'">
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
<span class="rel-arrow">-&gt;</span>
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
</router-link>
</template>
<template v-else>
<span class="rel-arrow">&lt;-</span>
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
<span class="rel-from">from</span>
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
</router-link>
</template>
</div>
<div class="rel-meta">
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
</div>
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
</div>
<button
v-if="authStore.isAuthenticated"
class="btn-icon delete"
@click="deleteItem(item)"
title="Remove relationship"
>
&times;
</button>
</div>
</div>
</div>
</template>
<!-- Add Relationship Modal -->
<div v-if="showAddModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<h3>Add Relationship</h3>
<button class="btn-icon" @click="closeModal">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Direction</label>
<select v-model="newRel.direction" class="form-control">
<option value="outgoing">This asset -> Target asset</option>
<option value="incoming">Source asset -> This asset</option>
</select>
</div>
<div class="form-group">
<label>Relationship Type</label>
<select v-model="newRel.relationshiptypeid" class="form-control" required>
<option value="">Select type...</option>
<option
v-for="t in relationshipTypes"
:key="t.relationshiptypeid"
:value="t.relationshiptypeid"
>
{{ t.relationshiptype }}
</option>
</select>
</div>
<div class="form-group">
<label>{{ newRel.direction === 'outgoing' ? 'Target Asset' : 'Source Asset' }}</label>
<div class="asset-search">
<input
type="text"
v-model="assetSearchQuery"
class="form-control"
placeholder="Search assets..."
@input="searchAssets"
/>
<div v-if="assetSearchResults.length > 0" class="search-results">
<div
v-for="asset in assetSearchResults"
:key="asset.assetid"
class="search-result-item"
:class="{ selected: newRel.targetAssetId === asset.assetid }"
@click="selectAsset(asset)"
>
<span class="result-icon">{{ getAssetIcon(asset.assettype) }}</span>
<span class="result-name">{{ asset.name || asset.assetnumber }}</span>
<span class="result-type">{{ asset.assettype }}</span>
</div>
</div>
</div>
<div v-if="selectedAsset" class="selected-asset">
<span class="result-icon">{{ getAssetIcon(selectedAsset.assettype) }}</span>
<span>{{ selectedAsset.name || selectedAsset.assetnumber }}</span>
<button class="btn-icon" @click="clearSelectedAsset">&times;</button>
</div>
</div>
<div class="form-group">
<label>Notes (optional)</label>
<textarea
v-model="newRel.notes"
class="form-control"
rows="2"
placeholder="Additional notes about this relationship..."
></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
<button
class="btn btn-primary"
:disabled="!canSave"
@click="saveRelationship"
>
Add Relationship
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package, Ruler } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api'
import { colorStyle } from '@/utils/colorStyle'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/toast'
import { apiError } from '../utils/apiError'
const toast = useToast()
const props = defineProps({
assetid: {
type: Number,
default: null
},
// Alternative: lookup by machine/asset number
machinenumber: {
type: String,
default: null
}
})
const emit = defineEmits(['updated'])
const authStore = useAuthStore()
const loading = ref(true)
const resolvedAssetId = ref(null)
const lookupFailed = ref(false)
const outgoing = ref([])
const incoming = ref([])
const relationshipTypes = ref([])
function colorForType(name) {
const t = relationshipTypes.value.find(rt => rt.relationshiptype === name)
return t?.color || null
}
const showAddModal = ref(false)
// New relationship form
const newRel = ref({
direction: 'outgoing',
relationshiptypeid: '',
targetAssetId: null,
notes: ''
})
const assetSearchQuery = ref('')
const assetSearchResults = ref([])
const selectedAsset = ref(null)
let searchTimeout = null
const hasRelationships = computed(() => outgoing.value.length > 0 || incoming.value.length > 0)
// Symmetric types collapse to one entry per {peer, type} unordered pair. All
// stored direction rows (both directions, plus any legacy duplicates) fold
// into one displayed entry; its rowIds carries every collapsed relationshipid
// so a delete removes them all.
const connectedItems = computed(() => {
const byPair = new Map()
const rows = [
...outgoing.value.map(rel => ({ rel, peer: rel.targetasset })),
...incoming.value.map(rel => ({ rel, peer: rel.sourceasset })),
]
for (const { rel, peer } of rows) {
if (rel.isdirectional !== false) continue
const selfid = resolvedAssetId.value
const peerid = peer?.assetid
const lo = Math.min(selfid, peerid)
const hi = Math.max(selfid, peerid)
const key = `${rel.relationshiptypeid}:${lo}:${hi}`
const existing = byPair.get(key)
if (existing) {
existing.rowIds.push(rel.relationshipid)
if (!existing.notes && rel.notes) existing.notes = rel.notes
} else {
byPair.set(key, {
key,
rowIds: [rel.relationshipid],
peer,
relationshiptypeid: rel.relationshiptypeid,
relationshiptypename: rel.relationshiptypename,
notes: rel.notes || null,
})
}
}
return Array.from(byPair.values())
})
// Directional types keep one entry per stored row with arrow phrasing.
const directionalItems = computed(() => {
const items = []
for (const rel of outgoing.value) {
if (rel.isdirectional === false) continue
items.push({
key: `out-${rel.relationshipid}`,
rowIds: [rel.relationshipid],
peer: rel.targetasset,
direction: 'outgoing',
relationshiptypeid: rel.relationshiptypeid,
relationshiptypename: rel.relationshiptypename,
notes: rel.notes || null,
})
}
for (const rel of incoming.value) {
if (rel.isdirectional === false) continue
items.push({
key: `in-${rel.relationshipid}`,
rowIds: [rel.relationshipid],
peer: rel.sourceasset,
direction: 'incoming',
relationshiptypeid: rel.relationshiptypeid,
relationshiptypename: rel.relationshiptypename,
notes: rel.notes || null,
})
}
return items
})
const canSave = computed(() => {
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
})
onMounted(async () => {
await resolveAssetId()
if (resolvedAssetId.value) {
await Promise.all([loadRelationships(), loadRelationshipTypes()])
}
})
watch(() => props.assetid, async () => {
await resolveAssetId()
if (resolvedAssetId.value) {
await loadRelationships()
}
})
watch(() => props.machinenumber, async () => {
await resolveAssetId()
if (resolvedAssetId.value) {
await loadRelationships()
}
})
async function resolveAssetId() {
// If assetid is provided directly, use it
if (props.assetid) {
resolvedAssetId.value = props.assetid
lookupFailed.value = false
return
}
// Otherwise, try to look up by machine number
if (props.machinenumber) {
try {
const response = await assetsApi.lookup(props.machinenumber)
resolvedAssetId.value = response.data.data?.assetid
lookupFailed.value = !resolvedAssetId.value
} catch (error) {
resolvedAssetId.value = null
lookupFailed.value = true
loading.value = false
}
}
}
async function loadRelationships() {
if (!resolvedAssetId.value) return
loading.value = true
try {
const response = await assetsApi.getRelationships(resolvedAssetId.value)
outgoing.value = response.data.data?.outgoing || []
incoming.value = response.data.data?.incoming || []
} catch (error) {
console.error('Failed to load relationships:', error)
} finally {
loading.value = false
}
}
async function loadRelationshipTypes() {
try {
const response = await relationshipTypesApi.list()
relationshipTypes.value = response.data.data || []
} catch (error) {
console.error('Failed to load relationship types:', error)
}
}
function searchAssets() {
clearTimeout(searchTimeout)
if (!assetSearchQuery.value || assetSearchQuery.value.length < 2) {
assetSearchResults.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const response = await assetsApi.search(assetSearchQuery.value, { perpage: 10 })
// Filter out the current asset
assetSearchResults.value = (response.data.data || []).filter(
a => a.assetid !== resolvedAssetId.value
)
} catch (error) {
console.error('Failed to search assets:', error)
}
}, 300)
}
function selectAsset(asset) {
selectedAsset.value = asset
newRel.value.targetAssetId = asset.assetid
assetSearchQuery.value = ''
assetSearchResults.value = []
}
function clearSelectedAsset() {
selectedAsset.value = null
newRel.value.targetAssetId = null
}
async function saveRelationship() {
if (!canSave.value) return
try {
const data = {
relationshiptypeid: parseInt(newRel.value.relationshiptypeid),
notes: newRel.value.notes || null
}
if (newRel.value.direction === 'outgoing') {
data.sourceassetid = resolvedAssetId.value
data.targetassetid = newRel.value.targetAssetId
} else {
data.sourceassetid = newRel.value.targetAssetId
data.targetassetid = resolvedAssetId.value
}
await assetsApi.createRelationship(data)
await loadRelationships()
closeModal()
emit('updated')
} catch (error) {
console.error('Failed to create relationship:', error)
toast.error(apiError(error, 'Failed to create relationship'))
}
}
// Delete every collapsed direction row behind a displayed entry.
async function deleteItem(item) {
if (!confirm('Remove this relationship?')) return
try {
await Promise.all(item.rowIds.map(id => assetsApi.deleteRelationship(id)))
await loadRelationships()
emit('updated')
} catch (error) {
console.error('Failed to delete relationship:', error)
toast.error('Failed to delete relationship')
}
}
function closeModal() {
showAddModal.value = false
newRel.value = {
direction: 'outgoing',
relationshiptypeid: '',
targetAssetId: null,
notes: ''
}
selectedAsset.value = null
assetSearchQuery.value = ''
assetSearchResults.value = []
}
function getAssetIcon(assettype) {
const icons = {
'machine': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe,
'measuring_tool': Ruler
}
return icons[assettype] || Package
}
function getAssetRoute(asset) {
if (!asset) return '#'
const routeMap = {
'machine': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'measuring_tool': '/measuringtools'
}
// serializer sends assettypename; old shape used assettype
const typename = asset.assettypename || asset.assettype
const basePath = routeMap[typename] || '/assets'
// pluginid is the extension-table id the detail routes key on
const id = asset.pluginid
|| asset.typedata?.networkdeviceid
|| asset.typedata?.machineid
|| asset.assetid
return `${basePath}/${id}`
}
</script>
<style scoped>
.relationships-section {
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--border);
padding: 1.25rem;
/* Match .section-card spacing so relationships is its own card with a gap
below (not visually merged with the Notes card) and does not split across
a multicol break. */
margin-bottom: 25px;
break-inside: avoid;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.empty-state {
color: var(--text-light);
font-style: italic;
padding: 1rem 0;
}
.relationship-group {
margin-bottom: 1.5rem;
}
.relationship-group:last-child {
margin-bottom: 0;
}
.group-title {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.relationship-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.relationship-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
}
.rel-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.rel-content {
flex: 1;
min-width: 0;
}
.rel-name {
font-weight: 500;
color: var(--link);
text-decoration: none;
}
.rel-name:hover {
text-decoration: underline;
}
.rel-line {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.rel-arrow {
font-family: monospace;
font-weight: 600;
color: var(--text-light);
}
.rel-from {
font-size: 0.8rem;
color: var(--text-light);
}
.rel-meta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.badge-outline {
background: transparent;
border: 1px solid var(--primary);
color: var(--primary);
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
.rel-type-badge {
font-size: 0.75rem;
color: var(--text-light);
}
.rel-notes {
font-size: 0.8rem;
color: var(--text-light);
margin-top: 0.25rem;
}
.btn-icon.delete {
background: none;
border: none;
color: var(--danger);
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
opacity: 0.6;
}
.btn-icon.delete:hover {
opacity: 1;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--bg-card-solid);
border-radius: 12px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--border);
color: var(--text);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 1.1rem;
}
.modal-header .btn-icon {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-light);
line-height: 1;
}
.modal-body {
padding: 1.25rem;
overflow-y: auto;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
}
.form-group {
margin-bottom: 1rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
/* Asset Search */
.asset-search {
position: relative;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card-solid);
border: 1px solid var(--border);
border-radius: 6px;
max-height: 200px;
overflow-y: auto;
z-index: 10;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.search-result-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background: var(--bg);
}
.search-result-item.selected {
background: rgba(65, 129, 255, 0.15);
}
.result-icon {
font-size: 1rem;
}
.result-name {
flex: 1;
font-weight: 500;
}
.result-type {
font-size: 0.75rem;
color: var(--text-light);
}
.selected-asset {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(65, 129, 255, 0.1);
border: 1px solid var(--primary);
border-radius: 6px;
}
.selected-asset .btn-icon {
margin-left: auto;
background: none;
border: none;
font-size: 1rem;
cursor: pointer;
color: var(--text-light);
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div class="color-swatch-picker">
<div class="swatches">
<button
v-for="c in palette"
:key="c"
type="button"
class="swatch"
:class="{ active: modelValue === c }"
:style="{ backgroundColor: c }"
:title="c"
@click="$emit('update:modelValue', c)"
></button>
</div>
<div class="custom">
<input
type="color"
:value="isHex(modelValue) ? modelValue : '#000000'"
@input="$emit('update:modelValue', $event.target.value)"
/>
<input
type="text"
:value="modelValue"
placeholder="#RRGGBB"
maxlength="20"
@input="$emit('update:modelValue', $event.target.value)"
/>
<span class="preview" :style="colorStyle(modelValue)">Aa</span>
</div>
</div>
</template>
<script setup>
import { PALETTE, colorStyle } from '@/utils/colorStyle'
defineProps({ modelValue: { type: String, default: '' } })
defineEmits(['update:modelValue'])
const palette = PALETTE
function isHex(c) {
return typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c)
}
</script>
<style scoped>
.color-swatch-picker {
display: flex;
flex-direction: column;
gap: 8px;
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.swatch {
width: 26px;
height: 26px;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
}
.swatch.active {
border-color: var(--bg);
box-shadow: 0 0 0 2px var(--text);
}
.custom {
display: flex;
align-items: center;
gap: 8px;
}
.custom input[type="text"] {
width: 120px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-family: monospace;
}
.custom input[type="color"] {
width: 34px;
height: 34px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: none;
cursor: pointer;
}
.preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 6px;
font-weight: 700;
border: 1px solid var(--border);
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div v-if="fields.length" class="custom-fields">
<div class="form-group" v-for="f in fields" :key="f.fieldid">
<label>{{ f.label }}</label>
<select v-if="f.datatype === 'select'" v-model="local[f.fieldid]" class="form-control">
<option value="">-- none --</option>
<option v-for="opt in f.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<label v-else-if="f.datatype === 'boolean'" class="checkbox-label">
<input type="checkbox"
:checked="local[f.fieldid] === 'true'"
@change="local[f.fieldid] = $event.target.checked ? 'true' : 'false'" />
Yes
</label>
<input v-else-if="f.datatype === 'date'" type="date" v-model="local[f.fieldid]" class="form-control" />
<input v-else-if="f.datatype === 'number'" type="number" v-model="local[f.fieldid]" class="form-control" />
<input v-else type="text" v-model="local[f.fieldid]" class="form-control" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { customFieldsApi } from '../api'
const props = defineProps({
// Asset type whose fields to render (needed even for a brand-new asset).
assettypeid: { type: [Number, String], default: null },
// Existing asset id, if editing - used to preload stored values.
assetid: { type: [Number, String], default: null },
})
const fields = ref([])
const local = reactive({})
async function load() {
fields.value = []
Object.keys(local).forEach(k => delete local[k])
if (!props.assettypeid) return
try {
// Definitions for this asset type (form-visible, active).
const defsResponse = await customFieldsApi.list({ assettypeid: props.assettypeid })
const defs = (defsResponse.data.data || []).filter(f => f.showonform)
fields.value = defs
// Seed blanks, then overlay stored values if editing.
for (const f of defs) local[f.fieldid] = f.datatype === 'boolean' ? 'false' : ''
if (props.assetid) {
const valResponse = await customFieldsApi.forAsset(props.assetid)
for (const f of (valResponse.data.data || [])) {
if (f.value != null) local[f.fieldid] = String(f.value)
}
}
} catch (err) {
console.error('Error loading custom field defs:', err)
}
}
// Persist current values against an asset id (parent calls after asset save).
async function save(assetid) {
if (!assetid || !fields.value.length) return
const values = {}
for (const f of fields.value) values[f.fieldid] = local[f.fieldid]
await customFieldsApi.saveForAsset(assetid, values)
}
onMounted(load)
watch(() => [props.assettypeid, props.assetid], load)
defineExpose({ save, hasFields: () => fields.value.length > 0 })
</script>
<style scoped>
.custom-fields {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<!-- Only render the card when there is at least one field with a value to show -->
<div class="section-card" v-if="visibleFields.length">
<h3 class="section-title">{{ title }}</h3>
<div class="info-list">
<div class="info-row" v-for="f in visibleFields" :key="f.fieldid">
<span class="info-label">{{ f.label }}</span>
<span class="info-value">{{ displayValue(f) }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { customFieldsApi } from '../api'
const props = defineProps({
assetid: { type: [Number, String], default: null },
title: { type: String, default: 'Additional Details' },
})
const fields = ref([])
const visibleFields = computed(() =>
fields.value.filter(f => f.showondetail && f.value != null && String(f.value).trim() !== ''))
function displayValue(f) {
if (f.datatype === 'boolean') {
return ['true', '1', 'yes'].includes(String(f.value).toLowerCase()) ? 'Yes' : 'No'
}
return f.value
}
async function load() {
if (!props.assetid) { fields.value = []; return }
try {
const response = await customFieldsApi.forAsset(props.assetid)
fields.value = response.data.data || []
} catch (err) {
console.error('Error loading custom fields:', err)
fields.value = []
}
}
onMounted(load)
watch(() => props.assetid, load)
</script>

View File

@@ -0,0 +1,53 @@
<template>
<button class="btn btn-secondary" :disabled="sending" @click="emailReport">
{{ sending ? 'Sending...' : 'Email report' }}
</button>
</template>
<script setup>
import { ref } from 'vue'
import { reportsApi } from '../api'
import { useToast } from '../composables/toast'
import { apiError } from '../utils/apiError'
// On-demand report delivery. Emails the given rows as an HTML table to the
// site's Alert Recipients (or an explicit `to`). Automatic/scheduled sending is
// out of scope for this app; point an external cron at POST /api/reports/email
// with an API token to automate.
const props = defineProps({
subject: { type: String, required: true },
columns: { type: Array, required: true },
rows: { type: Array, required: true },
intro: { type: String, default: '' },
to: { type: String, default: '' },
})
const toast = useToast()
const sending = ref(false)
async function emailReport() {
sending.value = true
try {
const payload = {
subject: props.subject,
columns: props.columns,
rows: props.rows,
intro: props.intro,
}
if (props.to) payload.to = props.to
const response = await reportsApi.email(payload)
const result = response.data?.data || {}
if (result.sent) {
toast.success('Report emailed.')
} else if (result.error) {
toast.error(`Report email failed: ${result.error}`)
} else {
toast.info(response.data?.message || 'Email is not configured.')
}
} catch (event) {
toast.error(apiError(event, 'Failed to email report'))
} finally {
sending.value = false
}
}
</script>

View File

@@ -0,0 +1,141 @@
<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>

View File

@@ -0,0 +1,251 @@
<template>
<div class="employee-search">
<div class="employee-search-container">
<input
v-model="searchQuery"
type="text"
class="form-control"
:placeholder="placeholder"
:disabled="disabled"
@input="onSearch"
@keydown.enter.prevent="addCustom"
/>
<div v-if="results.length" class="employee-dropdown">
<div
v-for="emp in results"
:key="emp.SSO"
class="employee-option"
@click="selectEmployee(emp)"
>
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
<span class="emp-sso">{{ emp.SSO }}</span>
</div>
</div>
</div>
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
<!-- Selected employee(s) display -->
<div v-if="multiple && selectedList.length" class="selected-employees">
<div
v-for="(emp, idx) in selectedList"
:key="idx"
class="selected-employee"
>
<span>{{ emp.name }}</span>
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">&times;</button>
</div>
</div>
<div v-else-if="!multiple && selected" class="selected-employee-display">
<span>{{ selected.name }}</span>
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">&times;</button>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { employeesApi } from '../api'
const props = defineProps({
modelValue: { type: [Object, Array], default: null },
multiple: { type: Boolean, default: false },
allowCustom: { type: Boolean, default: false },
placeholder: { type: String, default: 'Search by name...' },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const searchQuery = ref('')
const results = ref([])
const selected = ref(null)
const selectedList = ref([])
let searchTimeout = null
// Initialize from modelValue
watch(() => props.modelValue, (val) => {
if (props.multiple) {
selectedList.value = val || []
} else {
selected.value = val
}
}, { immediate: true })
async function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
const query = searchQuery.value.trim()
if (query.length < 2) {
results.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const response = await employeesApi.search(query)
results.value = response.data.data || []
} catch (err) {
console.error('Employee search error:', err)
results.value = []
}
}, 300)
}
function selectEmployee(emp) {
const employee = {
sso: String(emp.SSO),
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
}
if (props.multiple) {
// Check if already selected
if (selectedList.value.some(e => e.sso === employee.sso)) {
return
}
selectedList.value.push(employee)
emit('update:modelValue', selectedList.value)
} else {
selected.value = employee
emit('update:modelValue', employee)
}
searchQuery.value = ''
results.value = []
}
function addCustom() {
if (!props.allowCustom) return
const name = searchQuery.value.trim()
if (!name) return
const employee = {
sso: `NAME:${name}`,
name: name
}
if (props.multiple) {
selectedList.value.push(employee)
emit('update:modelValue', selectedList.value)
} else {
selected.value = employee
emit('update:modelValue', employee)
}
searchQuery.value = ''
results.value = []
}
function removeEmployee(idx) {
selectedList.value.splice(idx, 1)
emit('update:modelValue', selectedList.value)
}
function clearSelection() {
selected.value = null
emit('update:modelValue', null)
}
</script>
<style scoped>
.employee-search-container {
position: relative;
}
.employee-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card-solid, #1a1a1a);
border: 1px solid var(--border);
border-radius: 0.25rem;
max-height: 200px;
overflow-y: auto;
z-index: 100;
}
.employee-option {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.employee-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.emp-name {
font-weight: 500;
}
.emp-sso {
font-size: 0.85rem;
color: var(--text-light);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
/* Multiple selection */
.selected-employees {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.selected-employee {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
/* Single selection */
.selected-employee-display {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
margin-top: 0.5rem;
}
.emp-sso-tag {
font-size: 0.8rem;
opacity: 0.8;
}
.btn-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1.2rem;
line-height: 1;
opacity: 0.7;
margin-left: auto;
}
.btn-remove:hover {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,268 @@
<template>
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
<slot></slot>
<Teleport to="body">
<div
v-if="visible && hasPosition"
class="map-tooltip"
:style="tooltipStyle"
ref="tooltipRef"
@mouseenter="onTooltipEnter"
@mouseleave="onTooltipLeave"
@wheel.prevent="onWheel"
>
<div class="map-tooltip-content">
<div class="map-preview" ref="mapPreview">
<div
class="map-transform"
:style="transformStyle"
>
<img
:src="blueprintUrl"
alt="Shop Floor Map"
class="map-image"
@load="onImageLoad"
/>
<!-- Marker dot -->
<div
class="marker-dot"
:style="markerStyle"
></div>
</div>
</div>
<div class="map-tooltip-footer">
<span class="coordinates">{{ left }}, {{ top }}</span>
<span class="zoom-hint">Scroll to zoom</span>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
// Fetch this facility's blueprint + dimensions once; computeds below react
// when it loads.
loadMapConfig()
const props = defineProps({
left: { type: Number, default: null },
top: { type: Number, default: null },
machineName: { type: String, default: '' }
})
const visible = ref(false)
const tooltipRef = ref(null)
const mapPreview = ref(null)
const tooltipPosition = ref({ x: 0, y: 0 })
const isOverTooltip = ref(false)
const zoom = ref(1)
const imageLoaded = ref(false)
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)
})
// Calculate marker position as percentage of the facility blueprint size
const markerX = computed(() => {
return (props.left / mapConfig.width) * 100
})
const markerY = computed(() => {
return (props.top / mapConfig.height) * 100
})
// Marker style with counter-scale to maintain constant size
const markerStyle = computed(() => ({
left: markerX.value + '%',
top: markerY.value + '%',
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
}))
// Transform style that centers on the marker and zooms toward it
const transformStyle = computed(() => {
// Calculate translation to center the marker in the preview
const translateX = 50 - markerX.value
const translateY = 50 - markerY.value
return {
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
transformOrigin: `${markerX.value}% ${markerY.value}%`
}
})
const tooltipStyle = computed(() => ({
left: `${tooltipPosition.value.x}px`,
top: `${tooltipPosition.value.y}px`
}))
function onImageLoad() {
imageLoaded.value = true
}
function showTooltip(event) {
if (!hasPosition.value) return
visible.value = true
zoom.value = 1
const rect = event.target.getBoundingClientRect()
tooltipPosition.value = {
x: rect.left + rect.width / 2,
y: rect.bottom + 10
}
nextTick(() => {
adjustPosition()
})
}
function onWrapperLeave() {
// Small delay to allow moving to tooltip
setTimeout(() => {
if (!isOverTooltip.value) {
hideTooltip()
}
}, 100)
}
function onTooltipEnter() {
isOverTooltip.value = true
}
function onTooltipLeave() {
isOverTooltip.value = false
hideTooltip()
}
function hideTooltip() {
visible.value = false
zoom.value = 1
}
function onWheel(event) {
const delta = event.deltaY > 0 ? -0.3 : 0.3
const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
zoom.value = newZoom
}
function adjustPosition() {
if (!tooltipRef.value) return
const tooltip = tooltipRef.value
const rect = tooltip.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
if (rect.right > viewportWidth - 20) {
tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
}
if (rect.left < 20) {
tooltipPosition.value.x += (20 - rect.left)
}
if (rect.bottom > viewportHeight - 20) {
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
}
}
// Reset zoom when tooltip becomes visible
watch(visible, (newVal) => {
if (newVal) {
zoom.value = 1
}
})
// Reset imageLoaded when theme changes to force reload
watch(currentTheme, () => {
imageLoaded.value = false
})
</script>
<style scoped>
.location-tooltip-wrapper {
display: inline;
cursor: pointer;
}
.location-tooltip-wrapper:hover {
color: var(--primary, #1976d2);
}
</style>
<style>
.map-tooltip {
position: fixed;
z-index: 10000;
transform: translateX(-50%);
}
.map-tooltip-content {
background: var(--bg-card, #ffffff);
border-radius: 8px;
border: 1px solid var(--border, #e0e0e0);
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
overflow: hidden;
}
.map-preview {
position: relative;
width: 500px;
height: 385px;
overflow: hidden;
background: var(--bg, #f5f5f5);
}
.map-transform {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.15s ease-out;
}
.map-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.marker-dot {
position: absolute;
width: 16px;
height: 16px;
background: #ff0000;
border: 2px solid #ffffff;
border-radius: 50%;
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
pointer-events: none;
}
.map-tooltip-footer {
padding: 0.5rem 0.75rem;
background: var(--bg, #f5f5f5);
border-top: 1px solid var(--border, #e0e0e0);
display: flex;
justify-content: space-between;
align-items: center;
}
.coordinates {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 0.875rem;
color: var(--text-light, #666666);
}
.zoom-hint {
font-size: 0.75rem;
color: var(--text-light, #666666);
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<Teleport to="body">
<div v-if="modelValue" class="modal-overlay" @click.self="closeOnOverlay && close()">
<div class="modal-container" :class="sizeClass">
<div class="modal-header" v-if="title || $slots.header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
<button class="modal-close" @click="close" aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div class="modal-footer" v-if="$slots.footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, watch } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: '' },
size: { type: String, default: 'medium' }, // small, medium, large, fullscreen
closeOnOverlay: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue', 'close'])
const sizeClass = computed(() => `modal-${props.size}`)
function close() {
emit('update:modelValue', false)
emit('close')
}
// Handle escape key
watch(() => props.modelValue, (isOpen) => {
if (isOpen) {
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
} else {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
}
})
function handleEscape(e) {
if (e.key === 'Escape') close()
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-container {
background: var(--bg-card-solid);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
max-height: 90vh;
overflow: hidden;
color: var(--text);
}
.modal-small {
width: 400px;
max-width: 90vw;
}
.modal-medium {
width: 600px;
max-width: 90vw;
}
.modal-large {
width: 900px;
max-width: 95vw;
}
.modal-fullscreen {
width: 95vw;
height: 90vh;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 1.25rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-light);
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: var(--text);
}
.modal-body {
flex: 1;
overflow: auto;
padding: 1.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="pagination-bar" v-if="totalPages > 1 || showPerPage">
<div class="pagination-info">
<select
v-if="showPerPage"
:value="perPage"
class="perpage-select"
@change="$emit('update:perPage', Number($event.target.value))"
>
<option v-for="opt in perPageOptions" :key="opt" :value="opt">
{{ opt }} per page
</option>
</select>
</div>
<div class="pagination" v-if="totalPages > 1">
<button :disabled="page === 1" @click="$emit('update:page', page - 1)">
Prev
</button>
<button
v-for="p in visiblePages"
:key="p"
:class="{ active: p === page, ellipsis: p === '...' }"
:disabled="p === '...'"
@click="p !== '...' && $emit('update:page', p)"
>
{{ p }}
</button>
<button :disabled="page === totalPages" @click="$emit('update:page', page + 1)">
Next
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
page: { type: Number, required: true },
totalPages: { type: Number, required: true },
perPage: { type: Number, default: 20 },
showPerPage: { type: Boolean, default: true }
})
defineEmits(['update:page', 'update:perPage'])
const perPageOptions = [10, 20, 50, 100]
const visiblePages = computed(() => {
const total = props.totalPages
const current = props.page
const pages = []
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i)
return pages
}
pages.push(1)
if (current > 3) {
pages.push('...')
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (current < total - 2) {
pages.push('...')
}
pages.push(total)
return pages
})
</script>

View File

@@ -0,0 +1,790 @@
<template>
<div class="shopfloor-map">
<!-- Controls for legacy machine mode only (MapView handles filters for asset mode) -->
<div class="map-controls" v-if="!pickerMode && !assetTypeMode">
<div class="filters">
<select v-model="filters.machinetype" @change="applyFilters">
<option value="">All Types</option>
<option v-for="t in machinetypes" :key="t.machinetypeid" :value="t.machinetypeid">
{{ t.machinetype }}
</option>
</select>
<select v-model="filters.businessunit" @change="applyFilters">
<option value="">All Business Units</option>
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
</select>
<select v-model="filters.status" @change="applyFilters">
<option value="">All Statuses</option>
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
{{ s.status }}
</option>
</select>
<input
type="text"
v-model="filters.search"
placeholder="Search..."
@input="debounceSearch"
/>
</div>
<div class="legend">
<span
v-for="t in visibleTypes"
:key="t.machinetypeid"
class="legend-item"
>
<span class="legend-dot" :style="{ background: getTypeColor(t.machinetype) }"></span>
{{ t.machinetype }}
</span>
</div>
</div>
<!-- Legend for asset type mode - shows subtypes when a type is selected -->
<div class="map-legend" v-if="!pickerMode && assetTypeMode">
<!-- Show subtype legend when a specific type is selected -->
<template v-if="selectedAssetType && Object.keys(visibleSubtypes).length">
<span
v-for="(color, subtypeId) in visibleSubtypes"
:key="subtypeId"
class="legend-item"
>
<span class="legend-dot" :style="{ background: color }"></span>
{{ subtypeNames[subtypeId] || `Type ${subtypeId}` }}
</span>
</template>
<!-- Show asset type legend when no type is selected -->
<template v-else>
<span
v-for="(color, assetType) in visibleAssetTypes"
:key="assetType"
class="legend-item"
>
<span class="legend-dot" :style="{ background: color }"></span>
{{ assetTypeLabels[assetType] || assetType }}
</span>
</template>
</div>
<div class="picker-controls" v-if="pickerMode">
<span class="picker-message">Click on the map to set location</span>
<span v-if="pickedPosition" class="picker-coords">
Position: {{ pickedPosition.left }}, {{ pickedPosition.top }}
</span>
<button class="btn btn-secondary btn-sm" @click="clearPosition">Clear</button>
</div>
<div ref="mapContainer" class="map-container" :class="{ 'picker-active': pickerMode }"></div>
</div>
</template>
<script setup>
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 { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
const props = defineProps({
machines: { type: Array, default: () => [] },
machinetypes: { type: Array, default: () => [] },
businessunits: { type: Array, default: () => [] },
statuses: { type: Array, default: () => [] },
theme: { type: String, default: 'dark' },
pickerMode: { type: Boolean, default: false },
initialPosition: { type: Object, default: null }, // { left, top }
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
})
const emit = defineEmits(['markerClick', 'positionPicked'])
const mapContainer = ref(null)
let map = null
let imageOverlay = null
const markers = ref([])
const pickedPosition = ref(null)
let pickerMarker = null
let markerLayer = null
let canvasRenderer = null
const filters = ref({
machinetype: '',
businessunit: '',
status: '',
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
// Asset type colors (for unified map mode) - normalized lookup
const assetTypeColorsMap = {
'machine': '#F44336', // Red
'computer': '#2196F3', // Blue
'printer': '#4CAF50', // Green
'network device': '#FF9800', // Orange
'network_device': '#FF9800', // Orange (alternate key)
'measuring_tool': '#9C27B0', // Purple
'measuring tool': '#9C27B0' // Purple (normalized key)
}
// Get asset type color with case-insensitive lookup
function getAssetTypeColor(assettype) {
if (!assettype) return '#BDBDBD'
const normalized = assettype.toLowerCase()
return assetTypeColorsMap[normalized] || '#BDBDBD'
}
// Asset-type display labels come from the shared util (single source of truth).
const assetTypeLabels = new Proxy({}, {
get(target, prop) {
return typeof prop === 'string' ? assetTypeLabel(prop) : prop
}
})
// Type colors - distinct colors for each machine type
const typeColors = {
// Machining
'Mill': '#F44336', // Red
'Lathe': '#E91E63', // Pink
'Grinder': '#2196F3', // Blue
'Broach': '#00BCD4', // Cyan
'Hobbing': '#009688', // Teal
'Turn': '#FF5722', // Deep Orange (Mill Turn, Vertical Turn)
// Inspection & Measurement
'CMM': '#9C27B0', // Purple
'Measuring': '#7B1FA2', // Dark Purple
'Eddy': '#673AB7', // Deep Purple
'Inspection': '#8BC34A', // Light Green
// Heat Treatment & Processing
'Furnace': '#FF9800', // Orange
'Wash': '#4CAF50', // Green
'Wax': '#FFEB3B', // Yellow
// Automation
'Robot': '#3F51B5', // Indigo
'Deburr': '#5C6BC0', // Indigo Light
// Welding
'Welder': '#795548', // Brown
// IT/Network
'PC': '#607D8B', // Blue Grey
'Printer': '#78909C', // Blue Grey Light
'Switch': '#546E7A', // Blue Grey Dark
'Access Point': '#455A64', // Blue Grey Darker
'IDF': '#37474F', // Blue Grey Very Dark
// Other
'Saw': '#8D6E63', // Brown Light
'Press': '#A1887F', // Brown Lighter
'EDM': '#00ACC1', // Cyan Dark
'Drill': '#26A69A', // Teal Light
'CNC': '#66BB6A', // Green Light
'Assembly': '#CDDC39', // Lime
'Other': '#BDBDBD' // Grey
}
function getTypeColor(typeName) {
if (!typeName) return '#BDBDBD'
for (const [key, color] of Object.entries(typeColors)) {
if (typeName.toLowerCase().includes(key.toLowerCase())) {
return color
}
}
return '#BDBDBD' // Grey for unknown types
}
// Get unique visible types from machines
const visibleTypes = computed(() => {
const typeIds = new Set(props.machines.map(m => m.machinetypeid))
return props.machinetypes.filter(t => typeIds.has(t.machinetypeid))
})
// Get unique visible asset types (for asset mode)
const visibleAssetTypes = computed(() => {
const types = new Set(props.machines.map(m => m.assettype).filter(Boolean))
const result = {}
for (const t of types) {
result[t] = getAssetTypeColor(t)
}
return result
})
// Get subtype ID from asset based on asset type
function getSubtypeId(asset) {
if (!asset.typedata) return null
// Normalize network_device -> network device so the subtype id resolves.
const typeLower = (asset.assettype || '').toLowerCase().replace(/_/g, ' ')
if (typeLower === 'machine') return asset.typedata.machinetypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
if (typeLower === 'printer') return asset.typedata.printertypeid
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
return null
}
// Get visible subtypes when a type is selected
const visibleSubtypes = computed(() => {
if (!props.selectedAssetType) return {}
const subtypeIds = new Set(
props.machines
.map(m => getSubtypeId(m))
.filter(id => id != null)
)
const result = {}
for (const id of subtypeIds) {
if (props.subtypeColors[id]) {
result[id] = props.subtypeColors[id]
}
}
return result
})
function initMap() {
if (!mapContainer.value) return
// Use canvas renderer for performance - renders all markers on a single <canvas>
canvasRenderer = L.canvas({ padding: 0.5 })
map = L.map(mapContainer.value, {
crs: L.CRS.Simple,
minZoom: -4,
maxZoom: 2,
attributionControl: false,
renderer: canvasRenderer
})
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme), bounds)
imageOverlay.addTo(map)
// Set initial view - zoom out to show full floor plan
const initialZoom = -2
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
map.setMaxBounds(bounds)
// Picker mode: click to set position
if (props.pickerMode) {
map.on('click', handleMapClick)
// Show initial position if provided
if (props.initialPosition) {
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
}
} else {
renderMarkers()
}
}
function handleMapClick(e) {
if (!props.pickerMode) return
const leafletY = e.latlng.lat
const leafletX = e.latlng.lng
// Convert back to database coordinates
const dbLeft = Math.round(leafletX)
const dbTop = Math.round(MAP_HEIGHT - leafletY)
setPickerPosition(dbLeft, dbTop)
}
function setPickerPosition(left, top) {
// Remove old picker marker
if (pickerMarker) {
pickerMarker.remove()
}
pickedPosition.value = { left, top }
// Convert to Leaflet coordinates
const leafletY = MAP_HEIGHT - top
const leafletX = left
const icon = L.divIcon({
html: `<div class="picker-marker-dot"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
className: 'picker-marker'
})
pickerMarker = L.marker([leafletY, leafletX], { icon, draggable: true })
pickerMarker.addTo(map)
// Allow dragging to fine-tune position
pickerMarker.on('dragend', () => {
const pos = pickerMarker.getLatLng()
const newLeft = Math.round(pos.lng)
const newTop = Math.round(MAP_HEIGHT - pos.lat)
pickedPosition.value = { left: newLeft, top: newTop }
emit('positionPicked', pickedPosition.value)
})
emit('positionPicked', pickedPosition.value)
}
function clearPosition() {
if (pickerMarker) {
pickerMarker.remove()
pickerMarker = null
}
pickedPosition.value = null
emit('positionPicked', null)
}
// Get the detail page route based on machine category
// Extensible for future addon types (network, cameras, etc.)
function getDetailRoute(machine) {
const category = machine.category?.toLowerCase() || ''
const routeMap = {
'machine': '/machines',
'pc': '/pcs',
'printer': '/printers',
// Future addon routes can be added here:
// 'network': '/network',
// 'camera': '/cameras',
}
const basePath = routeMap[category] || '/machines'
return `${basePath}/${machine.machineid}`
}
function renderMarkers() {
// Clear existing markers
markers.value.forEach(m => m.marker.remove())
markers.value = []
props.machines.forEach(item => {
if (item.mapx == null || item.mapy == null) return
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
const leafletY = MAP_HEIGHT - item.mapy
const leafletX = item.mapx
// Determine color based on mode
let color, typeName, displayName, detailRoute
if (props.assetTypeMode) {
// Unified asset mode - use subtype colors when a type is selected
if (props.selectedAssetType) {
const subtypeId = getSubtypeId(item)
color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD'
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
} else {
// Prefer the stored AssetType.color; fall back to the built-in map.
color = item.assettypecolor || getAssetTypeColor(item.assettype)
typeName = item.assettype || ''
}
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
// Collapsed dual-bay pair: show the combined '2007 / 2008' label.
if (item.dualpathpartner) {
displayName = `${item.assetnumber} / ${item.dualpathpartner}`
}
detailRoute = getAssetDetailRoute(item)
} else {
// Legacy machine mode
typeName = item.machinetype || ''
color = getTypeColor(typeName)
displayName = item.alias || item.machinenumber || 'Unknown'
detailRoute = getDetailRoute(item)
}
// Use circleMarker instead of divIcon marker - renders on canvas
// for much better performance (no DOM element per marker)
const marker = L.circleMarker([leafletY, leafletX], {
radius: 6,
fillColor: color,
color: 'rgba(255,255,255,0.8)',
weight: 2,
fillOpacity: 1,
renderer: canvasRenderer
})
// Build tooltip content
let tooltipLines = [`<strong>${displayName}</strong>`]
if (props.assetTypeMode) {
tooltipLines.push(`<span style="color: #888;">${item.assettype || 'Unknown'}</span>`)
if (item.primaryip) {
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.primaryip}</span>`)
}
if (item.typedata?.hostname) {
tooltipLines.push(`<span style="color: #8cf;">${item.typedata.hostname}</span>`)
}
} else {
const category = item.category?.toLowerCase() || ''
if (typeName && typeName.toLowerCase() !== 'locationonly') {
tooltipLines.push(`<span style="color: #888;">${typeName}</span>`)
}
if (item.vendor) {
tooltipLines.push(`<span style="color: #aaa;">${item.vendor}${item.model ? ' ' + item.model : ''}</span>`)
} else if (item.model) {
tooltipLines.push(`<span style="color: #aaa;">${item.model}</span>`)
}
if (category === 'printer') {
if (item.ipaddress) {
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.ipaddress}</span>`)
}
if (item.hostname) {
tooltipLines.push(`<span style="color: #8cf;">${item.hostname}</span>`)
}
} else {
if (item.connected_pc) {
tooltipLines.push(`<span style="color: #fc8;">PC: ${item.connected_pc}</span>`)
}
}
}
if (item.businessunit) {
tooltipLines.push(`<span style="color: #ccc;">${item.businessunit}</span>`)
}
const tooltipContent = tooltipLines.join('<br>')
marker.bindTooltip(tooltipContent, {
direction: 'top',
offset: [0, -12],
className: 'marker-tooltip'
})
// Click popup (detailed info)
let popupContent
if (props.assetTypeMode) {
popupContent = `
<div class="marker-popup">
<strong>${displayName}</strong>
<div class="popup-details">
<div><span class="label">Asset #:</span> ${item.assetnumber || '-'}</div>
<div><span class="label">Type:</span> ${item.assettype || '-'}</div>
<div><span class="label">Status:</span> ${item.status || '-'}</div>
<div><span class="label">Location:</span> ${item.location || '-'}</div>
${item.primaryip ? `<div><span class="label">IP:</span> ${item.primaryip}</div>` : ''}
</div>
<a href="${detailRoute}" class="popup-link">View Details</a>
</div>
`
} else {
popupContent = `
<div class="marker-popup">
<strong>${displayName}</strong>
<div class="popup-details">
<div><span class="label">Number:</span> ${item.machinenumber || '-'}</div>
<div><span class="label">Type:</span> ${typeName || '-'}</div>
<div><span class="label">Category:</span> ${item.category || '-'}</div>
<div><span class="label">Status:</span> ${item.status || '-'}</div>
<div><span class="label">Vendor:</span> ${item.vendor || '-'}</div>
<div><span class="label">Model:</span> ${item.model || '-'}</div>
</div>
<a href="${detailRoute}" class="popup-link">View Details</a>
</div>
`
}
marker.bindPopup(popupContent)
marker.on('click', () => emit('markerClick', item))
marker.addTo(map)
// Build search data
const searchData = props.assetTypeMode
? `${item.assetnumber} ${item.name} ${item.displayname} ${item.assettype} ${item.status} ${item.businessunit} ${item.typedata?.hostname || ''} ${item.primaryip || ''}`.toLowerCase()
: `${item.machinenumber} ${item.alias} ${typeName} ${item.vendor} ${item.model} ${item.serialnumber} ${item.businessunit}`.toLowerCase()
markers.value.push({
marker,
machine: item,
searchData
})
})
applyFilters()
}
// Get detail route for unified asset format (shared util = single source).
function getAssetDetailRoute(asset) {
return assetDetailRoute(asset)
}
function applyFilters() {
const searchTerm = filters.value.search.toLowerCase()
markers.value.forEach(({ marker, machine, searchData }) => {
let visible = true
if (filters.value.machinetype && machine.machinetypeid !== filters.value.machinetype) {
visible = false
}
if (filters.value.businessunit && machine.businessunitid !== filters.value.businessunit) {
visible = false
}
if (filters.value.status && machine.statusid !== filters.value.status) {
visible = false
}
if (searchTerm && !searchData.includes(searchTerm)) {
visible = false
}
marker.setStyle({ fillOpacity: visible ? 1 : 0.15, opacity: visible ? 1 : 0.15 })
})
}
let searchTimeout = null
function debounceSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(applyFilters, 300)
}
watch(() => props.machines, (newVal, oldVal) => {
if (map && newVal !== oldVal) renderMarkers()
})
watch(() => props.theme, (newTheme) => {
if (imageOverlay && map) {
imageOverlay.setUrl(blueprintUrlFor(newTheme))
}
})
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
initMap()
})
onUnmounted(() => {
if (map) {
map.remove()
map = null
}
})
</script>
<style scoped>
.shopfloor-map {
display: flex;
flex-direction: column;
height: 100%;
}
.map-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
gap: 0.5rem;
}
.filters {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filters select,
.filters input {
padding: 0.625rem 1rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
background: var(--bg);
color: var(--text);
}
.filters select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2.25rem;
min-width: 160px;
}
.filters input {
width: 280px;
}
.filters input::placeholder {
color: var(--text-light);
opacity: 0.7;
}
.filters input::placeholder {
color: var(--text-light);
}
.legend {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
font-size: 1.125rem;
color: var(--text);
}
.map-legend-bar {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
font-size: 1rem;
color: var(--text);
padding: 0.5rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.5rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.legend-dot {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid var(--bg-card);
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
}
.map-container {
flex: 1;
min-height: 600px;
background: var(--bg);
border: 1px solid var(--border);
}
.map-container.picker-active {
cursor: crosshair;
}
.picker-controls {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 1rem;
background: var(--warning);
border-bottom: 1px solid var(--warning);
font-size: 1.125rem;
}
.picker-message {
font-weight: 500;
color: #1a1a1a;
}
.picker-coords {
font-family: monospace;
background: var(--bg-card);
color: var(--text);
padding: 0.375rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 1.125rem;
}
:deep(.marker-popup) {
min-width: 320px;
}
:deep(.marker-popup strong) {
display: block;
margin-bottom: 0.625rem;
font-size: 1.5rem;
}
:deep(.popup-details) {
font-size: 1.125rem;
line-height: 1.9;
}
:deep(.popup-details .label) {
color: #666;
font-weight: 500;
}
:deep(.popup-link) {
display: inline-block;
margin-top: 1rem;
color: #1976d2;
text-decoration: none;
font-size: 1.125rem;
font-weight: 500;
}
:deep(.popup-link:hover) {
text-decoration: underline;
}
:deep(.machine-marker) {
background: transparent !important;
border: none !important;
}
:deep(.machine-marker-dot) {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.8);
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
}
:deep(.picker-marker-dot) {
background: #ff0000;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--border);
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
}
:deep(.marker-tooltip) {
background: rgba(0, 0, 0, 0.92);
color: #fff;
border: none;
border-radius: 8px;
padding: 14px 18px;
font-size: 1.125rem;
line-height: 1.7;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
max-width: 400px;
}
:deep(.marker-tooltip::before) {
border-top-color: rgba(0, 0, 0, 0.92);
}
/* Legend bar for asset type mode (no filters - parent handles them) */
.map-legend {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
padding: 0.75rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 0.75rem;
font-size: 0.875rem;
color: var(--text);
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="toast-host">
<transition-group name="toast">
<div
v-for="t in toast.items"
:key="t.id"
class="toast"
:class="`toast-${t.type}`"
@click="toast.dismiss(t.id)"
>
<span class="toast-message">{{ t.message }}</span>
<button class="toast-close" @click.stop="toast.dismiss(t.id)" aria-label="Dismiss">&times;</button>
</div>
</transition-group>
</div>
</template>
<script setup>
import { useToast } from '../composables/toast'
const toast = useToast()
</script>
<style scoped>
.toast-host {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
z-index: 3000;
display: flex;
flex-direction: column;
gap: 0.6rem;
max-width: min(420px, calc(100vw - 2.5rem));
}
.toast {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
border-radius: 8px;
background: var(--bg-card);
color: var(--text);
border-left: 4px solid var(--secondary);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
cursor: pointer;
font-size: 0.9rem;
line-height: 1.35;
}
.toast-success { border-left-color: var(--success); }
.toast-error { border-left-color: var(--danger); }
.toast-info { border-left-color: var(--primary); }
.toast-message { flex: 1; }
.toast-close {
border: none;
background: none;
color: var(--text-light);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0;
}
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="section-card" v-if="warranties.length || showEmpty">
<h3 class="section-title">Warranty</h3>
<div v-if="warranties.length" class="warranty-list">
<div v-for="w in warranties" :key="w.warrantyid" class="warranty-item">
<div class="warranty-top">
<span class="warranty-vendor">{{ w.vendor }}</span>
<span class="status-badge" :style="colorStyle(w.statuscolor)">{{ statusLabel(w.status) }}</span>
</div>
<div class="warranty-meta">
<span v-if="w.servicelevel">{{ w.servicelevel }}</span>
<span v-if="w.enddate">Ends {{ formatDate(w.enddate) }}</span>
<span v-if="w.servicetag" class="mono">Tag {{ w.servicetag }}</span>
</div>
</div>
<router-link :to="`/warranties?addfor=${assetid}`" class="warranty-manage">Add / manage</router-link>
</div>
<div v-else class="warranty-empty">
<span class="muted">No warranty on record.</span>
<router-link :to="`/warranties?addfor=${assetid}`" class="warranty-manage">Add one</router-link>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi } from '../api'
const props = defineProps({
assetid: { type: [Number, String], default: null },
// When true, render the card even with no warranties (shows an "Add one" link).
showEmpty: { type: Boolean, default: false },
// Optional pre-fetched warranties. When provided, the panel uses them instead
// of fetching (lets a parent share one fetch with e.g. a hero badge).
items: { type: Array, default: null },
})
const warranties = ref([])
function statusLabel(status) {
return { active: 'Active', expiring: 'Expiring Soon', expired: 'Expired', unknown: 'Unknown' }[status] || status
}
function formatDate(d) {
if (!d) return '-'
return new Date(d + 'T00:00:00').toLocaleDateString()
}
async function load() {
// Parent-supplied data wins - skip the fetch.
if (props.items !== null) { warranties.value = props.items; return }
if (!props.assetid) { warranties.value = []; return }
try {
const response = await warrantyApi.forAsset(props.assetid)
warranties.value = response.data.data || []
} catch (err) {
console.error('Error loading warranties:', err)
warranties.value = []
}
}
onMounted(load)
watch(() => props.assetid, load)
watch(() => props.items, load)
</script>
<style scoped>
.warranty-list { display: flex; flex-direction: column; gap: 0.75rem; }
.warranty-item { padding: 0.6rem 0.75rem; background: var(--bg); border-radius: 6px; }
.warranty-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.warranty-vendor { font-weight: 600; color: var(--text); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.warranty-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
.warranty-empty { display: flex; align-items: center; gap: 0.6rem; }
.warranty-manage { font-size: 0.82rem; }
.mono { font-family: monospace; }
.muted { color: var(--text-light); }
</style>