printedparts stage 8: 1x0.5in bin labels
New public print view at /print/printedparts-labels following the plugin-owned USB label precedent: multi-select with per-item copies, CODE128 of the item code via JsBarcode (a QR at this size is at the edge of scanner tolerance), one label per page on 1in x 0.5in roll stock via a new @page size. The Detail page's Bin Label button preselects its item through ?item=<id>; the list header gains a batch Print Labels button.
This commit is contained in:
@@ -117,6 +117,12 @@ const routes = [
|
|||||||
component: () => import('../views/print/USBLabelBatch.vue'),
|
component: () => import('../views/print/USBLabelBatch.vue'),
|
||||||
meta: { plugin: 'usb' }
|
meta: { plugin: 'usb' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/print/printedparts-labels',
|
||||||
|
name: 'print-printedparts-labels',
|
||||||
|
component: () => import('../views/print/PrintedPartsLabels.vue'),
|
||||||
|
meta: { plugin: 'printedparts' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: AppLayout,
|
component: AppLayout,
|
||||||
|
|||||||
207
frontend/src/views/print/PrintedPartsLabels.vue
Normal file
207
frontend/src/views/print/PrintedPartsLabels.vue
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="no-print">
|
||||||
|
<div class="controls">
|
||||||
|
<h3>Print 3D Parts Bin Labels (1in x 0.5in)</h3>
|
||||||
|
<p>
|
||||||
|
Each label is one page on 1in x 0.5in roll stock: CODE128 barcode of
|
||||||
|
the item code, scannable at the parts kiosk.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-msg">Loading parts...</div>
|
||||||
|
<div v-else-if="items.length === 0" class="loading-msg">No parts found</div>
|
||||||
|
<div v-else class="parts-grid">
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.printeditemid"
|
||||||
|
class="part-item"
|
||||||
|
:class="{ selected: isSelected(item) }"
|
||||||
|
@click="toggleItem(item)"
|
||||||
|
>
|
||||||
|
<input type="checkbox" :checked="isSelected(item)" @click.stop />
|
||||||
|
<label>
|
||||||
|
<strong><code>{{ item.itemcode }}</code></strong>
|
||||||
|
<div class="alias">{{ item.itemname }}</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="selected-count">
|
||||||
|
Selected: <span class="count">{{ selectedItems.length }}</span> labels
|
||||||
|
<label class="copies-label">Copies each:
|
||||||
|
<input v-model.number="copies" type="number" min="1" max="10" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="print-btn" :disabled="selectedItems.length === 0"
|
||||||
|
@click="print">Print Labels</button>
|
||||||
|
<button class="clear-btn" @click="selectedItems = []">Clear All</button>
|
||||||
|
<button class="select-all-btn" @click="selectedItems = [...items]">Select All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="labels-container">
|
||||||
|
<div v-for="(label, index) in printLabels" :key="index" class="bin-label">
|
||||||
|
<svg :ref="element => setBarcodeElement(element, index)" class="bin-barcode"></svg>
|
||||||
|
<div class="bin-code">{{ label.itemcode }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
|
import JsBarcode from 'jsbarcode'
|
||||||
|
import { printedpartsApi } from '../../api'
|
||||||
|
|
||||||
|
const items = ref([])
|
||||||
|
const selectedItems = ref([])
|
||||||
|
const copies = ref(1)
|
||||||
|
const loading = ref(true)
|
||||||
|
const barcodeElements = ref({})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const response = await printedpartsApi.list({ perpage: 500 })
|
||||||
|
items.value = response.data.data || []
|
||||||
|
// ?item=<id> preselects one part (the Detail-page print button)
|
||||||
|
const preselect = new URLSearchParams(window.location.search).get('item')
|
||||||
|
if (preselect) {
|
||||||
|
const match = items.value.find(
|
||||||
|
candidate => String(candidate.printeditemid) === preselect)
|
||||||
|
if (match) selectedItems.value = [match]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading parts:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const printLabels = computed(() => {
|
||||||
|
const labels = []
|
||||||
|
for (const item of selectedItems.value) {
|
||||||
|
for (let copy = 0; copy < Math.max(1, copies.value); copy++) {
|
||||||
|
labels.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
})
|
||||||
|
|
||||||
|
function isSelected(item) {
|
||||||
|
return selectedItems.value.some(
|
||||||
|
candidate => candidate.printeditemid === item.printeditemid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleItem(item) {
|
||||||
|
if (isSelected(item)) {
|
||||||
|
selectedItems.value = selectedItems.value.filter(
|
||||||
|
candidate => candidate.printeditemid !== item.printeditemid)
|
||||||
|
} else {
|
||||||
|
selectedItems.value = [...selectedItems.value, item]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBarcodeElement(element, index) {
|
||||||
|
if (element) barcodeElements.value[index] = element
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(printLabels, async labels => {
|
||||||
|
await nextTick()
|
||||||
|
labels.forEach((label, index) => {
|
||||||
|
const element = barcodeElements.value[index]
|
||||||
|
if (element) {
|
||||||
|
// CODE128 of the short item code fits 1x0.5in with comfortable
|
||||||
|
// scanner tolerance; a QR at this size would be marginal.
|
||||||
|
JsBarcode(element, label.itemcode, {
|
||||||
|
format: 'CODE128',
|
||||||
|
displayValue: false,
|
||||||
|
width: 1.4,
|
||||||
|
height: 26,
|
||||||
|
margin: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
function print() {
|
||||||
|
window.print()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.controls {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 1rem auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.parts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 20rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.part-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.part-item.selected { border-color: var(--primary); }
|
||||||
|
.alias { color: var(--text-light); font-size: 0.85rem; }
|
||||||
|
.selected-count { margin: 0.75rem 0; }
|
||||||
|
.copies-label { margin-left: 1.25rem; }
|
||||||
|
.copies-label input { width: 4rem; padding: 0.25rem; }
|
||||||
|
.print-btn, .clear-btn, .select-all-btn {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.loading-msg { color: var(--text-light); padding: 1rem; }
|
||||||
|
|
||||||
|
/* screen preview of the labels */
|
||||||
|
.labels-container { display: flex; flex-wrap: wrap; gap: 0.4rem; padding: 1rem; }
|
||||||
|
.bin-label {
|
||||||
|
width: 1in;
|
||||||
|
height: 0.5in;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
outline: 1px dashed #bbb;
|
||||||
|
}
|
||||||
|
.bin-barcode { width: 0.92in; height: 0.3in; }
|
||||||
|
.bin-code {
|
||||||
|
font-size: 6.5pt;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #000;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1in x 0.5in roll stock: one label per page */
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none; }
|
||||||
|
.labels-container { display: block; padding: 0; gap: 0; }
|
||||||
|
.bin-label {
|
||||||
|
outline: none;
|
||||||
|
page-break-after: always;
|
||||||
|
break-after: page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
@page { size: 1in 0.5in; margin: 0; }
|
||||||
|
body { margin: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
</button>
|
</button>
|
||||||
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
|
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
|
||||||
class="btn btn-secondary btn-sm">Edit</router-link>
|
class="btn btn-secondary btn-sm">Edit</router-link>
|
||||||
|
<router-link :to="`/print/printedparts-labels?item=${item.printeditemid}`"
|
||||||
|
class="btn btn-secondary btn-sm">Bin Label</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>3D Printed Parts</h2>
|
<h2>3D Printed Parts</h2>
|
||||||
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
|
<div class="header-actions">
|
||||||
|
<router-link to="/print/printedparts-labels" class="btn btn-secondary">
|
||||||
|
Print Labels
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
@@ -131,6 +136,7 @@ function debouncedSearch() {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.header-actions { display: flex; gap: 0.5rem; }
|
||||||
.lowstock-filter {
|
.lowstock-filter {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user