toner report: the part to order, and where the printer is
The report exists to answer "what needs replacing and where do I get it", and it was answering neither. The part numbers were already in the lowsupplies payload and simply never rendered; a chip per part now shows them, with capacity tier and page yield on hover, since a model can list several tiers for one colour. Asset # and Location columns are gone. Location is replaced by the floor-plan preview the asset pages already use, hung off the printer name via its mapx/mapy. The IP is now the site's FQDN (printer_hostname_template, built from the IP exactly as PrinterForm does) and links to the printer's own web page in a new tab - the report is a worklist, and losing your place in it to visit one printer means finding your row again. The raw IP stays on hover. Cartridge names were ellipsised inside a fixed 120px column, hiding the one thing being reordered. The supplies cell is a grid with a max-content name column, so names show in full and still line up across a printer's rows. CSV and emailed exports follow the screen, with one row per part number so the result is a copy-pasteable order list.
This commit is contained in:
@@ -53,22 +53,42 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Printer Name</th>
|
<th>Printer Name</th>
|
||||||
<th>Asset #</th>
|
<th>Hostname</th>
|
||||||
<th>Location</th>
|
|
||||||
<th>IP Address</th>
|
|
||||||
<th>Supplies</th>
|
<th>Supplies</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||||
<td>
|
<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"
|
||||||
|
:machineName="printer.printername || printer.assetnumber"
|
||||||
|
>
|
||||||
<router-link :to="`/printers/${printer.printerid}`">
|
<router-link :to="`/printers/${printer.printerid}`">
|
||||||
{{ printer.printername || 'Unknown' }}
|
{{ printer.printername || 'Unknown' }}
|
||||||
</router-link>
|
</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>
|
||||||
<td>{{ printer.assetnumber }}</td>
|
|
||||||
<td>{{ printer.location || '-' }}</td>
|
|
||||||
<td>{{ printer.ipaddress }}</td>
|
|
||||||
<td class="supplies-cell">
|
<td class="supplies-cell">
|
||||||
<div
|
<div
|
||||||
v-for="(supply, idx) in printer.supplies"
|
v-for="(supply, idx) in printer.supplies"
|
||||||
@@ -86,6 +106,16 @@
|
|||||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||||
{{ supply.level }}%
|
{{ supply.level }}%
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -102,13 +132,14 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||||
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||||
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||||
|
|
||||||
const emailColumns = [
|
const emailColumns = [
|
||||||
{ key: 'printer', label: 'Printer' },
|
{ key: 'printer', label: 'Printer' },
|
||||||
{ key: 'assetnumber', label: 'Asset #' },
|
{ key: 'hostname', label: 'Hostname' },
|
||||||
{ key: 'location', label: 'Location' },
|
|
||||||
{ key: 'ipaddress', label: 'IP Address' },
|
|
||||||
{ key: 'supply', label: 'Supply' },
|
{ key: 'supply', label: 'Supply' },
|
||||||
|
{ key: 'partnumber', label: 'Part Number' },
|
||||||
{ key: 'level', label: 'Level' },
|
{ key: 'level', label: 'Level' },
|
||||||
{ key: 'status', label: 'Status' },
|
{ key: 'status', label: 'Status' },
|
||||||
]
|
]
|
||||||
@@ -125,6 +156,47 @@ const filterOptions = [
|
|||||||
{ label: 'Low', value: 'low' }
|
{ 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(() => {
|
const filteredPrinters = computed(() => {
|
||||||
if (filter.value === 'all') return printers.value
|
if (filter.value === 'all') return printers.value
|
||||||
return printers.value.filter(p =>
|
return printers.value.filter(p =>
|
||||||
@@ -132,36 +204,17 @@ const filteredPrinters = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// One row per supply, honoring the active filter, for the emailed table.
|
// Emailed table, honoring the active filter.
|
||||||
const emailRows = computed(() => {
|
const emailRows = computed(() =>
|
||||||
const rows = []
|
reportRows().map(row => ({ ...row, level: row.level + '%' }))
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
function exportCSV() {
|
function exportCSV() {
|
||||||
// one row per supply, honoring the active filter
|
|
||||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||||
const rows = [['printer', 'assetnumber', 'location', 'ipaddress', 'supply', 'level', 'status']]
|
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
|
||||||
for (const printer of filteredPrinters.value) {
|
const rows = [header]
|
||||||
for (const supply of printer.supplies || []) {
|
for (const row of reportRows()) {
|
||||||
rows.push([
|
rows.push(header.map(key => row[key]))
|
||||||
printer.printername || '', printer.assetnumber || '', printer.location || '',
|
|
||||||
printer.ipaddress || '', supply.name || '', supply.level, supply.status || ''
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
@@ -173,6 +226,7 @@ function exportCSV() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
|
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||||
const response = await printersApi.lowSupplies()
|
const response = await printersApi.lowSupplies()
|
||||||
const data = response.data.data
|
const data = response.data.data
|
||||||
printers.value = data.printers || []
|
printers.value = data.printers || []
|
||||||
@@ -218,32 +272,31 @@ onMounted(async () => {
|
|||||||
color: var(--danger);
|
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 {
|
.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 {
|
.supply-row {
|
||||||
display: flex;
|
display: contents;
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.supply-row:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.supply-name {
|
.supply-name {
|
||||||
flex: 0 0 120px;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.supply-bar-track {
|
.supply-bar-track {
|
||||||
flex: 1;
|
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -269,12 +322,33 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.supply-level {
|
.supply-level {
|
||||||
flex: 0 0 40px;
|
min-width: 40px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
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 {
|
.supply-text-ok {
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user