Add print badges, pagination, route splitting, JWT auth fixes, and list page alignment

- 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>
This commit is contained in:
cproudlock
2026-02-04 07:32:44 -05:00
parent c4bfdc2db2
commit 9efdb5f52d
89 changed files with 3951 additions and 1138 deletions

View File

@@ -0,0 +1,166 @@
<template>
<div>
<button class="print-btn" @click="print" v-if="!loading">Print Badge</button>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="equipment" class="badge-container">
<div class="model-name">{{ modelName }}</div>
<img
v-if="isInspection"
class="machine-image"
:src="geLogo"
alt="GE Logo"
/>
<img
v-else-if="imageUrl"
class="machine-image"
:src="imageUrl"
:alt="modelName"
/>
<div class="barcode-container">
<svg ref="barcodeEl"></svg>
<div class="machine-number">*{{ equipment.assetnumber }}*</div>
</div>
</div>
<div v-else class="error-msg">Equipment not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { equipmentApi } from '../../api'
import JsBarcode from 'jsbarcode'
const route = useRoute()
const loading = ref(true)
const equipment = ref(null)
const barcodeEl = ref(null)
const geLogo = '/images/applications/GE-Logo.png'
const isInspection = computed(() => {
if (!equipment.value) return false
return equipment.value.assetnumber?.startsWith('06')
})
const modelName = computed(() => {
if (!equipment.value) return ''
if (isInspection.value) return 'Inspection'
return equipment.value.equipment?.modelname || ''
})
const imageUrl = computed(() => {
if (!equipment.value) return null
const img = equipment.value.equipment?.imageurl
if (img) return img.startsWith('/') ? img : `/images/models/machines/${img}`
return null
})
onMounted(async () => {
try {
const response = await equipmentApi.get(route.params.id)
equipment.value = response.data.data
} catch (error) {
console.error('Error loading equipment:', error)
} finally {
loading.value = false
await nextTick()
generateBarcode()
}
})
function generateBarcode() {
if (!barcodeEl.value || !equipment.value) return
try {
JsBarcode(barcodeEl.value, equipment.value.assetnumber, {
format: 'CODE39',
displayValue: false,
width: 2,
height: 70,
margin: 0
})
} catch (e) {
console.error('Barcode generation error:', e)
}
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: 2.13in 3.38in; margin: 0; }
body { font-family: Arial, sans-serif; }
.badge-container {
width: 2.13in;
height: 3.38in;
background: white;
margin: 0 auto;
border: 1px solid #ccc;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.15in;
box-sizing: border-box;
}
.model-name {
font-size: 12pt;
font-weight: bold;
text-align: center;
margin-bottom: 0.1in;
color: #000;
}
.machine-image {
max-width: 1.8in;
max-height: 1.5in;
object-fit: contain;
margin-bottom: 0.1in;
}
.barcode-container {
text-align: center;
margin-top: auto;
}
.barcode-container svg {
width: 1.8in;
height: 0.9in;
}
.machine-number {
font-size: 14pt;
font-weight: bold;
font-family: monospace;
margin-top: -0.1in;
color: #000;
}
.print-btn {
display: block;
margin: 20px auto;
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
}
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: #666;
}
@media print {
.print-btn { display: none; }
.badge-container { border: none; margin: 0; }
}
</style>

View File

@@ -0,0 +1,291 @@
<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>

View File

@@ -0,0 +1,169 @@
<template>
<div>
<div class="no-print">
<button class="print-btn" :disabled="!printer" @click="print">Print QR Code</button>
<label>Position:
<select class="position-select" v-model="position">
<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="loading" class="loading-msg">Loading...</div>
<div v-else-if="printer" class="print-sheet">
<div
v-for="pos in 6"
:key="pos"
class="label"
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
>
<template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
<div class="qr-container">
<canvas ref="qrCanvas"></canvas>
</div>
<div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div>
<div class="info-inner">
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="error-msg">Printer not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '../../api'
import QRCode from 'qrcode'
const route = useRoute()
const loading = ref(true)
const printer = ref(null)
const position = ref('1')
const qrCanvas = ref(null)
const ipAddress = computed(() => {
if (!printer.value?.communications?.length) return null
const primary = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
return primary?.ipaddress || primary?.address || null
})
onMounted(async () => {
try {
const response = await printersApi.get(route.params.id)
printer.value = response.data.data
} catch (error) {
console.error('Error loading printer:', error)
} finally {
loading.value = false
await nextTick()
generateQR()
}
})
watch(position, async () => {
await nextTick()
generateQR()
})
function generateQR() {
const canvas = Array.isArray(qrCanvas.value) ? qrCanvas.value[0] : qrCanvas.value
if (!canvas || !printer.value) return
const qrUrl = `${window.location.origin}/printers/${printer.value.printer?.printerid || printer.value.assetid}`
QRCode.toCanvas(canvas, qrUrl, {
width: 144,
margin: 0,
errorCorrectionLevel: 'M'
}).catch(err => console.error('QR error:', err))
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; text-align: center; padding: 20px; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
margin: 5px;
}
.print-btn:disabled { background: #ccc; cursor: not-allowed; }
.position-select { padding: 8px; font-size: 14px; margin-left: 10px; }
.loading-msg, .error-msg {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
color: #666;
}
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
}
.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 #667eea; }
.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; }
@media print {
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.label { border: none !important; }
.label.inactive { visibility: hidden; }
}
</style>

View File

