Tech Tools section with a code generator, toner report that names the part, toner forecasting, warranty proof of cover, application uploads

This commit is contained in:
2026-08-12 11:51:38 -04:00
parent 568dfbb91c
commit d32513727b
38 changed files with 2667 additions and 779 deletions

View File

@@ -0,0 +1,182 @@
<template>
<div>
<div class="page-header">
<h1>Toner Forecast</h1>
<div class="actions">
<select v-model.number="days" class="form-control" @change="load">
<option :value="30">Last 30 days</option>
<option :value="90">Last 90 days</option>
<option :value="180">Last 180 days</option>
</select>
<router-link to="/reports/toner" class="btn btn-secondary">Toner Report</router-link>
</div>
</div>
<div v-if="loading" class="loading">Reading history...</div>
<!-- Zabbix off is not the same as nothing running out. Say which. -->
<div v-else-if="!available" class="card empty-state">
<p>{{ reason || 'Supply history is unavailable.' }}</p>
<p class="muted">
Levels and history both come from Zabbix. With it unreachable there is
nothing to forecast from - this is not a report of "nothing is due".
</p>
</div>
<template v-else>
<div class="summary-row">
<div class="summary-card">
<span class="summary-number">{{ summary.duewithin30 }}</span>
<span class="summary-label">due within 30 days</span>
</div>
<div class="summary-card">
<span class="summary-number">{{ summary.estimated }}</span>
<span class="summary-label">printers with an estimate</span>
</div>
<div class="summary-card">
<span class="summary-number">{{ summary.replacements }}</span>
<span class="summary-label">cartridges changed in {{ days }} days</span>
</div>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Printer</th>
<th>Runs out in</th>
<th>Supply</th>
<th>Level</th>
<th>Burn rate</th>
<th>Changed</th>
<th>Based on</th>
</tr>
</thead>
<tbody>
<!-- Sorted by days left, not by level: a cartridge at 60% falling
fast is ordered before one sitting at 8% that never moves. -->
<template v-for="p in printers" :key="p.printerid">
<tr v-for="(s, index) in p.supplies" :key="p.printerid + s.name"
:class="{ 'row-group-start': index === 0 }">
<td v-if="index === 0" :rowspan="p.supplies.length">
<router-link :to="`/printers/${p.printerid}`">
{{ p.printername || p.assetnumber }}
</router-link>
<div class="muted small">{{ p.ipaddress }}</div>
</td>
<td v-if="index === 0" :rowspan="p.supplies.length">
<span class="days" :class="urgency(p.daysleft)">
{{ p.daysleft }} days
</span>
</td>
<td>{{ s.name }}</td>
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td>
<td>{{ s.burnrateperday != null ? s.burnrateperday + '%/day' : '-' }}</td>
<td>{{ s.replacements || 0 }}</td>
<td class="muted small">
{{ s.reason ? s.reason : s.basisdays + ' days of readings' }}
</td>
</tr>
</template>
<tr v-if="!printers.length">
<td colspan="7" class="muted" style="text-align:center;">
Nothing can be estimated yet from {{ days }} days of history.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Kept separate rather than sorted in as 0 or as 999: a printer with no
estimate is neither urgent nor safe, and the reason is the useful part. -->
<div v-if="unestimated.length" class="card">
<h3 class="section-title">No estimate yet ({{ unestimated.length }})</h3>
<div class="table-container">
<table>
<thead>
<tr><th>Printer</th><th>Supply</th><th>Level</th><th>Why</th></tr>
</thead>
<tbody>
<template v-for="p in unestimated" :key="p.printerid">
<tr v-for="s in p.supplies" :key="p.printerid + s.name">
<td>
<router-link :to="`/printers/${p.printerid}`">
{{ p.printername || p.assetnumber }}
</router-link>
</td>
<td>{{ s.name }}</td>
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td>
<td class="muted">{{ s.reason || '-' }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { printersApi } from '@/api'
import { apiError } from '@/utils/apiError'
const loading = ref(true)
const available = ref(false)
const reason = ref('')
const days = ref(90)
const printers = ref([])
const unestimated = ref([])
const summary = ref({ duewithin30: 0, estimated: 0, replacements: 0 })
function urgency(daysleft) {
if (daysleft <= 7) return 'critical'
if (daysleft <= 30) return 'low'
return 'ok'
}
async function load() {
loading.value = true
try {
const response = await printersApi.supplyForecast(days.value)
const data = response.data.data
available.value = data.available
reason.value = data.reason || ''
printers.value = data.printers || []
unestimated.value = data.unestimated || []
summary.value = data.summary || summary.value
} catch (err) {
available.value = false
reason.value = apiError(err, 'Failed to read supply history')
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.actions { display: flex; gap: 0.5rem; align-items: center; }
.actions .form-control { width: auto; }
.summary-row { display: flex; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
.summary-card {
background: var(--bg-card-solid);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem 1.5rem;
min-width: 12rem;
}
.summary-number { display: block; font-size: 1.8rem; font-weight: 700; }
.summary-label { color: var(--text-light); font-size: 0.85rem; }
.days { font-weight: 700; }
.days.critical { color: var(--danger); }
.days.low { color: var(--warning); }
.small { font-size: 0.8rem; }
.row-group-start td { border-top: 2px solid var(--border); }
.empty-state { padding: 2rem; text-align: center; }
.section-title { padding: 0.75rem 1rem 0; }
</style>

View File

@@ -53,22 +53,42 @@
<thead>
<tr>
<th>Printer Name</th>
<th>Asset #</th>
<th>Location</th>
<th>IP Address</th>
<th>Hostname</th>
<th>Supplies</th>
</tr>
</thead>
<tbody>
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
<td>
<router-link :to="`/printers/${printer.printerid}`">
<!-- 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>{{ printer.assetnumber }}</td>
<td>{{ printer.location || '-' }}</td>
<td>{{ printer.ipaddress }}</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"
@@ -86,6 +106,16 @@
<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>
@@ -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);
}