THE RECIPROCAL LINKS BUG A PC's page showed "Serves machine 7803" at the top. The machine's page showed nothing equivalent, though the same edge was right there in the relationships card at the bottom. That asymmetry read as missing data and was reported as such. It was neither missing data nor an API fault. The endpoint returns both directions and always did; AssetTriageBand already walked outgoing AND incoming and deduped, with a comment explaining that production holds reciprocal rows. It then discarded every peer whose asset type was not 'machine', and hardcoded the label "Serves machine". So the component could only ever work from the PC side, and it was mounted only on the PC page. The counterpart type and label are now props, defaulting to the old values so the PC page is unchanged. The machine page mounts the band with counterparttype="computer" and the label "Controlled by". Eliminated with evidence rather than assumed, because this bug had already been misdiagnosed twice: the endpoint runs two queries and returns both arrays; the generic card renders both; that route has no pagination to clamp; and a single permission gate cannot admit one direction and not the other. THE RELATIONSHIP TYPE WAS ALSO HARDCODED Not every asset pair is linked by 'controls'. A machine reaches a printer through usesprinter and defaultprinter, so the band was blank on the printer page - the one page where the link is not obvious from the asset itself. The types are now a prop, and the printer page passes its own. THE CHIPS WERE DEAD LINKS Found while adopting the band more widely. It linked to /assets/<assetid>. There is no /assets/:id route and no catch-all, so every chip in the band was dead - and this change would have propagated that to four more pages. AssetRelationships.vue already resolved this correctly and privately. That function is now frontend/src/utils/assetRoute.js and both components use it, so a third caller cannot get it wrong again. Two things it gets right that are easy to miss: the detail routes key on pluginid, not assetid, so passing assetid opens the wrong record; and the lookup is now case-insensitive, because csvimport.py seeds 'Computer' and 'Machine' capitalised while this site's database holds them lowercase - a case-sensitive match produced dead links at whichever site seeded the other way. The band now carries pluginid and assettypename on each chip, which the resolver needs. TESTS assetRoute had none and neither did the band. Seven cover the resolver, including the assetid-vs-pluginid confusion and the seeding-case difference. On the backend, nothing asserted that the TARGET of a directional edge receives it in `incoming` - only that rows carry isdirectional. That missing assertion is this bug's exact shape, so it is now pinned: the endpoint was right all along, and the next such report should not cost anyone a re-diagnosis of the API. Verified on screen: the machine page shows "Controlled by G6K76CW3ESF" in the hero, the PC page is unchanged, and a page with no such link renders no empty band. Full suite 2162 passed, frontend 280 passed.
374 lines
15 KiB
Vue
374 lines
15 KiB
Vue
<template>
|
|
<div class="detail-page">
|
|
<div class="page-header">
|
|
<h2>Machine Details</h2>
|
|
<div class="header-actions">
|
|
<router-link :to="`/print/machine-badge/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
|
|
Print Badge
|
|
</router-link>
|
|
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
|
|
Print Label
|
|
</router-link>
|
|
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
|
|
Edit
|
|
</router-link>
|
|
<button @click="confirmRetire" class="btn btn-danger" v-if="machine">Retire</button>
|
|
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else-if="machine">
|
|
<!-- Dual-bay sibling banner: a Dualpath pair is one physical machine -->
|
|
<div v-if="machine.dualpathpartner" class="dualpath-banner">
|
|
Dual-bay machine - sibling bay:
|
|
<router-link
|
|
v-if="machine.dualpathpartner.machineid"
|
|
:to="`/machines/${machine.dualpathpartner.machineid}`"
|
|
>{{ machine.dualpathpartner.assetnumber }}</router-link>
|
|
<span v-else>{{ machine.dualpathpartner.assetnumber }}</span>
|
|
</div>
|
|
|
|
<!-- Hero Section -->
|
|
<div class="hero-card">
|
|
<div class="hero-image" v-if="machine.machine?.imageurl">
|
|
<img :src="withBase(machine.machine.imageurl)" :alt="machine.machine.modelname || 'Model photo'" />
|
|
</div>
|
|
<div class="hero-content">
|
|
<div class="hero-title">
|
|
<h1>{{ machine.assetnumber }}</h1>
|
|
<span v-if="machine.name" class="hero-alias">{{ machine.name }}</span>
|
|
</div>
|
|
<div class="hero-meta">
|
|
<span class="badge badge-lg badge-primary">
|
|
{{ machine.assettypename || 'Machine' }}
|
|
</span>
|
|
<span class="badge badge-lg" :class="getStatusClass(machine.statusname)">
|
|
{{ machine.statusname || 'Unknown' }}
|
|
</span>
|
|
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
|
|
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
|
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
|
|
</span>
|
|
</div>
|
|
<div class="hero-details">
|
|
<div class="hero-detail" v-if="machine.machine?.machinetypename">
|
|
<span class="hero-detail-label">Machine Type</span>
|
|
<span class="hero-detail-value">{{ machine.machine.machinetypename }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="machine.machine?.vendorname">
|
|
<span class="hero-detail-label">Vendor</span>
|
|
<span class="hero-detail-value">{{ machine.machine.vendorname }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="machine.machine?.modelname">
|
|
<span class="hero-detail-label">Model</span>
|
|
<span class="hero-detail-value">{{ machine.machine.modelname }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="machine.locationname">
|
|
<span class="hero-detail-label">Location</span>
|
|
<span class="hero-detail-value">{{ machine.locationname }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- The 6am band, machine side: which PCs drive this machine. The
|
|
same fact as a PC's "Serves machine", read from the other end -
|
|
and the end somebody is standing at when the machine has
|
|
stopped. It lived only in the relationships card near the
|
|
bottom of the page. -->
|
|
<AssetTriageBand
|
|
:assetid="machine.assetid"
|
|
counterparttype="computer"
|
|
counterpartlabel="Controlled by"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
|
<!-- Main Content Grid -->
|
|
<div class="content-grid">
|
|
<!-- Left Column -->
|
|
<div class="content-column">
|
|
<!-- Identity Section -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Identity</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Asset Number</span>
|
|
<span class="info-value">{{ machine.assetnumber }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="machine.name">
|
|
<span class="info-label">Name</span>
|
|
<span class="info-value">{{ machine.name }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('gaugelabreference', 'machine') && machine.gaugelabreference">
|
|
<span class="info-label">Gauge Lab Reference</span>
|
|
<span class="info-value mono">{{ machine.gaugelabreference }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('maintenancereference', 'machine') && machine.maintenancereference">
|
|
<span class="info-label">Maintenance Reference</span>
|
|
<span class="info-value mono">{{ machine.maintenancereference }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="machine.serialnumber">
|
|
<span class="info-label">Serial Number</span>
|
|
<span class="info-value mono">{{ machine.serialnumber }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hardware Section -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Hardware</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Machine Type</span>
|
|
<span class="info-value">{{ machine.machine?.machinetypename || '-' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Vendor</span>
|
|
<span class="info-value">
|
|
{{ machine.machine?.vendorname || '-' }}
|
|
<!-- Inherited from the catalog model, not stored on this record. -->
|
|
<small v-if="machine.machine?.vendorfrommodel" class="text-muted">(from model)</small>
|
|
</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Model</span>
|
|
<span class="info-value">{{ machine.machine?.modelname || '-' }}</span>
|
|
</div>
|
|
<!-- Only when it DIFFERS from this asset's own type. The two are
|
|
separate tables - modeltypes spans every kind of asset - but
|
|
they carry the same names in practice, so showing both
|
|
always put two identical Type rows in one section. Worth
|
|
seeing only when they disagree. -->
|
|
<div class="info-row"
|
|
v-if="machine.machine?.modeltypename && machine.machine.modeltypename !== machine.machine?.machinetypename">
|
|
<span class="info-label">Model type</span>
|
|
<span class="info-value">{{ machine.machine.modeltypename }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controller Section (for CNC machines) -->
|
|
<div class="section-card" v-if="machine.machine?.controllervendorname || machine.machine?.controllermodelname">
|
|
<h3 class="section-title">Controller</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Vendor</span>
|
|
<span class="info-value">{{ machine.machine?.controllervendorname || '-' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Model</span>
|
|
<span class="info-value">{{ machine.machine?.controllermodelname || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Right Column -->
|
|
<div class="content-column">
|
|
<!-- Location & Organization -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Location & Organization</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Location</span>
|
|
<span class="info-value">
|
|
<LocationMapTooltip
|
|
v-if="machine.mapx != null && machine.mapy != null"
|
|
:left="machine.mapx"
|
|
:top="machine.mapy"
|
|
:levelid="machine.levelid"
|
|
:machineName="machine.assetnumber"
|
|
>
|
|
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
|
</LocationMapTooltip>
|
|
<span v-else>{{ machine.locationname || '-' }}</span>
|
|
</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Business Unit</span>
|
|
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Machine Configuration -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Configuration</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Requires Manual Config</span>
|
|
<span class="info-value">
|
|
<span class="feature-tag" :class="{ active: machine.machine?.requiresmanualconfig }">
|
|
{{ machine.machine?.requiresmanualconfig ? 'Yes' : 'No' }}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Maintenance Section -->
|
|
<div class="section-card" v-if="machine.machine?.lastmaintenancedate || machine.machine?.nextmaintenancedate">
|
|
<h3 class="section-title">Maintenance</h3>
|
|
<div class="info-list">
|
|
<div class="info-row" v-if="machine.machine?.lastmaintenancedate">
|
|
<span class="info-label">Last Maintenance</span>
|
|
<span class="info-value">{{ formatDate(machine.machine.lastmaintenancedate) }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="machine.machine?.nextmaintenancedate">
|
|
<span class="info-label">Next Maintenance</span>
|
|
<span class="info-value">{{ formatDate(machine.machine.nextmaintenancedate) }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="machine.machine?.maintenanceintervaldays">
|
|
<span class="info-label">Interval</span>
|
|
<span class="info-value">{{ machine.machine.maintenanceintervaldays }} days</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Custom Fields -->
|
|
<CustomFieldsSection :assetid="machine.assetid" />
|
|
|
|
<!-- Plugin-contributed detail panels (ADR-010), incl. Warranty -->
|
|
<PluginAssetPanels :assetid="machine.assetid" />
|
|
|
|
<!-- All relationships (dualpath, controls, ...) -->
|
|
<AssetRelationships v-if="machine.assetid" :assetid="machine.assetid" />
|
|
|
|
<!-- Notes -->
|
|
<div class="section-card" v-if="machine.notes">
|
|
<h3 class="section-title">Notes</h3>
|
|
<p class="notes-text">{{ machine.notes }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audit Footer -->
|
|
<div class="audit-footer">
|
|
<span>Created {{ formatDate(machine.createddate) }}<template v-if="machine.createdby"> by {{ machine.createdby }}</template></span>
|
|
<span>Modified {{ formatDate(machine.modifieddate) }}<template v-if="machine.modifiedby"> by {{ machine.modifiedby }}</template></span>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="card">
|
|
<p style="text-align: center; color: var(--text-light);">Machine not found</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { machinesApi } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
|
import AssetTriageBand from '@/components/AssetTriageBand.vue'
|
|
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
|
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
|
import AssetRelationships from '@/components/AssetRelationships.vue'
|
|
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
|
import { setRecordTitle } from '@/composables/pageTitle'
|
|
import { useToast } from '@/composables/toast'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
const { isEnabled } = useIdentifierFlags()
|
|
|
|
const loading = ref(true)
|
|
const machine = ref(null)
|
|
const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
|
|
|
|
// relationships render via the shared AssetRelationships card
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const response = await machinesApi.get(route.params.id)
|
|
machine.value = response.data.data
|
|
setRecordTitle(machine.value?.assetnumber || machine.value?.name)
|
|
} catch (error) {
|
|
console.error('Error loading machine:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
function getStatusClass(status) {
|
|
if (!status) return 'badge-info'
|
|
const s = status.toLowerCase()
|
|
if (s === 'in use' || s === 'active') return 'badge-success'
|
|
if (s === 'in repair' || s === 'spare') return 'badge-warning'
|
|
if (s === 'retired' || s === 'disposed') return 'badge-danger'
|
|
return 'badge-info'
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-'
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
|
|
// Retire, not delete: the server deactivates the asset, so the machine
|
|
// leaves every list and report while its history and relationships stay put.
|
|
// Reversible by an admin setting isactive back to true.
|
|
async function confirmRetire() {
|
|
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
|
|
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
|
+ `History and relationships are kept, and it can be restored.`)) return
|
|
try {
|
|
await machinesApi.delete(machine.value?.machine?.machineid)
|
|
toast.success(`Retired ${label}`)
|
|
router.push('/machines')
|
|
} catch (error) {
|
|
console.error('Error retiring machine:', error)
|
|
toast.error(apiError(error, 'Failed to retire machine'))
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mono {
|
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
}
|
|
|
|
.dualpath-banner {
|
|
margin-bottom: 16px;
|
|
padding: 10px 14px;
|
|
border-radius: 6px;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-left: 4px solid var(--primary);
|
|
color: var(--text);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.dualpath-banner a {
|
|
color: var(--link);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.feature-tag {
|
|
display: inline-block;
|
|
padding: 0.3rem 0.625rem;
|
|
font-size: 0.875rem;
|
|
border-radius: 5px;
|
|
background: var(--bg);
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.feature-tag.active {
|
|
background: #e3f2fd;
|
|
color: #1976d2;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.feature-tag.active {
|
|
background: #1e3a5f;
|
|
color: #60a5fa;
|
|
}
|
|
}
|
|
</style>
|