Relocate warranty, measuringtools, network, printers, usb, notifications, computers, and slides into plugins/<name>/frontend/. Each plugin's views are pulled from wherever they lived (own dir, plus the shared views/settings/, views/reports/, views/print/ dirs, and top-level views) into the plugin's frontend/views/, and its route file becomes the self-contained routes.js. Handled the messy cases: - computers: name mismatch (its views live in views/pcs/) - moved by following the route file's own imports, so the dir name did not matter. Its OS/access- protocol/PC-type settings views move with it (only computers.js routed them). - network: NetworkHub's sibling sub-views (NetworkDevicesList, SubnetsBrowse, not directly routed) moved too so its `./` imports resolve. - printers: the qrLogo helper is SHARED with core AssetLabel, so it stays in views/print/ and PrinterQR imports it via @/views/print/qrLogo. - slides: route file is toplevel-only (TVDashboard); SlideManager stays core (core.js routes /settings/slides). frontend/src/views/ now holds only core views; frontend/src/router/routes/ holds only core.js. All 13 plugins are self-contained under plugins/<name>/frontend/. Verified live: Network (hub + moved sub-views), Computers (name mismatch), GE-Enforce (helper), printedparts all render from their staged frontends. Build + 58 vitest + naming green.
458 lines
17 KiB
Vue
458 lines
17 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="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 }}</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>
|
|
<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"
|
|
: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 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>
|