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.
500 lines
16 KiB
Vue
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>
|