POST /api/reports/email has always taken any {columns, rows} from any caller,
but the Email report button existed on the toner and warranty pages only - not
because those reports are special, but because they happen to be their own
routed pages and the reports page had no button at all. It is now beside Export
CSV for every report the page can build a table from, which is all of them:
plugin reports through their declared columns, core reports through the flat
list they return, and the asset inventory summary by flattening its three
tables into one with the grouping named in a column. Presentation-only fields -
a status colour, a link id - are left out; a column of hex codes is worse than
no column. The shapes live in a module with tests rather than in the view.
The button is now hidden without reports.export on all three pages. Offering
somebody a button that can only answer 403 is worse than not offering it.
Separately, on the USB kiosk a long drive name pushed Take or Return out
through the side of its card, leaving a drive nobody could press. The name is a
flex item and so defaulted to min-width:auto, keeping its full intrinsic width;
it now yields, wraps mid-word for the serial-style names that carry no spaces,
and clamps its description to two lines so one long name cannot make every card
in the row tall. The locker line is never clipped - it is how somebody finds
the drive.
387 lines
11 KiB
Vue
387 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 && canemail" 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"
|
|
:levelid="printer.levelid"
|
|
: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 { useAuthStore } from '@/stores/auth'
|
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
|
|
|
// Emailing a report is the reports.export permission the endpoint enforces.
|
|
// Offering the button to somebody who will only get a 403 is worse than not
|
|
// offering it.
|
|
const authStore = useAuthStore()
|
|
const canemail = computed(() => authStore.hasPermission('reports.export'))
|
|
|
|
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>
|