Files
shopdb-flask/plugins/usb/frontend/views/USBLabelBatch.vue
cproudlock 62f4a42210
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s
fix: page past the 100-row cap in batch label sheets and asset pickers
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.
2026-08-17 14:10:08 -04:00

418 lines
12 KiB
Vue

<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print USB Barcode Labels</h3>
<p>Select USB devices to print (72 labels per page - 6 ULINE labels x 12 mini-labels each, cut after printing):</p>
<div v-if="loadingDevices" class="loading-msg">Loading USB devices...</div>
<div v-else-if="devices.length === 0" class="loading-msg">No USB devices found</div>
<div v-else class="usb-grid">
<div
v-for="device in devices"
:key="device.device_id"
class="usb-item"
:class="{ selected: isSelected(device) }"
@click="toggleDevice(device)"
>
<input type="checkbox" :checked="isSelected(device)" @click.stop />
<label>
<strong><code>{{ device.device_id || '-' }}</code></strong>
<div class="alias">{{ device.device_desc || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedDevices.length }}</span> USB devices
(<span class="pages">{{ pageCount }}</span> pages)
<label style="margin-left: 20px;">Start at cell:
<select v-model="startCell" style="padding: 5px; font-size: 14px;">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<button class="print-btn" :disabled="selectedDevices.length === 0" @click="print">Print Labels</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 sheetPages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="cellNum in 6"
:key="cellNum"
class="label-cell"
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
>
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
<div
v-for="(miniItem, miniIdx) in page[cellNum - 1].items"
:key="miniIdx"
class="mini-label"
:class="miniItem ? 'filled' : 'empty'"
>
<template v-if="miniItem">
<img
v-if="labelStyle === 'qr'"
class="qr-img"
:src="qrImages[`${pageIdx}-${cellNum}-${miniIdx}`] || ''"
/>
<div v-else class="barcode-container">
<svg :ref="el => setBarcodeRef(el, pageIdx, cellNum, miniIdx)"></svg>
</div>
<div class="serial-text">{{ miniItem.device_id }}</div>
</template>
</div>
</div>
<div v-else class="empty-cell-text">Empty</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { usbApi } from '@/api'
import { barcodeInto, qrPngDataUrl } from '@/utils/codes'
import { getSetting } from '@/utils/siteSettings'
import { buildQrUrl } from '@/utils/qrTarget'
const MINI_LABELS_PER_CELL = 12
const CELLS_PER_PAGE = 6
const devices = ref([])
const selectedDevices = ref([])
const loadingDevices = ref(true)
const startCell = ref('1')
const barcodeRefs = ref({})
// usb_label_style setting: 'barcode' (CODE128 of the serial) or 'qr' (QR code
// linking to the qr_target_usb target, default = the device page).
const labelStyle = ref('barcode')
// QR data-URL images keyed like barcodeRefs. Images print reliably; live
// canvases do not.
const qrImages = ref({})
const pageCount = computed(() => {
if (selectedDevices.value.length === 0) return 0
const skippedCells = parseInt(startCell.value) - 1
const numCells = Math.ceil(selectedDevices.value.length / MINI_LABELS_PER_CELL)
const totalCellsNeeded = numCells + skippedCells
return Math.ceil(totalCellsNeeded / CELLS_PER_PAGE)
})
const sheetPages = computed(() => {
const result = []
if (selectedDevices.value.length === 0) return result
const startCellNum = parseInt(startCell.value)
let usbIdx = 0
for (let page = 0; page < pageCount.value; page++) {
const cells = []
for (let cellNum = 1; cellNum <= CELLS_PER_PAGE; cellNum++) {
const skipThisCell = page === 0 && cellNum < startCellNum
const hasContent = !skipThisCell && usbIdx < selectedDevices.value.length
const items = []
if (hasContent) {
for (let mini = 0; mini < MINI_LABELS_PER_CELL; mini++) {
if (usbIdx < selectedDevices.value.length) {
items.push(selectedDevices.value[usbIdx])
usbIdx++
} else {
items.push(null)
}
}
}
cells.push({ hasContent, items })
}
result.push(cells)
}
return result
})
onMounted(async () => {
labelStyle.value = (await getSetting('usb_label_style', 'barcode')) === 'qr' ? 'qr' : 'barcode'
try {
// listAll: perpage is clamped to 100, and a batch sheet must cover every
// device, not the first page of them.
devices.value = await usbApi.listAll()
} catch (error) {
console.error('Error loading USB devices:', error)
} finally {
loadingDevices.value = false
}
})
watch([selectedDevices, startCell], async () => {
await nextTick()
if (labelStyle.value === 'qr') {
await generateQrImages()
} else {
generateBarcodes()
}
}, { deep: true })
async function generateQrImages() {
const next = {}
for (let pageIdx = 0; pageIdx < sheetPages.value.length; pageIdx++) {
const page = sheetPages.value[pageIdx]
for (let cellIdx = 0; cellIdx < page.length; cellIdx++) {
const cell = page[cellIdx]
if (!cell.hasContent) continue
for (let miniIdx = 0; miniIdx < cell.items.length; miniIdx++) {
const item = cell.items[miniIdx]
if (!item) continue
try {
const url = await buildQrUrl('qr_target_usb', `/usb/${encodeURIComponent(item.device_id)}`, {
id: item.device_id || '',
serialnumber: item.device_id || '',
alias: item.device_desc || '',
})
next[`${pageIdx}-${cellIdx + 1}-${miniIdx}`] =
await qrPngDataUrl(url, { width: 150 })
} catch (e) {
console.error('QR error:', item.device_id, e)
}
}
}
}
qrImages.value = next
}
function setBarcodeRef(el, pageIdx, cellNum, miniIdx) {
if (el) {
barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`] = el
}
}
function generateBarcodes() {
sheetPages.value.forEach((page, pageIdx) => {
page.forEach((cell, cellIdx) => {
if (!cell.hasContent) return
const cellNum = cellIdx + 1
cell.items.forEach((item, miniIdx) => {
if (!item) return
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
if (!el) return
try {
barcodeInto(el, item.device_id, {
width: 1, height: 22, background: 'transparent',
})
} catch (e) {
console.error('Barcode error:', item.device_id, e)
}
})
})
})
}
function isSelected(device) {
return selectedDevices.value.some(d => d.device_id === device.device_id)
}
function toggleDevice(device) {
const idx = selectedDevices.value.findIndex(d => d.device_id === device.device_id)
if (idx > -1) {
selectedDevices.value.splice(idx, 1)
} else {
selectedDevices.value.push(device)
}
}
function clearSelection() {
selectedDevices.value = []
}
function selectAll() {
selectedDevices.value = [...devices.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;
}
.usb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
padding: 10px;
background: var(--bg);
}
.usb-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;
}
.usb-item:hover { border-color: var(--primary); }
.usb-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.usb-item input { margin-right: 10px; }
.usb-item label { cursor: pointer; flex: 1; }
.usb-item .alias { 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-cell {
width: 3in;
height: 3in;
position: absolute;
box-sizing: border-box;
border: 1px dashed #ccc;
overflow: hidden;
}
.label-cell.has-content { border: 1px solid var(--primary); }
.label-cell.empty { background: #fafafa; }
.cell-1 { top: 0.875in; left: 1.1875in; }
.cell-2 { top: 0.875in; left: 4.3125in; }
.cell-3 { top: 4in; left: 1.1875in; }
.cell-4 { top: 4in; left: 4.3125in; }
.cell-5 { top: 7.125in; left: 1.1875in; }
.cell-6 { top: 7.125in; left: 4.3125in; }
.mini-grid {
display: grid;
grid-template-columns: repeat(3, 1in);
grid-template-rows: repeat(4, 0.75in);
width: 3in;
height: 3in;
}
.mini-label {
width: 1in;
height: 0.75in;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0.02in;
border: 1px dotted #ddd;
overflow: hidden;
}
.mini-label.filled { border: 1px solid #999; }
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
.barcode-container { text-align: center; line-height: 0; }
.barcode-container svg { max-width: 0.9in; height: 24px; }
.qr-img { width: 0.48in; height: 0.48in; }
.serial-text {
font-size: 6pt;
font-weight: bold;
font-family: monospace;
text-align: center;
margin-top: 1px;
letter-spacing: 0.3px;
}
.empty-cell-text {
color: #ccc;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
@media print {
/* Force the barcodes/borders to print even when "Background graphics" is
off. */
body, .print-sheet, .label-cell, .mini-label, .barcode-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-cell { border: none !important; }
.label-cell.empty { visibility: hidden; }
.mini-label { border: 1px dotted #ccc !important; }
.mini-label.empty { visibility: hidden; }
}
</style>