The hover mini-map said "This asset has a position (2835, 1410) but no level" for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid prop, NONE of its seven call sites were taught to pass one - printer, machine and PC detail pages, the toner report, enforcement reports, the warranty chip and the dashboard cards - so the component correctly reported a missing level and the preview never drew. Two payloads behind those views also emitted mapx/mapy with no level: the toner report and the enforcement report. The map PDF export had the ORIGINAL bug still in it: it plotted every filtered asset onto the sheet, so exporting the ground floor printed second-floor markers on it. Worse than on screen, because nobody can correct a sheet once it has been printed and carried onto the floor. It now exports only the level being viewed. The legacy import loader sent mapleft/maptop with no level at three call sites. That loader is the one still to run against production, and every marker it created would have been undrawable. It now resolves the site's default level - the legacy schema predates levels and has one floor plan, so that is what its coordinates mean. THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid', not whether each position does: one module emitted 'mapx' six times and 'levelid' once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/ and plugins/, and fails any Vue file that binds tooltip coordinates without :levelid. Both new rules were confirmed to fail the build against planted violations before being relied on. Printer QR labels: the asset number is no longer printed. A label now reads name (8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber because that is where sites actually keep it - every printer here has an empty name field, so preferring the Windows queue name alone would have printed a blank line on every label.
333 lines
11 KiB
Vue
333 lines
11 KiB
Vue
<template>
|
|
<div>
|
|
<div class="no-print">
|
|
<div class="controls">
|
|
<h3>Batch Print Printer QR Codes</h3>
|
|
<p>Select printers to print (6 per page):</p>
|
|
|
|
<div v-if="loadingPrinters" class="loading-msg">Loading printers...</div>
|
|
<div v-else class="printer-grid">
|
|
<div
|
|
v-for="printer in printers"
|
|
:key="printer.assetid"
|
|
class="printer-item"
|
|
:class="{ selected: isSelected(printer) }"
|
|
@click="togglePrinter(printer)"
|
|
>
|
|
<input type="checkbox" :checked="isSelected(printer)" @click.stop />
|
|
<label>
|
|
<strong>{{ displayName(printer) }}</strong>
|
|
<div class="model">{{ printer.printer?.modelname || '' }}</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="selected-count">
|
|
Selected: <span class="count">{{ selectedPrinters.length }}</span> printers
|
|
(<span class="pages">{{ pageCount }}</span> pages)
|
|
</div>
|
|
|
|
<button class="print-btn" :disabled="selectedPrinters.length === 0" @click="print">Print QR Codes</button>
|
|
<button class="clear-btn" @click="clearSelection">Clear All</button>
|
|
<button class="select-all-btn" @click="selectAll">Select All</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sheets-container">
|
|
<div v-for="(page, pageIdx) in pages" :key="pageIdx" class="print-sheet">
|
|
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
|
<div
|
|
v-for="pos in 6"
|
|
:key="pos"
|
|
class="label"
|
|
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
|
|
>
|
|
<template v-if="page[pos - 1]">
|
|
<div class="csf-name">{{ labelName(page[pos - 1]) }}</div>
|
|
<div class="qr-container">
|
|
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
|
|
</div>
|
|
<div class="info-section">
|
|
<div class="info-inner">
|
|
<div v-if="fqdnFor(page[pos - 1])" class="info-row">{{ fqdnFor(page[pos - 1]) }}</div>
|
|
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<span v-else class="empty-label">Empty</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|
import { printersApi } from '@/api'
|
|
import { renderQrDataUrl } from '@/utils/codes'
|
|
import { buildQrUrl } from '@/utils/qrTarget'
|
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
|
|
|
const printers = ref([])
|
|
const selectedPrinters = ref([])
|
|
const loadingPrinters = ref(true)
|
|
// QR codes rendered to data-URL images (not live <canvas>): canvases are
|
|
// unreliable in print output, images print every time.
|
|
const qrImages = ref({})
|
|
|
|
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
|
|
|
|
const pages = computed(() => {
|
|
const result = []
|
|
for (let i = 0; i < selectedPrinters.value.length; i += 6) {
|
|
const page = []
|
|
for (let j = 0; j < 6; j++) {
|
|
page.push(selectedPrinters.value[i + j] || null)
|
|
}
|
|
result.push(page)
|
|
}
|
|
return result
|
|
})
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
|
// listAll: perpage is clamped to 100, and a batch sheet must cover every
|
|
// printer, not the first page of them.
|
|
printers.value = await printersApi.listAll()
|
|
} catch (error) {
|
|
console.error('Error loading printers:', error)
|
|
} finally {
|
|
loadingPrinters.value = false
|
|
}
|
|
})
|
|
|
|
watch(selectedPrinters, async () => {
|
|
await nextTick()
|
|
generateQRCodes()
|
|
}, { deep: true })
|
|
|
|
async function generateQRCodes() {
|
|
const next = {}
|
|
for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
|
|
const page = pages.value[pageIdx]
|
|
for (let idx = 0; idx < page.length; idx++) {
|
|
const printer = page[idx]
|
|
if (!printer) continue
|
|
const pos = idx + 1
|
|
const detailId = printer.printer?.printerid || printer.assetid
|
|
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
|
|
printerid: printer.printer?.printerid || '',
|
|
assetid: printer.assetid || '',
|
|
assetnumber: printer.assetnumber || '',
|
|
serialnumber: printer.serialnumber || '',
|
|
ip: getIp(printer) || '',
|
|
hostname: printer.printer?.windowsname || printer.name || '',
|
|
})
|
|
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
|
|
}
|
|
}
|
|
qrImages.value = next
|
|
}
|
|
|
|
// The printer's FQDN. A stored hostname wins; otherwise the site builds one
|
|
// from the IP through the printer_hostname_template setting (ADR-015), which is
|
|
// how PrinterForm and the toner report derive it.
|
|
const hostnameTemplate = ref('')
|
|
|
|
function fqdnFor(item) {
|
|
const stored = item?.printer?.hostname
|
|
if (stored) return stored
|
|
const ip = getIp(item)
|
|
if (!ip || !hostnameTemplate.value) return ''
|
|
return hostnameTemplate.value.replace('{ip}', ip.replace(/\./g, '-'))
|
|
}
|
|
|
|
// What goes on the label's prominent top line: the printer's NAME, e.g.
|
|
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
|
|
// queue name when one is set, otherwise the asset's name, and in practice most
|
|
// printers carry it as the assetnumber and nothing else, so that is the last
|
|
// fallback rather than a blank label. This is a name, not an identifier line:
|
|
// the label deliberately carries no separate "Asset #".
|
|
function labelName(item) {
|
|
return item?.printer?.windowsname || item?.name || item?.assetnumber || ''
|
|
}
|
|
|
|
function displayName(printer) {
|
|
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
|
|
}
|
|
|
|
function getIp(printer) {
|
|
// Check direct ipaddress field first (from list API)
|
|
if (printer.ipaddress && printer.ipaddress !== 'USB') return printer.ipaddress
|
|
// Fall back to communications array (from detail API)
|
|
if (!printer.communications?.length) return null
|
|
const primary = printer.communications.find(c => c.isprimary) || printer.communications[0]
|
|
return primary?.ipaddress || primary?.address || null
|
|
}
|
|
|
|
function isSelected(printer) {
|
|
return selectedPrinters.value.some(p => p.assetid === printer.assetid)
|
|
}
|
|
|
|
function togglePrinter(printer) {
|
|
const idx = selectedPrinters.value.findIndex(p => p.assetid === printer.assetid)
|
|
if (idx > -1) {
|
|
selectedPrinters.value.splice(idx, 1)
|
|
} else {
|
|
selectedPrinters.value.push(printer)
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
selectedPrinters.value = []
|
|
}
|
|
|
|
function selectAll() {
|
|
selectedPrinters.value = [...printers.value]
|
|
}
|
|
|
|
function print() {
|
|
window.print()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
@page { size: letter; margin: 0; }
|
|
|
|
.no-print { margin-bottom: 20px; padding: 20px; }
|
|
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
|
|
.controls h3 { margin-top: 0; }
|
|
|
|
.print-btn {
|
|
padding: 10px 30px;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 5px;
|
|
margin-right: 10px;
|
|
}
|
|
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
|
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
|
|
|
|
.clear-btn {
|
|
padding: 10px 20px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
background: var(--danger);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 5px;
|
|
margin-right: 10px;
|
|
}
|
|
|
|
.select-all-btn {
|
|
padding: 10px 20px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
background: var(--success);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.printer-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
gap: 10px;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--border);
|
|
padding: 10px;
|
|
background: var(--bg);
|
|
}
|
|
|
|
.printer-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 8px;
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.printer-item:hover { border-color: var(--primary); }
|
|
.printer-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
|
.printer-item input { margin-right: 10px; }
|
|
.printer-item label { cursor: pointer; flex: 1; }
|
|
.printer-item .model { font-size: 11px; color: var(--text-light); }
|
|
|
|
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
|
|
.selected-count .count { color: var(--primary); }
|
|
.selected-count .pages { color: var(--success); }
|
|
|
|
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
|
|
|
|
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
|
|
|
|
.print-sheet {
|
|
width: 8.5in;
|
|
height: 11in;
|
|
background: white;
|
|
margin: 0 auto;
|
|
position: relative;
|
|
border: 1px solid #ccc;
|
|
page-break-after: always;
|
|
}
|
|
.print-sheet:last-child { page-break-after: auto; }
|
|
|
|
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
|
|
|
.label {
|
|
width: 3in;
|
|
height: 3in;
|
|
position: absolute;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0.1in;
|
|
box-sizing: border-box;
|
|
border: 1px dashed #ccc;
|
|
}
|
|
.label.filled { border: 2px solid var(--primary); }
|
|
.label.empty { background: #fafafa; }
|
|
|
|
.qr-img { width: 144px; height: 144px; display: block; }
|
|
|
|
.pos-1 { top: 0.875in; left: 1.1875in; }
|
|
.pos-2 { top: 0.875in; left: 4.3125in; }
|
|
.pos-3 { top: 4in; left: 1.1875in; }
|
|
.pos-4 { top: 4in; left: 4.3125in; }
|
|
.pos-5 { top: 7.125in; left: 1.1875in; }
|
|
.pos-6 { top: 7.125in; left: 4.3125in; }
|
|
|
|
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
|
|
.qr-container { text-align: center; }
|
|
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
|
.info-inner { text-align: left; }
|
|
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
|
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
|
|
.empty-label { color: #999; font-size: 14px; }
|
|
|
|
@media print {
|
|
/* Force the browser to print rendered images/colors even when the user's
|
|
"Background graphics" option is off. Without this the QR images and
|
|
borders can drop out of the printout. */
|
|
body, .print-sheet, .label, .qr-img, .qr-container {
|
|
-webkit-print-color-adjust: exact !important;
|
|
print-color-adjust: exact !important;
|
|
}
|
|
body { padding: 0; margin: 0; background: white; }
|
|
.no-print { display: none !important; }
|
|
.sheets-container { gap: 0; }
|
|
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
|
|
.sheet-label { display: none; }
|
|
.label { border: none !important; }
|
|
.label.empty { visibility: hidden; }
|
|
}
|
|
</style>
|