@@ -0,0 +1,366 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print USB Barcode Labels</h3>
<p>Select USB devices to print (72 labels per page - 6 ULINE labels x 12 mini-labels each, cut after printing):</p>
<div v-if="loadingDevices" class="loading-msg">Loading USB devices...</div>
<div v-else-if="devices.length === 0" class="loading-msg">No USB devices found</div>
<div v-else class="usb-grid">
<div
v-for="device in devices"
:key="device.machineid"
class="usb-item"
:class="{ selected: isSelected(device) }"
@click="toggleDevice(device)"
>
<input type="checkbox" :checked="isSelected(device)" @click.stop />
<label>
<strong><code>{{ device.serialnumber || '-' }}</code></strong>
<div class="alias">{{ device.alias || device.machinenumber || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedDevices.length }}</span> USB devices
(<span class="pages">{{ pageCount }}</span> pages)
<label style="margin-left: 20px;">Start at cell:
<select v-model="startCell" style="padding: 5px; font-size: 14px;">
<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>
<button class="print-btn" :disabled="selectedDevices.length === 0" @click="print">Print Labels</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 sheetPages" :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="(miniItem, miniIdx) in page[cellNum - 1].items"
:key="miniIdx"
class="mini-label"
:class="miniItem ? 'filled' : 'empty'"
>
<template v-if="miniItem">
<div class="barcode-container">
<svg :ref="el => setBarcodeRef(el, pageIdx, cellNum, miniIdx)"></svg>
</div>
<div class="serial-text">{{ miniItem.serialnumber }}</div>
</template>
</div>
</div>
<div v-else class="empty-cell-text">Empty</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { usbApi } from '../../api'
import JsBarcode from 'jsbarcode'
const MINI_LABELS_PER_CELL = 12
const CELLS_PER_PAGE = 6
const devices = ref([])
const selectedDevices = ref([])
const loadingDevices = ref(true)
const startCell = ref('1')
const barcodeRefs = ref({})
const pageCount = computed(() => {
if (selectedDevices.value.length === 0) return 0
const skippedCells = parseInt(startCell.value) - 1
const numCells = Math.ceil(selectedDevices.value.length / MINI_LABELS_PER_CELL)
const totalCellsNeeded = numCells + skippedCells
return Math.ceil(totalCellsNeeded / CELLS_PER_PAGE)
})
const sheetPages = computed(() => {
const result = []
if (selectedDevices.value.length === 0) return result
const startCellNum = parseInt(startCell.value)
let usbIdx = 0
for (let page = 0; page < pageCount.value; page++) {
const cells = []
for (let cellNum = 1; cellNum <= CELLS_PER_PAGE; cellNum++) {
const skipThisCell = page === 0 && cellNum < startCellNum
const hasContent = !skipThisCell && usbIdx < selectedDevices.value.length
const items = []
if (hasContent) {
for (let mini = 0; mini < MINI_LABELS_PER_CELL; mini++) {
if (usbIdx < selectedDevices.value.length) {
items.push(selectedDevices.value[usbIdx])
usbIdx++
} else {
items.push(null)
}
}
}
cells.push({ hasContent, items })
}
result.push(cells)
}
return result
})
onMounted(async () => {
try {
const response = await usbApi.list({ perpage: 500 })
devices.value = response.data.data || []
} catch (error) {
console.error('Error loading USB devices:', error)
} finally {
loadingDevices.value = false
}
})
watch([selectedDevices, startCell], async () => {
await nextTick()
generateBarcodes()
}, { deep: true })
function setBarcodeRef(el, pageIdx, cellNum, miniIdx) {
if (el) {
barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`] = el
}
}
function generateBarcodes() {
sheetPages.value.forEach((page, pageIdx) => {
page.forEach((cell, cellIdx) => {
if (!cell.hasContent) return
const cellNum = cellIdx + 1
cell.items.forEach((item, miniIdx) => {
if (!item) return
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
if (!el) return
try {
JsBarcode(el, item.serialnumber, {
format: 'CODE128',
width: 1,
height: 22,
displayValue: false,
margin: 0,
background: 'transparent'
})
} catch (e) {
console.error('Barcode error:', item.serialnumber, e)
}
})
})
})
}
function isSelected(device) {
return selectedDevices.value.some(d => d.machineid === device.machineid)
}
function toggleDevice(device) {
const idx = selectedDevices.value.findIndex(d => d.machineid === device.machineid)
if (idx > -1) {
selectedDevices.value.splice(idx, 1)
} else {
selectedDevices.value.push(device)
}
}
function clearSelection() {
selectedDevices.value = []
}
function selectAll() {
selectedDevices.value = [...devices.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;
}
.usb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
background: #fafafa;
}
.usb-item {
display: flex;
align-items: center;
padding: 8px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.usb-item:hover { background: #f0f0f0; }
.usb-item.selected { background: #e7f1ff; border-color: #667eea; }
.usb-item input { margin-right: 10px; }
.usb-item label { cursor: pointer; flex: 1; }
.usb-item .alias { 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-cell {
width: 3in;
height: 3in;
position: absolute;
box-sizing: border-box;
border: 1px dashed #ccc;
overflow: hidden;
}
.label-cell.has-content { border: 1px solid #667eea; }
.label-cell.empty { background: #fafafa; }
.cell-1 { top: 0.875in; left: 1.1875in; }
.cell-2 { top: 0.875in; left: 4.3125in; }
.cell-3 { top: 4in; left: 1.1875in; }
.cell-4 { top: 4in; left: 4.3125in; }
.cell-5 { top: 7.125in; left: 1.1875in; }
.cell-6 { top: 7.125in; left: 4.3125in; }
.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; }
.barcode-container { text-align: center; line-height: 0; }
.barcode-container svg { 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%;
}
@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-cell { border: none !important; }
.label-cell.empty { visibility: hidden; }
.mini-label { border: 1px dotted #ccc !important; }
.mini-label.empty { visibility: hidden; }
}
</style>