The staging step that makes lean per-site frontend builds possible, plus the
first plugin relocated as the pilot.
- scripts/stage-frontend.mjs: copies each chosen plugin's plugins/<name>/frontend/
into frontend/src/.plugins-staged/<name>/ and codegens routes.gen.js. Plugin
selection via SITE_PLUGINS (comma-separated); empty = all plugins that have a
frontend/ (the full build). Wired as npm predev/prebuild; outputs gitignored.
- Router imports routes.gen.js and merges staged routes with the in-tree
./routes/*.js glob - dual-location during the transition.
- printedparts relocated: its 6 views (list/detail/form/kiosk + the settings and
labels views from the shared dirs) moved into plugins/printedparts/frontend/
views/, core imports rewritten to the @/ alias; routes.js is the self-contained
route module. Its old in-tree route file is removed.
Also fixes a crash the previous commit (37c764b) shipped: slides.js exports only
`toplevel` (its child routes live in core.js), so the router's
flatMap(m => m.default) produced an undefined child and threw
"Cannot read properties of undefined (reading 'path')" at load - the whole SPA
went blank. Guarded with `m.default || []`. (The earlier "print pages are blank"
reading was this crash, not page nature.)
Verified live: /machines renders again; the relocated /printedparts list renders
identically from the staged plugin frontend; SITE_PLUGINS=machines excludes
printedparts from routes.gen. Build (via npm, runs stage) + vitest + naming green.
208 lines
5.6 KiB
Vue
208 lines
5.6 KiB
Vue
<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>
|