183 lines
6.6 KiB
Vue
183 lines
6.6 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>
|
|
<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>
|