diff --git a/plugins/printers/frontend/views/TonerReport.vue b/plugins/printers/frontend/views/TonerReport.vue index b0c3500..59717ea 100644 --- a/plugins/printers/frontend/views/TonerReport.vue +++ b/plugins/printers/frontend/views/TonerReport.vue @@ -53,22 +53,42 @@ Printer Name - Asset # - Location - IP Address + Hostname Supplies - + + + + {{ printer.printername || 'Unknown' }} + + + {{ printer.printername || 'Unknown' }} - {{ printer.assetnumber }} - {{ printer.location || '-' }} - {{ printer.ipaddress }} + + + {{ fqdnFor(printer) }} + {{ printer.ipaddress || '-' }} +
{{ supply.level }}% + + {{ part.partnumber }} + - +
@@ -102,13 +132,14 @@ import { ref, computed, onMounted } from 'vue' import { printersApi } from '@/api' import EmailReportButton from '@/components/EmailReportButton.vue' +import LocationMapTooltip from '@/components/LocationMapTooltip.vue' +import { getPrinterHostnameTemplate } from '@/utils/siteSettings' const emailColumns = [ { key: 'printer', label: 'Printer' }, - { key: 'assetnumber', label: 'Asset #' }, - { key: 'location', label: 'Location' }, - { key: 'ipaddress', label: 'IP Address' }, + { key: 'hostname', label: 'Hostname' }, { key: 'supply', label: 'Supply' }, + { key: 'partnumber', label: 'Part Number' }, { key: 'level', label: 'Level' }, { key: 'status', label: 'Status' }, ] @@ -125,6 +156,47 @@ const filterOptions = [ { label: 'Low', value: 'low' } ] +// Printers have no stored FQDN; the site builds one from the IP via the +// printer_hostname_template setting, the same way PrinterForm does. +const hostnameTemplate = ref('') + +function fqdnFor(printer) { + if (!printer.ipaddress || !hostnameTemplate.value) return '' + return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-')) +} + +// A model can list several capacity tiers for one color, so the chip shows the +// part number and the tooltip says which one it is. +function partTooltip(part) { + const bits = [part.marketingname, part.capacitytier] + if (part.pageyield) bits.push(part.pageyield + ' pages') + return bits.filter(Boolean).join(' - ') +} + +// One flat row per part number so an ordering list is copy-pasteable; supplies +// with no part on file still get a row. +function reportRows() { + const rows = [] + for (const printer of filteredPrinters.value) { + for (const supply of printer.supplies || []) { + const parts = supply.partnumbers && supply.partnumbers.length + ? supply.partnumbers.map(p => p.partnumber) + : [''] + for (const partnumber of parts) { + rows.push({ + printer: printer.printername || '', + hostname: fqdnFor(printer) || printer.ipaddress || '', + supply: supply.name || '', + partnumber: partnumber, + level: supply.level, + status: supply.status || '', + }) + } + } + } + return rows +} + const filteredPrinters = computed(() => { if (filter.value === 'all') return printers.value return printers.value.filter(p => @@ -132,36 +204,17 @@ const filteredPrinters = computed(() => { ) }) -// One row per supply, honoring the active filter, for the emailed table. -const emailRows = computed(() => { - const rows = [] - for (const printer of filteredPrinters.value) { - for (const supply of printer.supplies || []) { - rows.push({ - printer: printer.printername || '', - assetnumber: printer.assetnumber || '', - location: printer.location || '', - ipaddress: printer.ipaddress || '', - supply: supply.name || '', - level: supply.level + '%', - status: supply.status || '', - }) - } - } - return rows -}) +// Emailed table, honoring the active filter. +const emailRows = computed(() => + reportRows().map(row => ({ ...row, level: row.level + '%' })) +) function exportCSV() { - // one row per supply, honoring the active filter const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"` - const rows = [['printer', 'assetnumber', 'location', 'ipaddress', 'supply', 'level', 'status']] - for (const printer of filteredPrinters.value) { - for (const supply of printer.supplies || []) { - rows.push([ - printer.printername || '', printer.assetnumber || '', printer.location || '', - printer.ipaddress || '', supply.name || '', supply.level, supply.status || '' - ]) - } + const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status'] + const rows = [header] + for (const row of reportRows()) { + rows.push(header.map(key => row[key])) } const csv = rows.map(row => row.map(quote).join(',')).join('\n') const link = document.createElement('a') @@ -173,6 +226,7 @@ function exportCSV() { onMounted(async () => { try { + hostnameTemplate.value = await getPrinterHostnameTemplate() const response = await printersApi.lowSupplies() const data = response.data.data printers.value = data.printers || [] @@ -218,32 +272,31 @@ onMounted(async () => { color: var(--danger); } +/* One grid for the whole cell, with each supply row contributing its cells + directly (display: contents). Cartridge names were being ellipsised inside a + fixed 120px column, which hid the very thing being reordered; a max-content + column sizes to the longest name instead, and the columns still line up + across the rows of one printer. */ .supplies-cell { - min-width: 280px; + min-width: 460px; + display: grid; + grid-template-columns: max-content minmax(80px, 1fr) auto max-content; + column-gap: 0.5rem; + row-gap: 0.35rem; + align-items: center; } .supply-row { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.35rem; -} - -.supply-row:last-child { - margin-bottom: 0; + display: contents; } .supply-name { - flex: 0 0 120px; font-size: 0.85rem; color: var(--text-light); - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } .supply-bar-track { - flex: 1; height: 8px; background: var(--border); border-radius: 4px; @@ -269,12 +322,33 @@ onMounted(async () => { } .supply-level { - flex: 0 0 40px; + min-width: 40px; text-align: right; font-size: 0.85rem; font-weight: 600; } +.supply-parts { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.part-chip { + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; + font-size: 0.75rem; + padding: 0.05rem 0.35rem; + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text); + white-space: nowrap; +} + +.part-none { + font-size: 0.85rem; + color: var(--text-light); +} + .supply-text-ok { color: var(--success); }