The single-label page gains an Output toggle (standalone vs place on a ULINE 6-up sheet) with a 2x3 cell picker, so one label can be printed into the correct physical position on a partially-used sheet - the single-label equivalent of the batch page start-cell offset. Batch page unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
426 lines
14 KiB
Vue
426 lines
14 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Controls (never printed) -->
|
|
<div class="no-print">
|
|
<div class="controls">
|
|
<h3>Print Asset Label</h3>
|
|
<div v-if="loading" class="loading-msg">Loading...</div>
|
|
<div v-else-if="!asset" class="error-msg">Asset not found</div>
|
|
<template v-else>
|
|
<div class="control-row">
|
|
<label>Style
|
|
<select v-model="style">
|
|
<option value="card">Card (badge)</option>
|
|
<option value="plain">Plain (code only)</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>
|
|
</div>
|
|
<p v-if="encodes === 'location' && !asset.locationcode" class="control-note">
|
|
This tool has no location; the label falls back to the asset page.
|
|
</p>
|
|
<div class="control-row">
|
|
<label>Output
|
|
<select v-model="outputMode">
|
|
<option value="single">Single label (standalone)</option>
|
|
<option value="uline-sheet">Place on ULINE 6-up sheet</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<!-- Cell picker: pick which cell of a partially-used ULINE sheet gets -->
|
|
<!-- this one label. 2x3 grid mirrors the physical cell positions. -->
|
|
<div v-if="outputMode === 'uline-sheet'" class="cell-picker">
|
|
<span class="cell-picker-label">Sheet cell</span>
|
|
<div class="cell-grid">
|
|
<button
|
|
v-for="cellNum in 6"
|
|
:key="cellNum"
|
|
type="button"
|
|
class="cell-btn"
|
|
:class="{ active: cellNum === sheetCell }"
|
|
@click="sheetCell = cellNum"
|
|
>{{ cellNum }}</button>
|
|
</div>
|
|
</div>
|
|
<button class="print-btn" @click="print">Print</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Printable area: standalone single label (unchanged default). -->
|
|
<div v-if="asset && outputMode === 'single'" class="label-sheet">
|
|
<div class="asset-label" :class="style">
|
|
<template v-if="style === 'card'">
|
|
<div class="label-title">{{ cardTitle }}</div>
|
|
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
|
|
<div class="label-fields">
|
|
<div v-for="field in identityFields" :key="field.label" class="label-field">
|
|
<span class="field-label">{{ field.label }}</span>
|
|
<span class="field-value">{{ field.value }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="code-area">
|
|
<template v-if="codeText">
|
|
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
|
|
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
|
|
<div class="code-caption">{{ caption }}</div>
|
|
</template>
|
|
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- One label placed at the chosen cell of a ULINE 6-up sheet, other five -->
|
|
<!-- blank, so it lands on the right spot of a partially-used sheet. Cell -->
|
|
<!-- layout/CSS replicated from AssetLabelBatch (left untouched) to keep -->
|
|
<!-- batch's verified rendering isolated. Only one branch renders at a time -->
|
|
<!-- so the shared barcodeEl ref resolves to whichever label is live. -->
|
|
<div v-else-if="asset" class="sheets-container">
|
|
<div class="print-sheet">
|
|
<div class="sheet-label no-print">ULINE 6-up sheet - cell {{ sheetCell }}</div>
|
|
<div
|
|
v-for="cellNum in 6"
|
|
:key="cellNum"
|
|
class="label"
|
|
:class="[`pos-${cellNum}`, cellNum === sheetCell ? 'active' : 'inactive']"
|
|
>
|
|
<div v-if="cellNum === sheetCell" class="asset-label in-cell" :class="style">
|
|
<template v-if="style === 'card'">
|
|
<div class="label-title">{{ cardTitle }}</div>
|
|
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
|
|
<div class="label-fields">
|
|
<div v-for="field in identityFields" :key="field.label" class="label-field">
|
|
<span class="field-label">{{ field.label }}</span>
|
|
<span class="field-value">{{ field.value }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div class="code-area">
|
|
<template v-if="codeText">
|
|
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
|
|
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
|
|
<div class="code-caption">{{ caption }}</div>
|
|
</template>
|
|
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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,
|
|
effectiveEncodes as effEncodes, resolveCodeText as resolveText, captionFor,
|
|
} from './assetLabel'
|
|
|
|
const route = useRoute()
|
|
const assettype = route.params.assettype
|
|
const config = TYPE_CONFIG[assettype] || null
|
|
|
|
const loading = ref(true)
|
|
const asset = ref(null)
|
|
const barcodeEl = ref(null)
|
|
const qrImage = ref('')
|
|
// The resolved string the code encodes; empty when the chosen field has no
|
|
// value (e.g. serial number on an asset with none) so the UI can explain it.
|
|
const codeText = ref('')
|
|
|
|
const style = ref('card')
|
|
const codetype = ref('qr')
|
|
const encodes = ref('assetpage')
|
|
// Output target: 'single' standalone label, or 'uline-sheet' placing this one
|
|
// label at sheetCell (1-6) of a ULINE 6-up sheet.
|
|
const outputMode = ref('single')
|
|
const sheetCell = ref(1)
|
|
|
|
const hasLocation = computed(() => hasLocationType(assettype))
|
|
|
|
const extension = computed(() => (asset.value && config) ? asset.value[config.extkey] : null)
|
|
|
|
const cardTitle = computed(() => {
|
|
if (!asset.value) return ''
|
|
const ext = extension.value || {}
|
|
return ext.modelname
|
|
|| ext.measuringtooltypename
|
|
|| asset.value.assettypename
|
|
|| (config ? config.label : 'Asset')
|
|
})
|
|
|
|
const imageUrl = computed(() => {
|
|
const ext = extension.value
|
|
return (ext && ext.imageurl) ? ext.imageurl : null
|
|
})
|
|
|
|
// Small identity table shown on the card.
|
|
const identityFields = computed(() => {
|
|
if (!asset.value) return []
|
|
const rows = []
|
|
if (asset.value.assetnumber) rows.push({ label: 'Asset #', value: asset.value.assetnumber })
|
|
if (asset.value.serialnumber) rows.push({ label: 'Serial', value: asset.value.serialnumber })
|
|
if (asset.value.name) rows.push({ label: 'Name', value: asset.value.name })
|
|
if (asset.value.locationname) rows.push({ label: 'Location', value: asset.value.locationname })
|
|
return rows
|
|
})
|
|
|
|
// Human caption printed under the code.
|
|
const caption = computed(() => captionFor(asset.value, encodes.value))
|
|
|
|
// Human phrase for the current encode mode, used in the "nothing to encode"
|
|
// message.
|
|
const ENCODE_LABELS = {
|
|
assetnumber: 'asset number', serialnumber: 'serial number',
|
|
location: 'inspection location', assetpage: 'page link', custom: 'target',
|
|
}
|
|
const encodeLabel = computed(() =>
|
|
ENCODE_LABELS[effEncodes(encodes.value, asset.value)] || 'value')
|
|
|
|
async function renderCode() {
|
|
const text = await resolveText(assettype, asset.value, encodes.value)
|
|
codeText.value = text
|
|
if (!text) { qrImage.value = ''; return }
|
|
if (codetype.value === 'qr') {
|
|
qrImage.value = await renderQrDataUrl(text)
|
|
} else {
|
|
await nextTick()
|
|
if (!barcodeEl.value) return
|
|
try {
|
|
JsBarcode(barcodeEl.value, text, {
|
|
format: 'CODE128', displayValue: false, width: 2, height: 70, margin: 0,
|
|
})
|
|
} catch (err) {
|
|
console.error('Barcode error:', err)
|
|
}
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (!config) { loading.value = false; return }
|
|
style.value = (await getSetting('label_default_style', 'card')) === 'plain' ? 'plain' : 'card'
|
|
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
|
|
encodes.value = await resolveDefaultEncodes(assettype)
|
|
try {
|
|
const response = await config.api.get(route.params.id)
|
|
asset.value = response.data.data
|
|
} catch (err) {
|
|
console.error('Error loading asset:', err)
|
|
} finally {
|
|
loading.value = false
|
|
await nextTick()
|
|
renderCode()
|
|
}
|
|
})
|
|
|
|
// outputMode/sheetCell re-mount the live label element, so redraw the barcode
|
|
// into the newly rendered svg (the qr path is just an <img> src, unaffected).
|
|
watch([style, codetype, encodes, outputMode, sheetCell], renderCode)
|
|
|
|
function print() {
|
|
window.print()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Standalone label is a small stock; the ULINE sheet is a letter page. Named */
|
|
/* pages let each render its own size (only one branch renders at a time). */
|
|
@page { size: 2.13in 3.38in; margin: 0; }
|
|
@page uline { size: letter; margin: 0; }
|
|
.print-sheet { page: uline; }
|
|
|
|
.no-print { padding: 20px; }
|
|
.controls {
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
max-width: 40rem;
|
|
}
|
|
.controls h3 { margin-top: 0; }
|
|
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
|
|
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
|
|
.control-row select { padding: 6px; font-size: 0.875rem; }
|
|
.control-note { color: var(--warning); font-size: 0.8125rem; margin: 0 0 12px; }
|
|
|
|
/* Cell picker: 2x3 grid mirroring the physical ULINE cell positions. */
|
|
.cell-picker { margin-bottom: 12px; }
|
|
.cell-picker-label { display: block; font-size: 0.875rem; margin-bottom: 4px; }
|
|
.cell-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 3rem);
|
|
grid-auto-rows: 3rem;
|
|
gap: 6px;
|
|
}
|
|
.cell-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
}
|
|
.cell-btn:hover { border-color: var(--primary); }
|
|
.cell-btn.active {
|
|
background: var(--primary);
|
|
color: white;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.print-btn {
|
|
padding: 10px 30px;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 5px;
|
|
}
|
|
.print-btn:hover { background: var(--primary-dark); }
|
|
|
|
.loading-msg, .error-msg { padding: 1rem 0; color: var(--text-light); }
|
|
|
|
.label-sheet { display: flex; justify-content: center; padding: 20px 0; }
|
|
|
|
.asset-label {
|
|
width: 2.13in;
|
|
min-height: 3.38in;
|
|
background: white;
|
|
color: #000;
|
|
border: 1px solid #ccc;
|
|
box-sizing: border-box;
|
|
padding: 0.15in;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
.asset-label.plain { justify-content: center; min-height: 2in; }
|
|
|
|
.label-title {
|
|
font-size: 12pt;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
margin-bottom: 0.08in;
|
|
}
|
|
.label-image {
|
|
max-width: 1.6in;
|
|
max-height: 1.2in;
|
|
object-fit: contain;
|
|
margin-bottom: 0.08in;
|
|
}
|
|
.label-fields { width: 100%; margin-bottom: 0.08in; }
|
|
.label-field {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 6px;
|
|
font-size: 8pt;
|
|
line-height: 1.4;
|
|
}
|
|
.field-label { color: #555; }
|
|
.field-value { font-weight: bold; text-align: right; word-break: break-all; }
|
|
|
|
.code-area {
|
|
margin-top: auto;
|
|
text-align: center;
|
|
width: 100%;
|
|
}
|
|
.code-qr { width: 1.5in; height: 1.5in; }
|
|
.code-barcode { width: 1.8in; height: 0.9in; }
|
|
.code-caption {
|
|
font-size: 12pt;
|
|
font-weight: bold;
|
|
font-family: monospace;
|
|
margin-top: 0.02in;
|
|
}
|
|
.code-missing {
|
|
font-size: 9pt;
|
|
color: #999;
|
|
padding: 0.3in 0.1in;
|
|
}
|
|
|
|
/* ULINE 6-up sheet (replicated from AssetLabelBatch; batch left untouched). */
|
|
.sheets-container { display: flex; justify-content: center; padding: 20px 0; }
|
|
.print-sheet {
|
|
width: 8.5in;
|
|
height: 11in;
|
|
background: white;
|
|
position: relative;
|
|
border: 1px solid #ccc;
|
|
}
|
|
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
|
.label {
|
|
width: 3in;
|
|
height: 3in;
|
|
position: absolute;
|
|
display: flex;
|
|
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); }
|
|
|
|
/* ULINE S-5627 / 6-up 3in cell positions on a letter sheet (match batch). */
|
|
.pos-1 { top: 0.875in; left: 1.1875in; }
|
|
.pos-2 { top: 0.875in; left: 4.3125in; }
|
|
.pos-3 { top: 4in; left: 1.1875in; }
|
|
.pos-4 { top: 4in; left: 4.3125in; }
|
|
.pos-5 { top: 7.125in; left: 1.1875in; }
|
|
.pos-6 { top: 7.125in; left: 4.3125in; }
|
|
|
|
/* The label shrunk to fit a 3in cell (standalone stock is taller than 3in). */
|
|
.label .asset-label.in-cell {
|
|
width: auto;
|
|
min-height: 0;
|
|
border: none;
|
|
padding: 0;
|
|
justify-content: center;
|
|
}
|
|
.label .asset-label.in-cell .label-title { font-size: 10pt; margin-bottom: 0.04in; }
|
|
.label .asset-label.in-cell .label-image { max-width: 1.2in; max-height: 0.7in; margin-bottom: 0.04in; }
|
|
.label .asset-label.in-cell .label-fields { margin-bottom: 0.04in; }
|
|
.label .asset-label.in-cell .code-area { margin-top: 0.05in; }
|
|
.label .asset-label.in-cell .code-qr { width: 1.1in; height: 1.1in; }
|
|
.label .asset-label.in-cell .code-barcode { width: 2.2in; height: 0.7in; }
|
|
.label .asset-label.in-cell .code-caption { font-size: 10pt; }
|
|
|
|
@media print {
|
|
.no-print { display: none !important; }
|
|
.label-sheet { padding: 0; }
|
|
.asset-label { border: none; }
|
|
.sheets-container { padding: 0; }
|
|
.print-sheet { border: none; }
|
|
.label { border: none !important; }
|
|
.label.inactive { visibility: hidden; }
|
|
body, .asset-label, .code-qr, .code-barcode {
|
|
-webkit-print-color-adjust: exact !important;
|
|
print-color-adjust: exact !important;
|
|
}
|
|
}
|
|
</style>
|