Follow-up to the application-picker fix. Three of these were already wrong on data that exists today, not merely latent. Batch label printing is the worst of them: AssetLabelBatch asked for 500 machines or PCs, got 100, and printed a sheet that looked complete. With 262 machines and 290 PCs in the catalogue that is a physically short run with no error anywhere - the operator finds out at the label printer, or later at the bay with no label on it. PrinterQRBatch, USBLabelBatch and PrintedPartsLabels had the same shape and are fixed alongside it, before their tables cross 100 too. MachineForm's "controls" PC dropdown offered the first 100 of 290, so a machine could not be linked to a PC sorting late in the list. NetworkDeviceForm had it for models, which are already past 100 - and the same file already called modelsApi.listAll() correctly two lines away. Adds listAll() to the machines, computers, printers, network, measuring-tools, USB and printed-parts APIs, all delegating to fetchAllPages(). Still outstanding: callers of vendors, locations, business units and the type catalogues that ask for more than 100. Those tables are all well under the cap today, so they are correct for now and wrong the day they are not.
309 lines
9.7 KiB
Vue
309 lines
9.7 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="model-name">{{ page[pos - 1].printer?.modelname || '' }}</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="csf-name">{{ page[pos - 1].assetnumber }}</div>
|
|
<div class="info-inner">
|
|
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</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'
|
|
|
|
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 {
|
|
// 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
|
|
}
|
|
|
|
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: 2px; 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>
|