The hover mini-map said "This asset has a position (2835, 1410) but no level" for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid prop, NONE of its seven call sites were taught to pass one - printer, machine and PC detail pages, the toner report, enforcement reports, the warranty chip and the dashboard cards - so the component correctly reported a missing level and the preview never drew. Two payloads behind those views also emitted mapx/mapy with no level: the toner report and the enforcement report. The map PDF export had the ORIGINAL bug still in it: it plotted every filtered asset onto the sheet, so exporting the ground floor printed second-floor markers on it. Worse than on screen, because nobody can correct a sheet once it has been printed and carried onto the floor. It now exports only the level being viewed. The legacy import loader sent mapleft/maptop with no level at three call sites. That loader is the one still to run against production, and every marker it created would have been undrawable. It now resolves the site's default level - the legacy schema predates levels and has one floor plan, so that is what its coordinates mean. THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid', not whether each position does: one module emitted 'mapx' six times and 'levelid' once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/ and plugins/, and fails any Vue file that binds tooltip coordinates without :levelid. Both new rules were confirmed to fail the build against planted violations before being relied on. Printer QR labels: the asset number is no longer printed. A label now reads name (8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber because that is where sites actually keep it - every printer here has an empty name field, so preferring the Windows queue name alone would have printed a blank line on every label.
471 lines
18 KiB
Vue
471 lines
18 KiB
Vue
<template>
|
|
<div class="detail-page">
|
|
<div class="page-header">
|
|
<h2>Printer Details</h2>
|
|
<div class="header-actions">
|
|
<router-link :to="`/print/printer-qr/${$route.params.id}`" class="btn btn-secondary" target="_blank">
|
|
Print QR
|
|
</router-link>
|
|
<router-link :to="`/print/asset-label/printer/${$route.params.id}`" class="btn btn-secondary" target="_blank">
|
|
Print Label
|
|
</router-link>
|
|
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
|
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else-if="printer">
|
|
<!-- Hero Section -->
|
|
<div class="hero-card">
|
|
<div class="hero-image" v-if="printer.printer?.imageurl">
|
|
<img :src="withBase(printer.printer.imageurl)" :alt="printer.printer.modelname || 'Model photo'" />
|
|
</div>
|
|
<div class="hero-content">
|
|
<div class="hero-title">
|
|
<h1>{{ displayTitle }}</h1>
|
|
</div>
|
|
<div class="hero-meta">
|
|
<span class="badge badge-lg badge-printer">Printer</span>
|
|
<span v-if="printer.printer?.iscsf" class="badge badge-lg badge-info">CSF</span>
|
|
<span v-if="printer.printer?.iscolor" class="badge badge-lg badge-success">Color</span>
|
|
<span v-if="printer.printer?.isnetwork" class="badge badge-lg badge-secondary">Network</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="printer.printer?.vendorname">
|
|
<span class="hero-detail-label">Vendor</span>
|
|
<span class="hero-detail-value">
|
|
{{ printer.printer.vendorname }}
|
|
<!-- Inherited from the catalog model, not stored on this record. -->
|
|
<small v-if="printer.printer?.vendorfrommodel" class="text-muted">(from model)</small>
|
|
</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="printer.printer?.modelname">
|
|
<span class="hero-detail-label">Model</span>
|
|
<span class="hero-detail-value">{{ printer.printer.modelname }}</span>
|
|
</div>
|
|
<!-- Only when it DIFFERS from the printer's own type: the two are
|
|
separate tables but carry the same names in practice. -->
|
|
<div class="hero-detail"
|
|
v-if="printer.printer?.modeltypename && printer.printer.modeltypename !== printer.printer?.printertypename">
|
|
<span class="hero-detail-label">Model type</span>
|
|
<span class="hero-detail-value">{{ printer.printer.modeltypename }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="printer.serialnumber">
|
|
<span class="hero-detail-label">Serial Number</span>
|
|
<span class="hero-detail-value mono">{{ printer.serialnumber }}</span>
|
|
</div>
|
|
<div class="hero-detail" v-if="ipAddress">
|
|
<span class="hero-detail-label">IP Address</span>
|
|
<span class="hero-detail-value mono">{{ ipAddress }}</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 mono">{{ printer.assetnumber }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="printer.printer?.windowsname">
|
|
<span class="info-label">Windows Name</span>
|
|
<span class="info-value mono">{{ printer.printer.windowsname }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('fqdn', 'printer') && printer.printer?.hostname">
|
|
<span class="info-label">Hostname / FQDN</span>
|
|
<span class="info-value mono">{{ printer.printer.hostname }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="printer.printer?.sharename">
|
|
<span class="info-label">CSF Share Name</span>
|
|
<span class="info-value mono">{{ printer.printer.sharename }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="printer.serialnumber">
|
|
<span class="info-label">Serial Number</span>
|
|
<span class="info-value mono">{{ printer.serialnumber }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('gaugelabreference', 'printer') && printer.gaugelabreference">
|
|
<span class="info-label">Gauge Lab Reference</span>
|
|
<span class="info-value mono">{{ printer.gaugelabreference }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="isEnabled('maintenancereference', 'printer') && printer.maintenancereference">
|
|
<span class="info-label">Maintenance Reference</span>
|
|
<span class="info-value mono">{{ printer.maintenancereference }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Printer Settings -->
|
|
<div class="section-card">
|
|
<h3 class="section-title">Printer Settings</h3>
|
|
<div class="info-list">
|
|
<div class="info-row" v-if="printer.printer?.installpath">
|
|
<span class="info-label">Install Path</span>
|
|
<span class="info-value mono">{{ printer.printer.installpath }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Color</span>
|
|
<span class="info-value">{{ printer.printer?.iscolor ? 'Yes' : 'No' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Duplex</span>
|
|
<span class="info-value">{{ printer.printer?.isduplex ? 'Yes' : 'No' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">Network Printer</span>
|
|
<span class="info-value">{{ printer.printer?.isnetwork ? 'Yes' : 'No' }}</span>
|
|
</div>
|
|
<div class="info-row">
|
|
<span class="info-label">CSF Printer</span>
|
|
<span class="info-value">{{ printer.printer?.iscsf ? 'Yes' : 'No' }}</span>
|
|
</div>
|
|
<div class="info-row" v-if="printer.printer?.pin">
|
|
<span class="info-label">PIN</span>
|
|
<span class="info-value mono">{{ printer.printer.pin }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column -->
|
|
<div class="content-column">
|
|
<!-- Network -->
|
|
<div class="section-card" v-if="printer.communications?.length">
|
|
<h3 class="section-title">Network</h3>
|
|
<div class="network-list">
|
|
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
|
|
<div class="network-primary">
|
|
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
|
|
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
|
</div>
|
|
<div class="network-secondary" v-if="comm.macaddress">
|
|
<span class="mac-address">{{ comm.macaddress }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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">Map Location</span>
|
|
<span class="info-value">
|
|
<LocationMapTooltip
|
|
v-if="printer.mapx != null && printer.mapy != null"
|
|
:left="printer.mapx"
|
|
:top="printer.mapy"
|
|
:levelid="printer.levelid"
|
|
:machineName="printer.name || printer.assetnumber"
|
|
>
|
|
<span class="location-link">View on Map</span>
|
|
</LocationMapTooltip>
|
|
<span v-else>Not mapped</span>
|
|
</span>
|
|
</div>
|
|
<div class="info-row" v-if="printer.businessunitname">
|
|
<span class="info-label">Business Unit</span>
|
|
<span class="info-value">{{ printer.businessunitname }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Custom Fields -->
|
|
<CustomFieldsSection :assetid="printer.assetid" />
|
|
|
|
<!-- Warranty -->
|
|
<PluginAssetPanels :assetid="printer.assetid" />
|
|
|
|
<!-- All relationships (defaultprinter, connectedto, ...) -->
|
|
<AssetRelationships v-if="printer.assetid" :assetid="printer.assetid" />
|
|
|
|
<!-- Notes -->
|
|
<div class="section-card" v-if="printer.notes">
|
|
<h3 class="section-title">Notes</h3>
|
|
<p class="notes-text">{{ printer.notes }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Supplies Card -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3>Supplies</h3>
|
|
</div>
|
|
|
|
<div v-if="supplies.length === 0" class="empty-state">
|
|
No supply information available
|
|
</div>
|
|
|
|
<div v-else class="supplies-grid">
|
|
<div v-for="supply in supplies" :key="supply.itemid || supply.name" class="supply-item">
|
|
<div class="supply-header">
|
|
<span class="supply-name">{{ supply.name }}</span>
|
|
<span class="supply-level" :class="supply.status">
|
|
{{ supply.level !== null ? `${supply.level}%` : 'N/A' }}
|
|
</span>
|
|
</div>
|
|
<div class="supply-bar">
|
|
<div
|
|
class="supply-bar-fill"
|
|
:class="supply.status"
|
|
:style="{ width: `${supply.level || 0}%` }"
|
|
></div>
|
|
</div>
|
|
<div class="supply-meta">
|
|
<span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste"> ({{ supply.remaining }}% remaining)</template></span>
|
|
<span v-if="supply.partnumbers && supply.partnumbers.length" class="supply-parts">
|
|
<span
|
|
v-for="part in supply.partnumbers"
|
|
:key="part.partnumber"
|
|
class="supply-part"
|
|
:data-tip="`Part ${part.partnumber}` + (part.pageyield ? ` - ${part.pageyield} pages` : '')"
|
|
>{{ part.marketingname || part.partnumber }}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Drivers Card: pulled from the driver catalog by this printer's model -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3>Drivers</h3>
|
|
</div>
|
|
|
|
<div v-if="drivers.length === 0" class="empty-state">
|
|
No drivers for this model. Add one under Settings > Printer Drivers and
|
|
link it to this printer's model.
|
|
</div>
|
|
|
|
<div v-else class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Location</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="driver in drivers" :key="driver.driverid">
|
|
<td><strong>{{ driver.name }}</strong></td>
|
|
<td>
|
|
<a v-if="isHttp(driver.location)" :href="driver.location" target="_blank" class="mono">{{ driver.location }}</a>
|
|
<span v-else class="mono">{{ driver.location }}</span>
|
|
</td>
|
|
<td>{{ driver.description || '-' }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audit Footer -->
|
|
<div class="audit-footer">
|
|
<span>Created {{ formatDate(printer.createddate) }}<template v-if="printer.createdby"> by {{ printer.createdby }}</template></span>
|
|
<span>Modified {{ formatDate(printer.modifieddate) }}<template v-if="printer.modifiedby"> by {{ printer.modifiedby }}</template></span>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="card">
|
|
<p style="text-align: center; color: var(--text-light);">Printer not found</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, computed } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { printersApi } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
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 { useWarrantyBadge } from '@/composables/warrantyBadge'
|
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
|
|
|
const route = useRoute()
|
|
const { isEnabled } = useIdentifierFlags()
|
|
|
|
const loading = ref(true)
|
|
const printer = ref(null)
|
|
const { heroWarranty, warrantyDate } = useWarrantyBadge(() => printer.value?.assetid)
|
|
const supplies = ref([])
|
|
const drivers = ref([])
|
|
|
|
// Best display identifier for a printer. name is often the literal "NONE",
|
|
// so fall back to the Windows name, then hostname, then asset number.
|
|
const displayTitle = computed(() => {
|
|
const p = printer.value
|
|
if (!p) return ''
|
|
const name = (p.name || '').trim()
|
|
if (name && name.toUpperCase() !== 'NONE') return name
|
|
return p.printer?.windowsname || p.printer?.hostname || p.assetnumber
|
|
})
|
|
|
|
function isHttp(loc) {
|
|
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
|
|
}
|
|
|
|
// Get IP address from communications
|
|
const ipAddress = computed(() => {
|
|
if (!printer.value?.communications) return null
|
|
const primaryComm = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
|
|
return primaryComm?.ipaddress || null
|
|
})
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const [printerRes, suppliesRes] = await Promise.all([
|
|
printersApi.get(route.params.id),
|
|
printersApi.getSupplies(route.params.id).catch(() => ({ data: { data: [] } }))
|
|
])
|
|
|
|
printer.value = printerRes.data.data
|
|
// supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
|
|
supplies.value = suppliesRes.data.data?.supplies || []
|
|
// drivers are attached to the printer detail, matched by model
|
|
drivers.value = printerRes.data.data?.drivers || []
|
|
} catch (error) {
|
|
console.error('Error loading printer:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
function getStatusClass(status) {
|
|
if (!status) return 'badge-info'
|
|
const s = status.toLowerCase()
|
|
if (s === 'in use' || s === 'active' || s === 'online') return 'badge-success'
|
|
if (s === 'in repair' || s === 'offline') return 'badge-warning'
|
|
if (s === 'retired' || s === 'error') return 'badge-danger'
|
|
return 'badge-info'
|
|
}
|
|
|
|
function formatSupplyType(supplytype) {
|
|
if (!supplytype) return ''
|
|
return supplytype.charAt(0).toUpperCase() + supplytype.slice(1)
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-'
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Printer-specific styles - shared styles are in global style.css */
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
color: var(--text-light);
|
|
padding: 2.5rem;
|
|
font-size: 1.125rem;
|
|
}
|
|
|
|
/* Supplies */
|
|
.mono { font-family: monospace; font-size: 0.85rem; word-break: break-all; }
|
|
|
|
.supplies-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
.supply-item {
|
|
background: var(--bg);
|
|
padding: 1.25rem;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.supply-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.supply-name {
|
|
font-weight: 500;
|
|
font-size: 1.125rem;
|
|
}
|
|
|
|
.supply-level {
|
|
font-weight: 600;
|
|
font-size: 1.125rem;
|
|
}
|
|
|
|
.supply-level.ok { color: var(--success); }
|
|
.supply-level.low { color: var(--warning); }
|
|
.supply-level.critical { color: var(--danger); }
|
|
|
|
.supply-bar {
|
|
height: 10px;
|
|
background: var(--border);
|
|
border-radius: 5px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.supply-bar-fill {
|
|
height: 100%;
|
|
transition: width 0.3s ease;
|
|
background: var(--success);
|
|
}
|
|
|
|
.supply-bar-fill.low { background: var(--warning); }
|
|
.supply-bar-fill.critical { background: var(--danger); }
|
|
|
|
.supply-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 1rem;
|
|
color: var(--text-light);
|
|
margin-top: 0.625rem;
|
|
}
|
|
|
|
.supply-parts {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.supply-part {
|
|
cursor: help;
|
|
border-bottom: 1px dotted var(--text-light);
|
|
position: relative;
|
|
}
|
|
|
|
/* instant CSS tooltip, no native title delay */
|
|
.supply-part:hover::after {
|
|
content: attr(data-tip);
|
|
position: absolute;
|
|
bottom: 125%;
|
|
right: 0;
|
|
white-space: nowrap;
|
|
background: var(--text);
|
|
color: var(--bg-card);
|
|
padding: 0.35rem 0.6rem;
|
|
border-radius: 4px;
|
|
font-size: 0.85rem;
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
}
|
|
</style>
|