Add batch ULINE label printing for all asset types
All checks were successful
CI / backend (push) Successful in 1m24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

A multi-select batch sheet at /print/asset-label-batch/<type> lays
selected assets onto ULINE label pages (6-up 3x3 or dense 72-up mini),
with code-type and encode toggles matching the single label, plus a
start-cell offset for partial sheets. Print Labels buttons on all five
asset list pages. The per-type config and encode resolution are
extracted to a shared print/assetLabel.js used by both the single and
batch views. Measuring-tool batches encode each tool inspection-
operation code (decode-verified 0615).

808 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 12:08:22 -04:00
parent a846587f39
commit 4b20c2eb56
9 changed files with 523 additions and 1 deletions

View File

@@ -55,6 +55,14 @@ ADR-007 and ADR-002.
has no value (e.g. serial number on an asset with none), the label states so
instead of rendering an empty code. Asset payloads now carry a derived
`locationcode` (leading token of the resolved own/inherited location name).
- Batch label sheets: a "Print Labels" button on each asset list page opens
`/print/asset-label-batch/<assettype>`, a multi-select sheet that lays the
chosen assets onto ULINE label pages (6-up 3 in x 3 in, or a dense 72-up
mini-label format), with a start-cell offset to reuse partial sheets. Shares
the same code-type and encode settings/defaults as the single label, so a
batch of measuring tools encodes each tool's inspection location code just
like the single label does. Restores the ULINE batch printing the legacy
shopdb had, generalized across all asset types.
- Support-team contact UX: the settings Support Teams page now manages each
team's contacts in a per-team "Contacts (N)" modal (name, SSO, active, plus
Add/Edit/Delete) instead of an inline row expander, and the application

View File

@@ -184,6 +184,8 @@ them under `instance/branding/`.
The shared asset-label generator lives at `/print/asset-label/<assettype>/<id>` (public, like the other `/print/*` pages; `assettype` is one of `machine`, `computer`, `printer`, `network_device`, `measuring_tool`, and `id` is the asset's plugin id). It can encode the asset page link, the asset number, the serial number, a custom `qr_target_<type>` template, or - for measuring tools by default - the asset's inspection location code (the leading token of the location name, e.g. `0615`). A measuring tool with no location falls back to its asset page.
The batch generator at `/print/asset-label-batch/<assettype>` (reached from the "Print Labels" button on each asset list page) lays a multi-selection of one type onto ULINE label sheets: a 6-up 3 in x 3 in format or a dense 72-up mini-label format, with a start-cell offset for reusing partial sheets. It reuses the same code-type and `label_default_encodes_<type>` defaults as the single label.
### map
| Key | Default | Notes |

View File

@@ -85,6 +85,12 @@ const routes = [
name: 'print-asset-label',
component: () => import('../views/print/AssetLabel.vue')
},
// Batch label sheets (ULINE 6-up / mini 72-up) for one asset type.
{
path: '/print/asset-label-batch/:assettype',
name: 'print-asset-label-batch',
component: () => import('../views/print/AssetLabelBatch.vue')
},
{
path: '/print/printer-qr',
name: 'print-printer-qr-batch',

View File

@@ -2,6 +2,7 @@
<div>
<div class="page-header">
<h2>Machines</h2>
<router-link to="/print/asset-label-batch/machine" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/machines/new" class="btn btn-primary">Add Machine</router-link>
</div>

View File

@@ -2,6 +2,7 @@
<div>
<div class="page-header">
<h2>Measuring Tools</h2>
<router-link to="/print/asset-label-batch/measuring_tool" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/measuringtools/new" class="btn btn-primary">Add Measuring Tool</router-link>
</div>

View File

@@ -2,6 +2,7 @@
<div>
<div class="page-header">
<h2>Network Devices</h2>
<router-link to="/print/asset-label-batch/network_device" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/network/new" class="btn btn-primary">Add Device</router-link>
</div>
@@ -117,7 +118,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'

View File

@@ -2,6 +2,7 @@
<div>
<div class="page-header">
<h2>Computers</h2>
<router-link to="/print/asset-label-batch/computer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
</div>

View File

@@ -0,0 +1,501 @@
<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 JsBarcode from 'jsbarcode'
import { renderQrDataUrl } from './qrLogo'
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 {
JsBarcode(el, text, {
format: 'CODE128', displayValue: false, width, height, margin: 0,
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 {
const response = await config.api.list({ perpage: 500 })
assets.value = response.data.data || []
} 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>

View File

@@ -4,6 +4,7 @@
<h2>Printers</h2>
<div class="header-actions">
<router-link to="/print/printer-qr" class="btn btn-secondary" target="_blank">Batch Print QR</router-link>
<router-link to="/print/asset-label-batch/printer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/printers/new" class="btn btn-primary">Add Printer</router-link>
</div>
</div>