- Fix equipment badge barcode not rendering (loading race condition) - Fix printer QR code not rendering on initial load (same race condition) - Add model image to equipment badge via imageurl from Model table - Fix white-on-white machine number text on badge, tighten barcode spacing - Add PaginationBar component used across all list pages - Split monolithic router into per-plugin route modules - Fix 25 GET API endpoints returning 401 (jwt_required -> optional=True) - Align list page columns across Equipment, PCs, and Network pages - Add print views: EquipmentBadge, PrinterQRSingle, PrinterQRBatch, USBLabelBatch - Add PC Relationships report, migration docs, and CLAUDE.md project guide - Various plugin model, API, and frontend refinements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
292 lines
8.5 KiB
Vue
292 lines
8.5 KiB
Vue
<template>
|
|
<div>
|
|
<div class="no-print">
|
|
<div class="controls">
|
|
<h3>Batch Print Printer QR Codes</h3>
|
|
<p>Select printers to print (6 per page):</p>
|
|
|
|
<div v-if="loadingPrinters" class="loading-msg">Loading printers...</div>
|
|
<div v-else class="printer-grid">
|
|
<div
|
|
v-for="printer in printers"
|
|
:key="printer.assetid"
|
|
class="printer-item"
|
|
:class="{ selected: isSelected(printer) }"
|
|
@click="togglePrinter(printer)"
|
|
>
|
|
<input type="checkbox" :checked="isSelected(printer)" @click.stop />
|
|
<label>
|
|
<strong>{{ displayName(printer) }}</strong>
|
|
<div class="model">{{ printer.printer?.modelname || '' }}</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="selected-count">
|
|
Selected: <span class="count">{{ selectedPrinters.length }}</span> printers
|
|
(<span class="pages">{{ pageCount }}</span> pages)
|
|
</div>
|
|
|
|
<button class="print-btn" :disabled="selectedPrinters.length === 0" @click="print">Print QR Codes</button>
|
|
<button class="clear-btn" @click="clearSelection">Clear All</button>
|
|
<button class="select-all-btn" @click="selectAll">Select All</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sheets-container">
|
|
<div v-for="(page, pageIdx) in pages" :key="pageIdx" class="print-sheet">
|
|
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
|
<div
|
|
v-for="pos in 6"
|
|
:key="pos"
|
|
class="label"
|
|
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
|
|
>
|
|
<template v-if="page[pos - 1]">
|
|
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
|
|
<div class="qr-container">
|
|
<canvas :ref="el => setQrRef(el, pageIdx, pos)"></canvas>
|
|
</div>
|
|
<div class="info-section">
|
|
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
|
|
<div class="info-inner">
|
|
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
|
|
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<span v-else class="empty-label">Empty</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|
import { printersApi } from '../../api'
|
|
import QRCode from 'qrcode'
|
|
|
|
const printers = ref([])
|
|
const selectedPrinters = ref([])
|
|
const loadingPrinters = ref(true)
|
|
const qrRefs = ref({})
|
|
|
|
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
|
|
|
|
const pages = computed(() => {
|
|
const result = []
|
|
for (let i = 0; i < selectedPrinters.value.length; i += 6) {
|
|
const page = []
|
|
for (let j = 0; j < 6; j++) {
|
|
page.push(selectedPrinters.value[i + j] || null)
|
|
}
|
|
result.push(page)
|
|
}
|
|
return result
|
|
})
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const response = await printersApi.list({ perpage: 500 })
|
|
printers.value = response.data.data || []
|
|
} catch (error) {
|
|
console.error('Error loading printers:', error)
|
|
} finally {
|
|
loadingPrinters.value = false
|
|
}
|
|
})
|
|
|
|
watch(selectedPrinters, async () => {
|
|
await nextTick()
|
|
generateQRCodes()
|
|
}, { deep: true })
|
|
|
|
function setQrRef(el, pageIdx, pos) {
|
|
if (el) {
|
|
qrRefs.value[`${pageIdx}-${pos}`] = el
|
|
}
|
|
}
|
|
|
|
function generateQRCodes() {
|
|
pages.value.forEach((page, pageIdx) => {
|
|
page.forEach((printer, idx) => {
|
|
if (!printer) return
|
|
const pos = idx + 1
|
|
const canvas = qrRefs.value[`${pageIdx}-${pos}`]
|
|
if (!canvas) return
|
|
|
|
const qrUrl = `${window.location.origin}/printers/${printer.printer?.printerid || printer.assetid}`
|
|
QRCode.toCanvas(canvas, qrUrl, {
|
|
width: 144,
|
|
margin: 0,
|
|
errorCorrectionLevel: 'M'
|
|
}).catch(err => console.error('QR error:', err))
|
|
})
|
|
})
|
|
}
|
|
|
|
function displayName(printer) {
|
|
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
|
|
}
|
|
|
|
function getIp(printer) {
|
|
if (!printer.communications?.length) return null
|
|
const primary = printer.communications.find(c => c.isprimary) || printer.communications[0]
|
|
return primary?.ipaddress || primary?.address || null
|
|
}
|
|
|
|
function isSelected(printer) {
|
|
return selectedPrinters.value.some(p => p.assetid === printer.assetid)
|
|
}
|
|
|
|
function togglePrinter(printer) {
|
|
const idx = selectedPrinters.value.findIndex(p => p.assetid === printer.assetid)
|
|
if (idx > -1) {
|
|
selectedPrinters.value.splice(idx, 1)
|
|
} else {
|
|
selectedPrinters.value.push(printer)
|
|
}
|
|
}
|
|
|
|
function clearSelection() {
|
|
selectedPrinters.value = []
|
|
}
|
|
|
|
function selectAll() {
|
|
selectedPrinters.value = [...printers.value]
|
|
}
|
|
|
|
function print() {
|
|
window.print()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
@page { size: letter; margin: 0; }
|
|
|
|
.no-print { margin-bottom: 20px; padding: 20px; }
|
|
.controls { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
|
|
.controls h3 { margin-top: 0; }
|
|
|
|
.print-btn {
|
|
padding: 10px 30px;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
background: #667eea;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 5px;
|
|
margin-right: 10px;
|
|
}
|
|
.print-btn:disabled { background: #ccc; cursor: not-allowed; }
|
|
|
|
.clear-btn {
|
|
padding: 10px 20px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
background: #dc3545;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 5px;
|
|
margin-right: 10px;
|
|
}
|
|
|
|
.select-all-btn {
|
|
padding: 10px 20px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
background: #28a745;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.printer-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
|
gap: 10px;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
border: 1px solid #ddd;
|
|
padding: 10px;
|
|
background: #fafafa;
|
|
}
|
|
|
|
.printer-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 8px;
|
|
background: white;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.printer-item:hover { background: #f0f0f0; }
|
|
.printer-item.selected { background: #e7f1ff; border-color: #667eea; }
|
|
.printer-item input { margin-right: 10px; }
|
|
.printer-item label { cursor: pointer; flex: 1; }
|
|
.printer-item .model { font-size: 11px; color: #666; }
|
|
|
|
.selected-count { font-weight: bold; margin: 10px 0; }
|
|
.selected-count .count { color: #667eea; }
|
|
.selected-count .pages { color: #28a745; }
|
|
|
|
.loading-msg { text-align: center; padding: 2rem; color: #666; }
|
|
|
|
.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; }
|
|
|
|
.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;
|
|
border: 1px dashed #ccc;
|
|
}
|
|
.label.filled { border: 2px solid #667eea; }
|
|
.label.empty { background: #fafafa; }
|
|
|
|
.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; }
|
|
|
|
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
|
|
.qr-container { text-align: center; }
|
|
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
|
.info-inner { text-align: left; }
|
|
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
|
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; }
|
|
.empty-label { color: #999; font-size: 14px; }
|
|
|
|
@media print {
|
|
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.empty { visibility: hidden; }
|
|
}
|
|
</style>
|