Add batch ULINE label printing for all asset types
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:
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
501
frontend/src/views/print/AssetLabelBatch.vue
Normal file
501
frontend/src/views/print/AssetLabelBatch.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user