The forecast identified a cartridge by its part number alone. 'W2020X' only tells you which toner it is once you already know, and both places that name a supply - the Order now list and the Part column - showed nothing else, so the report read as a list of codes. Both now show marketingname, which is what the box says: '414X Black'. The part number moves to the title attribute, one hover away, because it is still the thing an order is placed against. A supply with no name on file falls back to the number rather than showing an empty cell; marketingname is nullable. 'Copy list' is deliberately untouched. It writes the part number verbatim, which is what purchasing pastes into an order, and a spec pins that the name does NOT reach that text. firstPart() now returns the whole part row rather than its number, since the cell needs both halves. It has one caller. Dropped the .partnumber style with its .unmapped variant: nothing renders that class any more, and the variant was already dead - the unmapped block lists no part number by definition. Verified by mounting the component against a fixture. The dev box has no Zabbix, so the page itself has no rows to look at there.
467 lines
17 KiB
Vue
467 lines
17 KiB
Vue
<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>
|
|
<!-- The answer first. Everything below it is the evidence. -->
|
|
<div class="card order-card">
|
|
<div class="order-head">
|
|
<h2 class="section-title">
|
|
Order now
|
|
<span class="muted">- empty, or out within {{ horizondays }} days</span>
|
|
</h2>
|
|
<button v-if="orderlist.length" class="btn btn-secondary" @click="copyOrder">
|
|
{{ copied ? 'Copied' : 'Copy list' }}
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="!orderlist.length" class="muted nothing-due">
|
|
Nothing is due in the next {{ horizondays }} days.
|
|
</p>
|
|
<ul v-else class="order-list">
|
|
<li v-for="item in mapped" :key="orderKey(item)">
|
|
<span class="quantity">{{ item.quantity }}x</span>
|
|
<!-- Name here too, number on hover. 'Copy list' still writes the
|
|
part number, which is what an order is placed against. -->
|
|
<span class="supplyname" :title="item.partnumber">
|
|
{{ item.marketingname || item.partnumber }}
|
|
</span>
|
|
<span class="muted order-detail">
|
|
{{ colorLabel(item.color) }}<template v-if="item.model">, {{ item.model }}</template>
|
|
</span>
|
|
<span class="muted order-printers">{{ printerNames(item) }}</span>
|
|
</li>
|
|
</ul>
|
|
|
|
<!-- A cartridge nobody has mapped a part to still has to be ordered,
|
|
so it cannot be hidden. It cannot be ordered from this page
|
|
either, so it does not get a line each - it is one job: map them. -->
|
|
<div v-if="unmapped.length" class="unmapped-block">
|
|
<button class="unmapped-head" @click="toggle('unmapped')">
|
|
<span class="band-caret">{{ isOpen('unmapped') ? '-' : '+' }}</span>
|
|
{{ unmappedCount }} more due, with no part number on file
|
|
</button>
|
|
<ul v-if="isOpen('unmapped')" class="order-list">
|
|
<li v-for="item in unmapped" :key="orderKey(item)">
|
|
<span class="quantity">{{ item.quantity }}x</span>
|
|
<span class="muted order-detail">
|
|
{{ colorLabel(item.color) }}<template v-if="item.model">, {{ item.model }}</template>
|
|
</span>
|
|
<span class="muted order-printers">{{ printerNames(item) }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bands, not a sortable table: the question is "which pile is this in",
|
|
and a pile with nothing in it is worth seeing as empty. -->
|
|
<div v-for="group in bands" :key="group.key" class="card band-card">
|
|
<button class="band-head" @click="toggle(group.key)">
|
|
<span class="band-caret">{{ isOpen(group.key) ? '-' : '+' }}</span>
|
|
<span class="band-title">{{ group.title }}</span>
|
|
<span class="band-count" :class="group.key">{{ group.items.length }}</span>
|
|
</button>
|
|
|
|
<div v-if="isOpen(group.key) && group.items.length" class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="colcolor"></th>
|
|
<th>Printer</th>
|
|
<th>Supply</th>
|
|
<th>Part</th>
|
|
<th class="collevel">Level</th>
|
|
<th>Runs out</th>
|
|
<th>Rate</th>
|
|
<th title="Cartridge changes seen anywhere in the history window, and how long the CURRENT cartridge has been in">Replacements</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="c in group.items" :key="c.printerid + c.name">
|
|
<td class="colcolor">
|
|
<span class="chip" :style="chipStyle(c.color)"
|
|
:title="colorLabel(c.color)"></span>
|
|
</td>
|
|
<td>
|
|
<router-link :to="`/printers/${c.printerid}`">
|
|
{{ c.printername || c.assetnumber }}
|
|
</router-link>
|
|
<div class="muted small">{{ c.ipaddress }}</div>
|
|
</td>
|
|
<td>{{ c.name }}</td>
|
|
<td>
|
|
<!-- The cartridge's NAME, with the part number on hover. A
|
|
name says which toner this is; 'W2020A' only identifies it
|
|
once you already know. The number is what purchasing wants,
|
|
so it stays one hover away and stays in the copied order
|
|
list verbatim. Falls back to the number when a supply has
|
|
no name on file - marketingname is nullable. -->
|
|
<span v-if="firstPart(c)" class="supplyname"
|
|
:title="firstPart(c).partnumber">
|
|
{{ firstPart(c).marketingname || firstPart(c).partnumber }}
|
|
</span>
|
|
<span v-else class="muted">-</span>
|
|
</td>
|
|
<td class="collevel">
|
|
<!-- A bar reads at a glance where a percentage has to be
|
|
compared digit by digit against the row above. -->
|
|
<span class="bar" :title="levelText(c)">
|
|
<span class="bar-fill" :class="c.band"
|
|
:style="{ width: barWidth(c) }"></span>
|
|
</span>
|
|
<span class="level-text">{{ levelText(c) }}</span>
|
|
</td>
|
|
<td>
|
|
<span class="days" :class="c.band">{{ daysText(c) }}</span>
|
|
</td>
|
|
<td class="muted small">
|
|
<template v-if="c.burnrateperday != null">
|
|
{{ c.burnrateperday }}%/day<!--
|
|
--><span v-if="c.rateunstable" class="unstable"
|
|
title="This cartridge's usage has varied a lot between readings - a burst then a lull, or the reverse. The estimate is the best available, not a measurement.">~</span>
|
|
</template>
|
|
<template v-else>-</template>
|
|
</td>
|
|
<td class="muted small">
|
|
<!-- Two DIFFERENT spans, so they must not be joined by a
|
|
word that implies one contains the other. Replacements are
|
|
counted across the whole history window; basisdays is only
|
|
how long the CURRENT cartridge has been in. "2 in 4.0d"
|
|
claimed two changes within four days, which is the shape
|
|
that reads as broken data - and here it would be the
|
|
label lying, not the data. -->
|
|
<template v-if="c.basisdays">
|
|
{{ c.replacements || 0 }}, this one {{ c.basisdays }}d
|
|
</template>
|
|
<template v-else>{{ c.replacements || 0 }}</template>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Kept apart rather than sorted in as 0 or as 999: a cartridge with no
|
|
estimate is neither urgent nor safe, and the reason is the point. -->
|
|
<div v-if="unestimated.length" class="card band-card">
|
|
<button class="band-head" @click="toggle('none')">
|
|
<span class="band-caret">{{ isOpen('none') ? '-' : '+' }}</span>
|
|
<span class="band-title">No estimate yet</span>
|
|
<span class="band-count">{{ unestimated.length }}</span>
|
|
</button>
|
|
<div v-if="isOpen('none')" class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="colcolor"></th>
|
|
<th>Printer</th><th>Supply</th><th class="collevel">Level</th><th>Why</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="c in unestimated" :key="c.printerid + c.name">
|
|
<td class="colcolor">
|
|
<span class="chip" :style="chipStyle(c.color)"></span>
|
|
</td>
|
|
<td>
|
|
<router-link :to="`/printers/${c.printerid}`">
|
|
{{ c.printername || c.assetnumber }}
|
|
</router-link>
|
|
</td>
|
|
<td>{{ c.name }}</td>
|
|
<td class="collevel">{{ levelText(c) }}</td>
|
|
<td class="muted">{{ c.reason || '-' }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="muted footnote">
|
|
{{ summary.replacements }} cartridges changed across the fleet in the
|
|
last {{ days }} days.
|
|
</p>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, 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 horizondays = ref(14)
|
|
const cartridges = ref([])
|
|
const unestimated = ref([])
|
|
const orderlist = ref([])
|
|
const summary = ref({ bands: {}, replacements: 0, toorder: 0 })
|
|
const copied = ref(false)
|
|
|
|
// Only what is already empty starts open. The order list above covers the same
|
|
// ground in a tenth of the height, so the bands are evidence, asked for.
|
|
const openBands = ref(new Set(['empty']))
|
|
|
|
const mapped = computed(() => orderlist.value.filter(item => item.partnumber))
|
|
const unmapped = computed(() => orderlist.value.filter(item => !item.partnumber))
|
|
const unmappedCount = computed(() =>
|
|
unmapped.value.reduce((total, item) => total + item.quantity, 0))
|
|
|
|
const BAND_TITLES = {
|
|
empty: 'Empty - order now',
|
|
soon: 'Within 2 weeks',
|
|
month: 'Within 30 days',
|
|
later: 'Later',
|
|
}
|
|
|
|
const bands = computed(() =>
|
|
Object.keys(BAND_TITLES).map(key => ({
|
|
key,
|
|
title: BAND_TITLES[key],
|
|
items: cartridges.value.filter(c => c.band === key),
|
|
})))
|
|
|
|
function isOpen(key) {
|
|
return openBands.value.has(key)
|
|
}
|
|
|
|
function toggle(key) {
|
|
const next = new Set(openBands.value)
|
|
next.has(key) ? next.delete(key) : next.add(key)
|
|
openBands.value = next
|
|
}
|
|
|
|
// Toner colours are the one place a literal colour is the data rather than a
|
|
// theme choice - a cyan chip has to be cyan in both light and dark mode.
|
|
const CHIP_COLORS = {
|
|
black: '#2b2b2b', cyan: '#00b7eb', magenta: '#d6006e', yellow: '#f2c200',
|
|
}
|
|
|
|
function chipStyle(color) {
|
|
const known = CHIP_COLORS[(color || '').toLowerCase()]
|
|
return known
|
|
? { background: known }
|
|
: { background: 'var(--bg)', border: '1px solid var(--border)' }
|
|
}
|
|
|
|
function colorLabel(color) {
|
|
if (!color || color === 'none') return 'Supply'
|
|
return color.charAt(0).toUpperCase() + color.slice(1)
|
|
}
|
|
|
|
// The whole part row, not just its number: the table shows the name and keeps
|
|
// the number for the hover, and both come from here. Several capacity tiers can
|
|
// match; the first is the standard one, which is what orderlist() groups on and
|
|
// what the low-supplies report shows first.
|
|
function firstPart(cartridge) {
|
|
const parts = cartridge.partnumbers || []
|
|
return parts.length ? parts[0] : null
|
|
}
|
|
|
|
function levelText(cartridge) {
|
|
return cartridge.currentlevel != null
|
|
? Math.round(cartridge.currentlevel) + '%'
|
|
: '-'
|
|
}
|
|
|
|
function barWidth(cartridge) {
|
|
const level = cartridge.currentlevel
|
|
if (level == null) return '0%'
|
|
return Math.max(0, Math.min(100, level)) + '%'
|
|
}
|
|
|
|
function daysText(cartridge) {
|
|
if (cartridge.daysleft == null) return '-'
|
|
if (cartridge.daysleft === 0) return 'empty'
|
|
if (cartridge.daysleft === 1) return '1 day'
|
|
return cartridge.daysleft + ' days'
|
|
}
|
|
|
|
function orderKey(item) {
|
|
return [item.partnumber || 'unmapped', item.color, item.model].join('|')
|
|
}
|
|
|
|
// Enough to recognise which printers are meant, without a line of hostnames
|
|
// wrapping over the quantity that is the point of the row.
|
|
const NAMES_SHOWN = 3
|
|
|
|
function printerNames(item) {
|
|
const names = (item.printers || []).map(p => p.printername).filter(Boolean)
|
|
if (names.length <= NAMES_SHOWN) return names.join(', ')
|
|
return `${names.slice(0, NAMES_SHOWN).join(', ')} +${names.length - NAMES_SHOWN} more`
|
|
}
|
|
|
|
// Purchasing wants it as text in a mail, not as a screenshot of a table.
|
|
async function copyOrder() {
|
|
const lines = orderlist.value.map(item => {
|
|
const part = item.partnumber || 'NO PART NUMBER ON FILE'
|
|
const where = printerNames(item)
|
|
return `${item.quantity}x ${part} (${colorLabel(item.color)}` +
|
|
`${item.model ? ', ' + item.model : ''}) - ${where}`
|
|
})
|
|
try {
|
|
await navigator.clipboard.writeText(lines.join('\n'))
|
|
copied.value = true
|
|
setTimeout(() => { copied.value = false }, 2000)
|
|
} catch {
|
|
copied.value = false
|
|
}
|
|
}
|
|
|
|
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 || ''
|
|
cartridges.value = data.cartridges || []
|
|
unestimated.value = data.unestimated || []
|
|
orderlist.value = data.orderlist || []
|
|
horizondays.value = data.horizondays || 14
|
|
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; }
|
|
|
|
.order-card { padding: 0 0 0.5rem; }
|
|
.order-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
padding: 0 1rem;
|
|
}
|
|
.order-head .section-title { padding: 0.75rem 0; }
|
|
.nothing-due { padding: 0 1rem 0.75rem; }
|
|
.order-list { list-style: none; margin: 0; padding: 0 1rem; }
|
|
.order-list li {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.6rem;
|
|
flex-wrap: wrap;
|
|
padding: 0.4rem 0;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.quantity { font-weight: 700; min-width: 2.5rem; }
|
|
.unmapped-block { border-top: 1px solid var(--border); }
|
|
.unmapped-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
width: 100%;
|
|
padding: 0.5rem 1rem;
|
|
background: none;
|
|
border: none;
|
|
color: var(--warning);
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.unmapped-block .order-list li { border-top: 1px dashed var(--border); }
|
|
/* A name is prose, not a code, so it does not get the monospace the part number
|
|
used to carry here. The help cursor is the only signal that the number is on
|
|
the hover. Replaced .partnumber, which nothing renders any more. */
|
|
.supplyname { font-weight: 700; cursor: help; }
|
|
.order-detail { font-size: 0.9rem; }
|
|
.order-printers { font-size: 0.8rem; margin-left: auto; text-align: right; }
|
|
|
|
.band-card { padding: 0; margin-bottom: 1rem; }
|
|
.band-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
width: 100%;
|
|
padding: 0.75rem 1rem;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text);
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.band-caret { width: 1rem; color: var(--text-light); }
|
|
.band-title { flex: 1; }
|
|
.band-count {
|
|
min-width: 1.8rem;
|
|
padding: 0.1rem 0.5rem;
|
|
border-radius: 1rem;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
text-align: center;
|
|
font-size: 0.85rem;
|
|
}
|
|
.band-count.empty { background: var(--danger); border-color: var(--danger); color: #fff; }
|
|
.band-count.soon { background: var(--warning); border-color: var(--warning); color: #fff; }
|
|
|
|
.colcolor { width: 1.5rem; }
|
|
.chip {
|
|
display: inline-block;
|
|
width: 0.9rem;
|
|
height: 0.9rem;
|
|
border-radius: 50%;
|
|
vertical-align: middle;
|
|
}
|
|
.collevel { width: 9rem; white-space: nowrap; }
|
|
.bar {
|
|
display: inline-block;
|
|
width: 5rem;
|
|
height: 0.6rem;
|
|
border-radius: 0.3rem;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
overflow: hidden;
|
|
vertical-align: middle;
|
|
}
|
|
.bar-fill { display: block; height: 100%; background: var(--success); }
|
|
.bar-fill.empty { background: var(--danger); }
|
|
.bar-fill.soon { background: var(--warning); }
|
|
.level-text { margin-left: 0.4rem; font-size: 0.85rem; }
|
|
|
|
.days { font-weight: 700; }
|
|
.days.empty { color: var(--danger); }
|
|
.days.soon { color: var(--warning); }
|
|
.small { font-size: 0.8rem; }
|
|
|
|
/* Marks a rate the intervals do not agree on. Deliberately quiet - it qualifies
|
|
the number beside it rather than competing with the urgency bands. */
|
|
.unstable { margin-left: 2px; font-weight: 600; cursor: help; }
|
|
.empty-state { padding: 2rem; text-align: center; }
|
|
.footnote { margin-top: 1rem; font-size: 0.85rem; }
|
|
</style>
|