Files
shopdb-flask/frontend/src/views/print/AssetLabelBatch.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

500 lines
16 KiB
Vue

<template>
<div>
<!-- Controls (never printed) -->
<div class="no-print">
<div class="controls">
<h3>Batch Print {{ config ? config.label : 'Asset' }} Labels</h3>
<p v-if="!config" class="error-msg">Unknown asset type.</p>
<template v-else>
<div class="control-row">
<label>Format
<select v-model="format">
<option value="uline6">ULINE 6-up (3 in x 3 in, 6 per page)</option>
<option value="mini72">Mini 72-up (12 per cell, 72 per page)</option>
</select>
</label>
<label>Code type
<select v-model="codetype">
<option value="qr">QR code</option>
<option value="barcode">Barcode (CODE128)</option>
</select>
</label>
<label>Encodes
<select v-model="encodes">
<option value="assetpage">Asset page (link)</option>
<option value="assetnumber">Asset number</option>
<option value="serialnumber">Serial number</option>
<option v-if="hasLocation" value="location">Inspection location code</option>
<option value="custom">Custom target (settings template)</option>
</select>
</label>
<label>Start at cell
<select v-model="startCell">
<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>
<div v-if="loadingAssets" class="loading-msg">Loading assets...</div>
<div v-else-if="assets.length === 0" class="loading-msg">No assets found.</div>
<div v-else class="asset-grid">
<div
v-for="asset in assets"
:key="asset.assetid"
class="asset-item"
:class="{ selected: isSelected(asset) }"
@click="toggle(asset)"
>
<input type="checkbox" :checked="isSelected(asset)" @click.stop />
<label>
<strong>{{ asset.assetnumber || '-' }}</strong>
<div class="asset-sub">{{ asset.name || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selected.length }}</span>
(<span class="pages">{{ pageCount }}</span> page{{ pageCount === 1 ? '' : 's' }})
</div>
<button class="print-btn" :disabled="selected.length === 0" @click="print">Print Labels</button>
<button class="secondary-btn" @click="selectAll">Select All</button>
<button class="secondary-btn" @click="clearSelection">Clear</button>
</template>
</div>
</div>
<!-- Printable sheets -->
<div class="sheets-container">
<!-- ULINE 6-up: one asset per 3in cell -->
<template v-if="format === 'uline6'">
<div v-for="(page, pageIdx) in uline6Pages" :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"
:class="[`pos-${cellNum}`, page[cellNum - 1] ? 'active' : 'inactive']"
>
<template v-if="page[cellNum - 1]">
<div class="model-name">{{ titleFor(page[cellNum - 1]) }}</div>
<div class="qr-container">
<img
v-if="codetype === 'qr'"
class="qr-img"
:src="qrMap[page[cellNum - 1].assetid] || ''"
/>
<svg
v-else
:ref="el => setBarcodeRef(el, page[cellNum - 1].assetid, 'u')"
class="barcode-svg"
></svg>
</div>
<div class="info-section">
<div class="csf-name">{{ captionMap[page[cellNum - 1].assetid] }}</div>
<div v-if="page[cellNum - 1].name" class="info-row">{{ page[cellNum - 1].name }}</div>
</div>
</template>
</div>
</div>
</template>
<!-- Mini 72-up: 6 cells x 12 mini labels -->
<template v-else>
<div v-for="(page, pageIdx) in mini72Pages" :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="(item, miniIdx) in page[cellNum - 1].items"
:key="miniIdx"
class="mini-label"
:class="item ? 'filled' : 'empty'"
>
<template v-if="item">
<img v-if="codetype === 'qr'" class="mini-qr" :src="qrMap[item.assetid] || ''" />
<svg v-else :ref="el => setBarcodeRef(el, item.assetid, 'm')" class="mini-barcode"></svg>
<div class="serial-text">{{ captionMap[item.assetid] }}</div>
</template>
</div>
</div>
<div v-else class="empty-cell-text">Empty</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { renderQrDataUrl, barcodeInto } from '@/utils/codes'
import { getSetting } from '@/utils/siteSettings'
import {
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
resolveCodeText, captionFor,
} from './assetLabel'
const MINI_PER_CELL = 12
const CELLS_PER_PAGE = 6
const route = useRoute()
const assettype = route.params.assettype
const config = TYPE_CONFIG[assettype] || null
const assets = ref([])
const selected = ref([])
const loadingAssets = ref(true)
const format = ref('uline6')
const codetype = ref('qr')
const encodes = ref('assetpage')
const startCell = ref('1')
// assetid -> encoded code text / caption / QR data URL. Barcodes render to refs.
const codeMap = ref({})
const captionMap = ref({})
const qrMap = ref({})
const barcodeRefs = ref({})
const hasLocation = computed(() => hasLocationType(assettype))
function isSelected(asset) {
return selected.value.some(a => a.assetid === asset.assetid)
}
function toggle(asset) {
const idx = selected.value.findIndex(a => a.assetid === asset.assetid)
if (idx > -1) selected.value.splice(idx, 1)
else selected.value.push(asset)
}
function selectAll() { selected.value = [...assets.value] }
function clearSelection() { selected.value = [] }
function titleFor(asset) {
return asset.name || (config ? config.label : 'Asset')
}
const pageCount = computed(() => {
if (selected.value.length === 0) return 0
const skipped = parseInt(startCell.value) - 1
if (format.value === 'uline6') {
return Math.ceil((selected.value.length + skipped) / CELLS_PER_PAGE)
}
const cells = Math.ceil(selected.value.length / MINI_PER_CELL)
return Math.ceil((cells + skipped) / CELLS_PER_PAGE)
})
// ULINE 6-up: each page is 6 cells, each holding one asset (or null).
const uline6Pages = computed(() => {
const pages = []
if (selected.value.length === 0) return pages
const skip = parseInt(startCell.value) - 1
let idx = 0
for (let page = 0; page < pageCount.value; page++) {
const cells = []
for (let cell = 0; cell < CELLS_PER_PAGE; cell++) {
const blank = page === 0 && cell < skip
cells.push((!blank && idx < selected.value.length) ? selected.value[idx++] : null)
}
pages.push(cells)
}
return pages
})
// Mini 72-up: each page is 6 cells, each holding up to 12 mini labels.
const mini72Pages = computed(() => {
const pages = []
if (selected.value.length === 0) return pages
const skip = parseInt(startCell.value) - 1
let idx = 0
for (let page = 0; page < pageCount.value; page++) {
const cells = []
for (let cell = 0; cell < CELLS_PER_PAGE; cell++) {
const blank = (page === 0 && cell < skip) || idx >= selected.value.length
const items = []
if (!blank) {
for (let mini = 0; mini < MINI_PER_CELL; mini++) {
items.push(idx < selected.value.length ? selected.value[idx++] : null)
}
}
cells.push({ hasContent: !blank, items })
}
pages.push(cells)
}
return pages
})
function setBarcodeRef(el, assetid, prefix) {
if (el) barcodeRefs.value[`${prefix}-${assetid}`] = el
}
// Resolve the encode text + caption for every loaded asset once, then per
// encode-mode change. Kept over the whole list (cheap) so selection is instant.
async function buildCodeMap() {
const codes = {}
const captions = {}
for (const asset of assets.value) {
codes[asset.assetid] = await resolveCodeText(assettype, asset, encodes.value)
captions[asset.assetid] = captionFor(asset, encodes.value)
}
codeMap.value = codes
captionMap.value = captions
}
async function buildQrMap() {
if (codetype.value !== 'qr') return
const images = {}
for (const asset of selected.value) {
const text = codeMap.value[asset.assetid]
if (text) images[asset.assetid] = await renderQrDataUrl(text)
}
qrMap.value = images
}
function renderBarcodes() {
if (codetype.value !== 'barcode') return
const prefix = format.value === 'uline6' ? 'u' : 'm'
const height = format.value === 'uline6' ? 60 : 22
const width = format.value === 'uline6' ? 2 : 1
for (const asset of selected.value) {
const el = barcodeRefs.value[`${prefix}-${asset.assetid}`]
const text = codeMap.value[asset.assetid]
if (!el || !text) continue
try {
barcodeInto(el, text, { width, height, background: 'transparent' })
} catch (err) {
console.error('Barcode error:', asset.assetnumber, err)
}
}
}
async function refreshCodes() {
await buildCodeMap()
await refreshRender()
}
async function refreshRender() {
await buildQrMap()
await nextTick()
renderBarcodes()
}
onMounted(async () => {
if (!config) { loadingAssets.value = false; return }
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
encodes.value = await resolveDefaultEncodes(assettype)
try {
// listAll, not list: perpage is clamped to 100 server-side, so a batch
// sheet built from list() silently omitted every asset past the first
// 100 and printed a short run that looked complete.
assets.value = await config.api.listAll()
} catch (err) {
console.error('Error loading assets:', err)
} finally {
loadingAssets.value = false
await refreshCodes()
}
})
watch(encodes, refreshCodes)
watch([selected, format, codetype, startCell], refreshRender, { deep: true })
function print() { window.print() }
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { padding: 20px; }
.controls {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.controls h3 { margin-top: 0; }
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 16px; }
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
.control-row select { padding: 6px; font-size: 0.875rem; }
.asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--border);
padding: 10px;
background: var(--bg);
}
.asset-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;
}
.asset-item:hover { border-color: var(--primary); }
.asset-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.asset-item input { margin-right: 10px; }
.asset-item label { cursor: pointer; flex: 1; }
.asset-item .asset-sub { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 12px 0; color: var(--text); }
.selected-count .count { color: var(--primary); }
.selected-count .pages { color: var(--success); }
.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; }
.secondary-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--secondary);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.loading-msg, .error-msg { text-align: center; padding: 1.5rem; 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; }
/* ULINE 6-up cells */
.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;
}
.label.inactive { border: 1px dashed #ccc; }
.label.active { border: 2px solid var(--primary); }
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
.qr-container { text-align: center; }
.qr-img { width: 144px; height: 144px; display: block; }
.barcode-svg { width: 2.4in; height: 0.8in; }
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; color: #000; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; text-align: center; }
/* Mini 72-up cells */
.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; }
.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; }
.mini-qr { width: 0.48in; height: 0.48in; }
.mini-barcode { max-width: 0.9in; height: 24px; }
.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%;
}
/* ULINE S-5627 / 6-up 3in label positions on a letter sheet */
.pos-1, .cell-1 { top: 0.875in; left: 1.1875in; }
.pos-2, .cell-2 { top: 0.875in; left: 4.3125in; }
.pos-3, .cell-3 { top: 4in; left: 1.1875in; }
.pos-4, .cell-4 { top: 4in; left: 4.3125in; }
.pos-5, .cell-5 { top: 7.125in; left: 1.1875in; }
.pos-6, .cell-6 { top: 7.125in; left: 4.3125in; }
@media print {
body, .print-sheet, .label, .label-cell, .mini-label, .qr-img, .mini-qr, .barcode-svg, .mini-barcode {
-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.inactive { visibility: hidden; }
.label-cell { border: none !important; }
.label-cell.empty { visibility: hidden; }
.mini-label { border: 1px dotted #ccc !important; }
.mini-label.empty { visibility: hidden; }
}
</style>