Files
shopdb-flask/plugins/printers/frontend/views/PrinterDetail.vue
cproudlock f34b9ca710
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
Carry the level everywhere a position is drawn, and gate it per occurrence
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.
2026-08-18 09:36:45 -04:00

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 &gt; 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>