Add email sending (service + 3 flows) and a general asset label generator
All checks were successful
CI / backend (push) Successful in 1m23s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Email: a stdlib SMTP mailer (settings-first config, graceful no-op when
unconfigured), a test-email endpoint wired to the Email settings page,
forced first-login password change (users.mustchangepassword, migration
7d23, /change-password flow), new-user welcome mail, and on-demand
report/alert delivery (POST /api/reports/email + Email Report buttons)
with an external-cron-with-a-scoped-PAT path documented for automation.
All tests patch smtplib - no network.

Labels: a shared /print/asset-label/<type>/<id> view any asset detail
page opens - card or plain style, QR or barcode, configurable encoding.
Per-type qr_target_* templates plus label_default_style/codetype/encodes
settings on the Printing page. Measuring-tool labels default to encoding
their inspection-operation code (derived from the location name, e.g.
0615), so every tool in an area shares the area code - verified by
decoding the rendered QR. Machine labels default to the machine number;
blank-serial handled gracefully.

808 tests pass; both features verified live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 11:58:30 -04:00
parent 7d309aabeb
commit a846587f39
34 changed files with 1819 additions and 12 deletions

View File

@@ -0,0 +1,276 @@
<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>
<button class="print-btn" @click="print">Print</button>
</template>
</div>
</div>
<!-- Printable area -->
<div v-if="asset" 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>
</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')
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()
}
})
watch([style, codetype, encodes], renderCode)
function print() {
window.print()
}
</script>
<style scoped>
@page { size: 2.13in 3.38in; margin: 0; }
.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; }
.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;
}
@media print {
.no-print { display: none !important; }
.label-sheet { padding: 0; }
.asset-label { border: none; }
body, .asset-label, .code-qr, .code-barcode {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
}
</style>

View File

@@ -0,0 +1,99 @@
// Shared wiring for the asset label pages (single AssetLabel.vue and batch
// AssetLabelBatch.vue): per-type api/route config plus the "what does the code
// encode" resolution so both surfaces behave identically.
import {
machinesApi, computersApi, printersApi, networkApi, measuringtoolsApi,
} from '../../api'
import { fillUrlTemplate } from '@/utils/qrTarget'
import { getSetting, getSiteBaseUrl } from '@/utils/siteSettings'
// Per asset-type: which api loads it, its detail/list routes, the extension key
// nested in the merged payload, and its qr_target_<type> settings key.
export const TYPE_CONFIG = {
machine: {
api: machinesApi, extkey: 'machine', targetKey: 'qr_target_machine',
label: 'Machine', detailPath: id => `/machines/${id}`, listPath: '/machines',
},
computer: {
api: computersApi, extkey: 'computer', targetKey: 'qr_target_computer',
label: 'Computer', detailPath: id => `/pcs/${id}`, listPath: '/pcs',
},
printer: {
api: printersApi, extkey: 'printer', targetKey: 'qr_target_printer',
label: 'Printer', detailPath: id => `/printers/${id}`, listPath: '/printers',
},
network_device: {
api: networkApi, extkey: 'network_device', targetKey: 'qr_target_network_device',
label: 'Network Device', detailPath: id => `/network/${id}`, listPath: '/network',
},
measuring_tool: {
api: measuringtoolsApi, extkey: 'measuringtool', targetKey: 'qr_target_measuring_tool',
label: 'Measuring Tool', detailPath: id => `/measuringtools/${id}`, listPath: '/measuringtools',
},
}
// Hardcoded fallback default per type when the site setting is unset. Machines
// encode their machine number, measuring tools their inspection location.
const DEFAULT_ENCODES = { machine: 'assetnumber', measuring_tool: 'location' }
export function hasLocationType(assettype) {
return assettype === 'measuring_tool'
}
// The default encode mode for a type: the label_default_encodes_<type> site
// setting when valid, else the hardcoded fallback.
export async function resolveDefaultEncodes(assettype) {
const valid = ['assetpage', 'assetnumber', 'serialnumber', 'custom']
if (hasLocationType(assettype)) valid.push('location')
const seeded = await getSetting(`label_default_encodes_${assettype}`, '')
return valid.includes(seeded) ? seeded : (DEFAULT_ENCODES[assettype] || 'assetpage')
}
// 'location' degrades to the asset page when the asset has no location code.
export function effectiveEncodes(encodes, asset) {
if (encodes === 'location' && !asset?.locationcode) return 'assetpage'
return encodes
}
function tokensFor(asset) {
return {
assetid: asset.assetid || '',
assetnumber: asset.assetnumber || '',
serialnumber: asset.serialnumber || '',
name: asset.name || '',
pluginid: asset.pluginid || '',
locationcode: asset.locationcode || '',
locationname: asset.locationname || '',
}
}
// The string a label's code encodes for one asset. Empty when the chosen field
// has no value (e.g. serial number on an asset with none).
export async function resolveCodeText(assettype, asset, encodes) {
const config = TYPE_CONFIG[assettype]
if (!config || !asset) return ''
const detailId = asset.pluginid
const assetPageUrl = `${await getSiteBaseUrl()}${config.detailPath(detailId)}`
switch (effectiveEncodes(encodes, asset)) {
case 'assetnumber': return asset.assetnumber || ''
case 'serialnumber': return asset.serialnumber || ''
case 'location': return asset.locationcode || ''
case 'custom': {
const template = (await getSetting(config.targetKey, '')).trim()
return template ? fillUrlTemplate(template, tokensFor(asset)) : assetPageUrl
}
case 'assetpage':
default: return assetPageUrl
}
}
// Human caption printed under the code.
export function captionFor(asset, encodes) {
if (!asset) return ''
switch (effectiveEncodes(encodes, asset)) {
case 'location': return asset.locationcode || ''
case 'serialnumber': return asset.serialnumber || ''
case 'assetnumber':
default: return asset.assetnumber || ''
}
}