Two separate floors were collapsing a live cartridge into a spent one, and removing either alone changes nothing. EMPTY_LEVEL was 5, so everything at or below 5% was assigned daysleft 0 outright. The reasoning was that a printer at 1% is out of toner as far as anyone standing at it is concerned. But the row exists to say how long is left, and 2% draining a point a day has two days in it. Flooring put that cartridge beside ones that genuinely are empty with no way back to the difference. It is 0 now: empty means empty. int(level / rate) then truncated the division, so anything under a full day arrived as 0 whatever the floor did - a cartridge with six hours in it was indistinguishable from one with nothing, and the band read it as empty. daysleft is fractional now, rounded to two places, which is about a quarter of an hour: finer than the estimate deserves, but it costs nothing and keeps the ordering of two nearly-spent cartridges meaningful. daysText reads in whatever unit carries meaning: "6 hours", "1 hour", "29 min", "4 days". Below an hour it goes to minutes with a floor of one, because rounding hours would land back on "empty" - the same bug one rung down. BEHAVIOUR CHANGE worth knowing: a cartridge at 1-5% with NO history used to get daysleft 0 from the floor and land on the order list. A rate needs two readings; with none there is nothing to divide, and the old answer was right by accident - it said "empty" about a level nobody had watched move. It now reports 'no history' and shows under "No estimate yet". This reaches only printers newly added to Zabbix; anything that got to 3% the ordinary way has the history to forecast from. Five existing tests pinned the old rule. They recorded a real decision, so they are rewritten to the new one rather than deleted. One of them was passing for the wrong reason: its series (2.0, 1.7, 1.4, 1.1) drops 0.9, under MIN_DROP_FOR_ESTIMATE, so it never had a rate at all and only passed because the floor short-circuited ahead of the rate check. It now uses a real 20-day drop at a tenth of a point a day and asserts the ten days its docstring always described. The forecast fixture's black cartridge moves from 1% to 0% so the empty band keeps its API-level coverage, and a magenta at 3% covers hours-left end to end.
488 lines
18 KiB
Vue
488 lines
18 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)) + '%'
|
|
}
|
|
|
|
// daysleft is fractional, so the last day of a cartridge's life is readable
|
|
// instead of collapsing to "empty". Only an actual zero is empty; anything above
|
|
// it has time left and the row says how much, in whatever unit carries meaning.
|
|
const HOURS_PER_DAY = 24
|
|
const MINUTES_PER_HOUR = 60
|
|
|
|
function daysText(cartridge) {
|
|
const days = cartridge.daysleft
|
|
if (days == null) return '-'
|
|
if (days <= 0) return 'empty'
|
|
|
|
if (days < 1) {
|
|
const hours = days * HOURS_PER_DAY
|
|
// Under an hour, hours would round to 0 and read as empty again.
|
|
if (hours < 1) {
|
|
const minutes = Math.max(1, Math.round(hours * MINUTES_PER_HOUR))
|
|
return `${minutes} min`
|
|
}
|
|
const rounded = Math.round(hours)
|
|
return rounded === 1 ? '1 hour' : `${rounded} hours`
|
|
}
|
|
|
|
// Whole days from here. The estimate is not precise enough for "3.7 days" to
|
|
// mean more than "about 4", and a column of decimals is harder to scan.
|
|
const rounded = Math.round(days)
|
|
return rounded === 1 ? '1 day' : `${rounded} 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>
|