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:
793
frontend/src/components/AssetRelationships.vue
Normal file
793
frontend/src/components/AssetRelationships.vue
Normal 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"
|
||||
>
|
||||
×
|
||||
</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">-></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"><-</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"
|
||||
>
|
||||
×
|
||||
</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">×</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">×</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>
|
||||
101
frontend/src/components/ColorSwatchPicker.vue
Normal file
101
frontend/src/components/ColorSwatchPicker.vue
Normal 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>
|
||||
82
frontend/src/components/CustomFieldsInputs.vue
Normal file
82
frontend/src/components/CustomFieldsInputs.vue
Normal 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>
|
||||
48
frontend/src/components/CustomFieldsSection.vue
Normal file
48
frontend/src/components/CustomFieldsSection.vue
Normal 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>
|
||||
53
frontend/src/components/EmailReportButton.vue
Normal file
53
frontend/src/components/EmailReportButton.vue
Normal 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>
|
||||
141
frontend/src/components/EmbeddedLocationMap.vue
Normal file
141
frontend/src/components/EmbeddedLocationMap.vue
Normal 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>
|
||||
251
frontend/src/components/EmployeeSearch.vue
Normal file
251
frontend/src/components/EmployeeSearch.vue
Normal 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)">×</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">×</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>
|
||||
268
frontend/src/components/LocationMapTooltip.vue
Normal file
268
frontend/src/components/LocationMapTooltip.vue
Normal 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>
|
||||
145
frontend/src/components/Modal.vue
Normal file
145
frontend/src/components/Modal.vue
Normal 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">×</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>
|
||||
83
frontend/src/components/PaginationBar.vue
Normal file
83
frontend/src/components/PaginationBar.vue
Normal 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>
|
||||
790
frontend/src/components/ShopFloorMap.vue
Normal file
790
frontend/src/components/ShopFloorMap.vue
Normal 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>
|
||||
75
frontend/src/components/ToastHost.vue
Normal file
75
frontend/src/components/ToastHost.vue
Normal 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">×</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>
|
||||
81
frontend/src/components/WarrantyPanel.vue
Normal file
81
frontend/src/components/WarrantyPanel.vue
Normal 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>
|
||||
Reference in New Issue
Block a user