Every detail page read "West Jefferson ShopDB - Machine". Two tabs on two different machines were indistinguishable, a bookmark said nothing about what it pointed at, and a link shared into Teams previewed as the section rather than the thing. The router cannot fix this alone: afterEach runs before the view has fetched anything, so it only ever knows the KIND of page. So a detail view names its record once the record arrives, and both writers go through composables/pageTitle.js. THE ORDERING IS THE WHOLE PROBLEM. The router awaits the site settings to build the "<facility> ShopDB" prefix, so on a cold load it finishes AFTER a view that fetched quickly and would overwrite the good title with the generic one. The route's fullPath is claimed before that await, and the generic write stands down when a record title already belongs to the route being landed on. The reverse case is handled too: a view that names its record BEFORE the facility name arrives composed against the placeholder prefix, so the late resolve rewrites it rather than leaving "ShopDB - CSF16" on a site called West Jefferson. Both are pinned by tests, because both fail only under timing. Covers machines, PCs, printers, network devices, subnets, measuring tools, USB devices, printed parts, applications, employees, knowledge base articles and guides. Printers reuse the page's own displayTitle, which already discards the literal name "NONE" a discovered queue can carry, so the tab and the heading agree. A record with no usable name, or one that failed to load, keeps the section name: "Machine" beats "Machine 4192" for somebody scanning a row of tabs. Long names are cut at a word boundary - knowledge base articles are named with a whole sentence of description, which a browser otherwise truncates mid-word. Reports gained meta.title, which the router already supported and nothing used. That also fixes "Pc Relationships", which the name-derived spelling produced.
445 lines
16 KiB
Vue
445 lines
16 KiB
Vue
<template>
|
|
<div class="detail-page">
|
|
<div class="page-header">
|
|
<h2>Computer Details</h2>
|
|
<div class="header-actions">
|
|
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
|
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
|
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else-if="computer">
|
|
<!-- Hero Section -->
|
|
<div class="hero-card">
|
|
<div class="hero-image" v-if="computer.computer?.imageurl">
|
|
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
|
|
</div>
|
|
<div class="hero-content">
|
|
<div class="hero-title">
|
|
<h1>{{ computer.assetnumber }}</h1>
|
|
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
|
|
</div>
|
|
<div class="hero-meta">
|
|
<span class="badge badge-lg badge-info">Computer</span>
|
|
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
|
|
{{ computer.statusname || 'Unknown' }}
|
|
</span>
|
|
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
|
|
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
|
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
|
|
</span>
|
|
</div>
|
|
<div class="hero-details">
|
|
<div class="hero-detail" v-if="computer.computer?.computertypename">
|
|
<span class="hero-detail-label">PC Type</span>
|
|
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="computer.computer?.osname">
|
|
<span class="hero-detail-label">OS</span>
|
|
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="computer.locationname">
|
|
<span class="hero-detail-label">Location</span>
|
|
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
|
</div>
|
|
</div>
|
|
</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">{{ computer.assetnumber }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="computer.name">
|
|
<span class="info-label">Name</span>
|
|
<span class="info-value">{{ computer.name }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname">
|
|
<span class="info-label">Hostname</span>
|
|
<span class="info-value mono">{{ computer.computer.hostname }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="computer.serialnumber">
|
|
<span class="info-label">Serial Number</span>
|
|
<span class="info-value mono">{{ computer.serialnumber }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('gaugelabreference', 'computer') && computer.gaugelabreference">
|
|
<span class="info-label">Gauge Lab Reference</span>
|
|
<span class="info-value mono">{{ computer.gaugelabreference }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('maintenancereference', 'computer') && computer.maintenancereference">
|
|
<span class="info-label">Maintenance Reference</span>
|
|
<span class="info-value mono">{{ computer.maintenancereference }}</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">Computer Type</span>
|
|
<span class="info-value">{{ computer.computer?.computertypename || '-' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Vendor</span>
|
|
<span class="info-value">
|
|
{{ computer.computer?.vendorname || '-' }}
|
|
<!-- Inherited from the catalog model, not stored on this record. -->
|
|
<small v-if="computer.computer?.vendorfrommodel" class="text-muted">(from model)</small>
|
|
</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Model</span>
|
|
<span class="info-value">{{ computer.computer?.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="computer.computer?.modeltypename && computer.computer.modeltypename !== computer.computer?.computertypename">
|
|
<span class="info-label">Model type</span>
|
|
<span class="info-value">{{ computer.computer.modeltypename }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Operating System</span>
|
|
<span class="info-value">{{ computer.computer?.osname || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Remote Access -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Remote Access</h3>
|
|
<div class="access-methods">
|
|
<template v-for="a in (computer.accessmethods || [])" :key="a.id">
|
|
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link">{{ a.name }}</a>
|
|
<span v-else class="access-link disabled" title="No hostname/IP set for this PC">{{ a.name }}</span>
|
|
</template>
|
|
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status / Check-in -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Status</h3>
|
|
<div class="info-list">
|
|
<div class="info-row">
|
|
<span class="info-label">Logged In User</span>
|
|
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Last Reported</span>
|
|
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Last Boot</span>
|
|
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column -->
|
|
<div class="content-column">
|
|
<!-- Network -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Network</h3>
|
|
<div v-if="computer.communications?.length" class="network-list">
|
|
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
|
|
<div class="network-primary">
|
|
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
|
|
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
|
|
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
|
</div>
|
|
<div class="network-secondary" v-if="comm.macaddress">
|
|
<span class="mac-address mono">{{ comm.macaddress }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-else class="muted">No network addresses on record</p>
|
|
</div>
|
|
|
|
<!-- 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="computer.mapx != null && computer.mapy != null"
|
|
:left="computer.mapx"
|
|
:top="computer.mapy"
|
|
:levelid="computer.levelid"
|
|
:machineName="computer.assetnumber"
|
|
>
|
|
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
|
</LocationMapTooltip>
|
|
<span v-else>{{ computer.locationname || '-' }}</span>
|
|
</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Business Unit</span>
|
|
<span class="info-value">{{ computer.businessunitname || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Installed Applications -->
|
|
<div class="section-card" v-if="installedApps.length > 0">
|
|
<h3 class="section-title">Installed Applications</h3>
|
|
<div class="app-list">
|
|
<router-link
|
|
v-for="app in installedApps"
|
|
:key="app.id"
|
|
:to="`/applications/${app.appid}`"
|
|
class="app-item"
|
|
>
|
|
<div class="app-info">
|
|
<span class="app-name">{{ app.appname }}</span>
|
|
<span class="app-version" v-if="app.installedversion">v{{ app.installedversion }}</span>
|
|
</div>
|
|
<div class="app-desc" v-if="app.appdescription">
|
|
{{ app.appdescription }}
|
|
</div>
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Custom Fields -->
|
|
<CustomFieldsSection :assetid="computer.assetid" />
|
|
|
|
<!-- Warranty -->
|
|
<PluginAssetPanels :assetid="computer.assetid" />
|
|
|
|
<!-- All relationships (controls, defaultprinter, ...) -->
|
|
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
|
|
|
<!-- Notes -->
|
|
<div class="section-card" v-if="computer.notes">
|
|
<h3 class="section-title">Notes</h3>
|
|
<p class="notes-text">{{ computer.notes }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audit Footer -->
|
|
<div class="audit-footer">
|
|
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
|
|
<span>Modified {{ formatDate(computer.modifieddate) }}<template v-if="computer.modifiedby"> by {{ computer.modifiedby }}</template></span>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="card">
|
|
<p style="text-align: center; color: var(--text-light);">Computer not found</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { colorStyle } from "@/utils/colorStyle"
|
|
import { useRoute } from 'vue-router'
|
|
import { computersApi, applicationsApi } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
|
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
|
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
|
import AssetRelationships from '@/components/AssetRelationships.vue'
|
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
|
import { setRecordTitle } from '@/composables/pageTitle'
|
|
|
|
const route = useRoute()
|
|
const { isEnabled } = useIdentifierFlags()
|
|
|
|
const loading = ref(true)
|
|
const computer = ref(null)
|
|
const installedApps = ref([])
|
|
|
|
// Warranty fetch + hero badge (shared across asset detail pages).
|
|
const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
|
|
|
|
// relationships render via the shared AssetRelationships card
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const response = await computersApi.get(route.params.id)
|
|
computer.value = response.data.data
|
|
setRecordTitle(computer.value?.assetnumber || computer.value?.name)
|
|
|
|
// Load installed applications
|
|
try {
|
|
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
|
|
installedApps.value = appsResponse.data.data || []
|
|
} catch (appError) {
|
|
}
|
|
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
|
} catch (error) {
|
|
console.error('Error loading computer:', 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') return 'badge-warning'
|
|
if (s === 'retired') return 'badge-danger'
|
|
return 'badge-info'
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-'
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* PC-specific styles - shared styles are in global style.css */
|
|
|
|
/* Remote-access protocol links */
|
|
.access-methods {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
.access-methods .muted {
|
|
color: var(--text-light);
|
|
}
|
|
.access-link {
|
|
display: inline-block;
|
|
padding: 3px 12px;
|
|
margin: 0 6px 4px 0;
|
|
border-radius: 14px;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
text-decoration: none;
|
|
}
|
|
.access-link:hover {
|
|
background: var(--primary-dark);
|
|
text-decoration: none;
|
|
}
|
|
.access-link.disabled {
|
|
background: var(--secondary);
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Installed Applications */
|
|
.app-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.app-item {
|
|
display: block;
|
|
padding: 1rem;
|
|
background: var(--bg);
|
|
border-radius: 8px;
|
|
text-decoration: none;
|
|
color: inherit;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.app-item:hover {
|
|
background: var(--border);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.app-info {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.app-name {
|
|
font-weight: 500;
|
|
font-size: 1.125rem;
|
|
color: var(--text);
|
|
}
|
|
|
|
.app-version {
|
|
font-size: 1rem;
|
|
color: var(--text-light);
|
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
|
}
|
|
|
|
.app-desc {
|
|
margin-top: 0.375rem;
|
|
font-size: 1rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
/* Network details */
|
|
.network-details {
|
|
margin-top: 0.5rem;
|
|
display: flex;
|
|
gap: 1.25rem;
|
|
font-size: 1rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
/* Network card (IP / MAC list) */
|
|
.network-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6rem;
|
|
}
|
|
.network-item {
|
|
padding: 0.5rem 0.7rem;
|
|
background: var(--bg);
|
|
border-radius: 6px;
|
|
}
|
|
.network-primary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.ip-address {
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.comm-type {
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
.primary-badge {
|
|
padding: 0.1rem 0.5rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
border-radius: 10px;
|
|
}
|
|
.mac-address {
|
|
font-size: 0.82rem;
|
|
color: var(--text-light);
|
|
}
|
|
.muted {
|
|
color: var(--text-light);
|
|
margin: 0;
|
|
}
|
|
</style>
|