Files
shopdb-flask/plugins/printers/frontend/views/TonerReport.vue
cproudlock 4d807ccb4b toner report: the part to order, and where the printer is
The report exists to answer "what needs replacing and where do I get it",
and it was answering neither. The part numbers were already in the
lowsupplies payload and simply never rendered; a chip per part now shows
them, with capacity tier and page yield on hover, since a model can list
several tiers for one colour.

Asset # and Location columns are gone. Location is replaced by the
floor-plan preview the asset pages already use, hung off the printer name
via its mapx/mapy.

The IP is now the site's FQDN (printer_hostname_template, built from the
IP exactly as PrinterForm does) and links to the printer's own web page in
a new tab - the report is a worklist, and losing your place in it to visit
one printer means finding your row again. The raw IP stays on hover.

Cartridge names were ellipsised inside a fixed 120px column, hiding the
one thing being reordered. The supplies cell is a grid with a max-content
name column, so names show in full and still line up across a printer's
rows.

CSV and emailed exports follow the screen, with one row per part number so
the result is a copy-pasteable order list.
2026-08-12 11:43:25 -04:00

379 lines
11 KiB
Vue

<template>
<div>
<div class="page-header">
<h1>Toner Report</h1>
<div class="header-actions">
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
:columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading supply data...</div>
<div v-else-if="error" class="card">
<p class="text-danger">{{ error }}</p>
</div>
<template v-else>
<!-- Summary Bar -->
<div class="summary-bar">
<div class="summary-stat card">
<div class="stat-value">{{ summary.total_checked }}</div>
<div class="stat-label">Printers Checked</div>
</div>
<div class="summary-stat card stat-low">
<div class="stat-value">{{ summary.low }}</div>
<div class="stat-label">Low Supply</div>
</div>
<div class="summary-stat card stat-critical">
<div class="stat-value">{{ summary.critical }}</div>
<div class="stat-label">Critical Supply</div>
</div>
</div>
<!-- Filter Buttons -->
<div class="filters">
<button
v-for="f in filterOptions"
:key="f.value"
class="btn"
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
@click="filter = f.value"
>
{{ f.label }}
</button>
</div>
<!-- Printers Table -->
<div class="card">
<div class="table-container">
<table v-if="filteredPrinters.length">
<thead>
<tr>
<th>Printer Name</th>
<th>Hostname</th>
<th>Supplies</th>
</tr>
</thead>
<tbody>
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
<td>
<!-- Coordinates give the name the same floor-plan preview the
printer's own page uses; it replaces the Location column. -->
<LocationMapTooltip
v-if="printer.mapx != null && printer.mapy != null"
:left="printer.mapx"
:top="printer.mapy"
:machineName="printer.printername || printer.assetnumber"
>
<router-link :to="`/printers/${printer.printerid}`">
{{ printer.printername || 'Unknown' }}
</router-link>
</LocationMapTooltip>
<router-link v-else :to="`/printers/${printer.printerid}`">
{{ printer.printername || 'Unknown' }}
</router-link>
</td>
<td>
<!-- Opens the printer's own web page. New tab: the report is a
worklist, and losing your place in it to visit one printer
means finding your row again. -->
<a
v-if="fqdnFor(printer)"
:href="`http://${fqdnFor(printer)}`"
target="_blank"
rel="noopener noreferrer"
:title="printer.ipaddress"
>{{ fqdnFor(printer) }}</a>
<span v-else>{{ printer.ipaddress || '-' }}</span>
</td>
<td class="supplies-cell">
<div
v-for="(supply, idx) in printer.supplies"
:key="idx"
class="supply-row"
>
<span class="supply-name">{{ supply.name }}</span>
<div class="supply-bar-track">
<div
class="supply-bar-fill"
:class="'supply-' + supply.status"
:style="{ width: Math.max(supply.level, 2) + '%' }"
></div>
</div>
<span class="supply-level" :class="'supply-text-' + supply.status">
{{ supply.level }}%
</span>
<span class="supply-parts">
<span
v-for="part in supply.partnumbers"
:key="part.partnumber"
class="part-chip"
:title="partTooltip(part)"
>{{ part.partnumber }}</span>
<span v-if="!supply.partnumbers || !supply.partnumbers.length"
class="part-none" title="No part number on file for this model and color">-</span>
</span>
</div>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-state">No printers match the selected filter.</p>
</div>
</div>
</template>
</div>
</template>
<script setup>
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: 'hostname', label: 'Hostname' },
{ key: 'supply', label: 'Supply' },
{ key: 'partnumber', label: 'Part Number' },
{ key: 'level', label: 'Level' },
{ key: 'status', label: 'Status' },
]
const loading = ref(true)
const error = ref(null)
const printers = ref([])
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
const filter = ref('all')
const filterOptions = [
{ label: 'All', value: 'all' },
{ label: 'Critical', value: 'critical' },
{ 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 =>
p.supplies.some(s => s.status === filter.value)
)
})
// Emailed table, honoring the active filter.
const emailRows = computed(() =>
reportRows().map(row => ({ ...row, level: row.level + '%' }))
)
function exportCSV() {
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
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')
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
link.download = 'toner_report.csv'
link.click()
URL.revokeObjectURL(link.href)
}
onMounted(async () => {
try {
hostnameTemplate.value = await getPrinterHostnameTemplate()
const response = await printersApi.lowSupplies()
const data = response.data.data
printers.value = data.printers || []
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
} catch (err) {
console.error('Error loading toner report:', err)
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
} finally {
loading.value = false
}
})
</script>
<style scoped>
.summary-bar {
display: flex;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.summary-stat {
flex: 1;
text-align: center;
padding: 1.25rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text);
}
.stat-label {
color: var(--text-light);
margin-top: 0.25rem;
}
.stat-low .stat-value {
color: var(--warning);
}
.stat-critical .stat-value {
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: 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: contents;
}
.supply-name {
font-size: 0.85rem;
color: var(--text-light);
white-space: nowrap;
}
.supply-bar-track {
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.supply-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.supply-ok {
background: var(--success);
}
.supply-low {
background: var(--warning);
}
.supply-critical {
background: var(--danger);
}
.supply-level {
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);
}
.supply-text-low {
color: var(--warning);
}
.supply-text-critical {
color: var(--danger);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-light);
}
.text-danger {
color: var(--danger);
}
.header-actions {
display: flex;
gap: 0.5rem;
}
</style>