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 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 instead of rendering an empty code. Asset payloads now carry a derived
`locationcode` (leading token of the resolved own/inherited location name). `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 - 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 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 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 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 ### map
| Key | Default | Notes | | Key | Default | Notes |

View File

@@ -85,6 +85,12 @@ const routes = [
name: 'print-asset-label', name: 'print-asset-label',
component: () => import('../views/print/AssetLabel.vue') 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', path: '/print/printer-qr',
name: 'print-printer-qr-batch', name: 'print-printer-qr-batch',

View File

@@ -2,6 +2,7 @@
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Machines</h2> <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> <router-link to="/machines/new" class="btn btn-primary">Add Machine</router-link>
</div> </div>

View File

@@ -2,6 +2,7 @@
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Measuring Tools</h2> <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> <router-link to="/measuringtools/new" class="btn btn-primary">Add Measuring Tool</router-link>
</div> </div>

View File

@@ -2,6 +2,7 @@
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Network Devices</h2> <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> <router-link to="/network/new" class="btn btn-primary">Add Device</router-link>
</div> </div>
@@ -117,7 +118,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle" import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '../../api' import { networkApi, vendorsApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue' import PaginationBar from '../../components/PaginationBar.vue'

View File

@@ -2,6 +2,7 @@
<div> <div>
<div class="page-header"> <div class="page-header">
<h2>Computers</h2> <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> <router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
</div> </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> <h2>Printers</h2>
<div class="header-actions"> <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/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> <router-link to="/printers/new" class="btn btn-primary">Add Printer</router-link>
</div> </div>
</div> </div>