ADR-013 Phase 4: frontend staging machinery + relocate printedparts; fix router crash
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.
This commit is contained in:
58
plugins/printedparts/frontend/routes.js
Normal file
58
plugins/printedparts/frontend/routes.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Printedparts plugin frontend routes (ADR-013 Phase 4: self-contained plugin
|
||||
* frontend). Views live beside this file under views/; core imports use the
|
||||
* @/ alias. The stage-frontend step copies this whole frontend/ dir into the
|
||||
* Vite tree and aggregates these routes, so a per-site build that omits
|
||||
* printedparts carries none of this code.
|
||||
*
|
||||
* `default` = AppLayout child routes; `toplevel` = full-screen routes.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'printedparts',
|
||||
name: 'printedparts',
|
||||
component: () => import('./views/PrintedItemsList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/new',
|
||||
name: 'printedparts-new',
|
||||
component: () => import('./views/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id',
|
||||
name: 'printedparts-detail',
|
||||
component: () => import('./views/PrintedItemDetail.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id/edit',
|
||||
name: 'printedparts-edit',
|
||||
component: () => import('./views/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printedparts',
|
||||
name: 'settings-printedparts',
|
||||
component: () => import('./views/PrintedPartsSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' }
|
||||
}
|
||||
]
|
||||
|
||||
export const toplevel = [
|
||||
{
|
||||
// Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad.
|
||||
path: '/parts-kiosk',
|
||||
name: 'parts-kiosk',
|
||||
component: () => import('./views/PartsKiosk.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
// Requires login: lists the whole catalog, printedparts.view-gated at the API.
|
||||
path: '/print/printedparts-labels',
|
||||
name: 'print-printedparts-labels',
|
||||
component: () => import('./views/PrintedPartsLabels.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
}
|
||||
]
|
||||
295
plugins/printedparts/frontend/views/PartsKiosk.vue
Normal file
295
plugins/printedparts/frontend/views/PartsKiosk.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="parts-kiosk" @click="focusWedge">
|
||||
<!-- keyboard-wedge scanners type the code + Enter into this hidden,
|
||||
always-focused input; whichever step is active consumes the scan -->
|
||||
<input ref="wedgeInput" v-model="wedgeBuffer" class="wedge-input"
|
||||
autocomplete="off" @keydown.enter.prevent="onWedgeEnter" />
|
||||
|
||||
<header class="kiosk-header">
|
||||
<h1>3D Printed Parts</h1>
|
||||
<button v-if="step !== 'item'" class="btn btn-secondary" @click="reset">
|
||||
Start over
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
||||
|
||||
<!-- step 1: scan the bin -->
|
||||
<section v-if="step === 'item'" class="kiosk-step">
|
||||
<p class="kiosk-prompt">Scan the barcode on the bin</p>
|
||||
<p class="kiosk-hint">
|
||||
No scanner?
|
||||
<a href="#" @click.prevent="manualEntry = !manualEntry">Type the number</a>
|
||||
</p>
|
||||
<div v-if="manualEntry" class="entry-panel">
|
||||
<div class="entry-display" :class="{ empty: !manualCode }">
|
||||
{{ manualCode || 'label number' }}
|
||||
</div>
|
||||
<TouchKeypad @digit="manualCode += $event"
|
||||
@clear="manualCode = ''"
|
||||
@backspace="manualCode = manualCode.slice(0, -1)" />
|
||||
<button class="btn btn-primary take-button" :disabled="!manualCode"
|
||||
@click="lookupItem(manualCode)">Look up</button>
|
||||
<p class="kiosk-hint">Just the number from the label; letters are
|
||||
added automatically.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- step 2: badge -->
|
||||
<section v-else-if="step === 'badge'" class="kiosk-step">
|
||||
<div class="item-card">
|
||||
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
|
||||
<div>
|
||||
<h2>{{ item.itemname }}</h2>
|
||||
<p class="kiosk-hint">{{ item.itemcode }} - {{ item.quantityonhand }} on hand</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="kiosk-prompt">Scan your badge or tap in your SSO</p>
|
||||
<div class="entry-panel">
|
||||
<div class="entry-display" :class="{ empty: !manualBadge }">
|
||||
{{ manualBadge || 'SSO' }}
|
||||
</div>
|
||||
<TouchKeypad @digit="manualBadge += $event"
|
||||
@clear="manualBadge = ''"
|
||||
@backspace="manualBadge = manualBadge.slice(0, -1)" />
|
||||
<button class="btn btn-primary take-button" :disabled="!manualBadge"
|
||||
@click="acceptBadge(manualBadge)">Next</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- step 3: quantity -->
|
||||
<section v-else-if="step === 'quantity'" class="kiosk-step">
|
||||
<div class="item-card">
|
||||
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
|
||||
<div>
|
||||
<h2>{{ item.itemname }}</h2>
|
||||
<p class="kiosk-hint">{{ item.quantityonhand }} on hand</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="kiosk-prompt">How many are you taking?</p>
|
||||
<div class="entry-panel">
|
||||
<div class="entry-display" :class="{ empty: !quantity }">
|
||||
{{ quantity || '0' }}
|
||||
</div>
|
||||
<TouchKeypad @digit="quantity += $event"
|
||||
@clear="quantity = ''"
|
||||
@backspace="quantity = quantity.slice(0, -1)" />
|
||||
<button class="btn btn-primary take-button" :disabled="!quantity || submitting"
|
||||
@click="submitTake">
|
||||
{{ submitting ? 'Working...' : 'TAKE' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- done -->
|
||||
<section v-else-if="step === 'done'" class="kiosk-step">
|
||||
<p class="kiosk-success">Done - {{ doneMessage }}</p>
|
||||
<p class="kiosk-hint">Starting over in a few seconds...</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { printedpartsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||
|
||||
const step = ref('item')
|
||||
const item = ref(null)
|
||||
const badge = ref('')
|
||||
const quantity = ref('')
|
||||
const error = ref('')
|
||||
const doneMessage = ref('')
|
||||
const submitting = ref(false)
|
||||
const manualEntry = ref(false)
|
||||
const manualCode = ref('')
|
||||
const manualBadge = ref('')
|
||||
const wedgeInput = ref(null)
|
||||
const wedgeBuffer = ref('')
|
||||
let resetTimer = null
|
||||
|
||||
onMounted(focusWedge)
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
|
||||
function focusWedge(event) {
|
||||
// Tapping a visible input/button must keep it - only reclaim focus for
|
||||
// the wedge scanner from dead space.
|
||||
const tag = event?.target?.tagName
|
||||
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|
||||
|| tag === 'BUTTON' || tag === 'A') return
|
||||
wedgeInput.value?.focus()
|
||||
}
|
||||
|
||||
function onWedgeEnter() {
|
||||
const scanned = wedgeBuffer.value.trim()
|
||||
wedgeBuffer.value = ''
|
||||
if (!scanned) return
|
||||
if (step.value === 'item') lookupItem(scanned)
|
||||
else if (step.value === 'badge') acceptBadge(scanned)
|
||||
}
|
||||
|
||||
async function lookupItem(itemcode) {
|
||||
error.value = ''
|
||||
if (!itemcode) return
|
||||
try {
|
||||
const response = await printedpartsApi.kioskItem(itemcode.trim())
|
||||
item.value = response.data.data
|
||||
step.value = 'badge'
|
||||
manualEntry.value = false
|
||||
manualCode.value = ''
|
||||
} catch (lookupError) {
|
||||
error.value = lookupError.response?.data?.data?.error?.message ||
|
||||
'No part matches that barcode'
|
||||
}
|
||||
focusWedge()
|
||||
}
|
||||
|
||||
function acceptBadge(value) {
|
||||
error.value = ''
|
||||
const scanned = (value || '').trim()
|
||||
if (!scanned) return
|
||||
badge.value = scanned
|
||||
manualBadge.value = ''
|
||||
step.value = 'quantity'
|
||||
focusWedge()
|
||||
}
|
||||
|
||||
async function submitTake() {
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await printedpartsApi.kioskTake({
|
||||
itemcode: item.value.itemcode,
|
||||
badge: badge.value,
|
||||
quantity: parseInt(quantity.value, 10)
|
||||
})
|
||||
doneMessage.value = response.data.message
|
||||
step.value = 'done'
|
||||
resetTimer = setTimeout(reset, 4000)
|
||||
} catch (takeError) {
|
||||
error.value = takeError.response?.data?.data?.error?.message ||
|
||||
'Could not complete - see the parts team'
|
||||
if (takeError.response?.status === 422) {
|
||||
// badge problem: go back a step so the next scan retries cleanly
|
||||
step.value = 'badge'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
clearTimeout(resetTimer)
|
||||
step.value = 'item'
|
||||
item.value = null
|
||||
badge.value = ''
|
||||
quantity.value = ''
|
||||
error.value = ''
|
||||
doneMessage.value = ''
|
||||
focusWedge()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.parts-kiosk {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.kiosk-header {
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.wedge-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
}
|
||||
.kiosk-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
max-width: 40rem;
|
||||
width: 100%;
|
||||
}
|
||||
.kiosk-prompt { font-size: 1.6rem; font-weight: 600; }
|
||||
.kiosk-hint { color: var(--text-light); }
|
||||
.kiosk-error {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.kiosk-success { font-size: 1.6rem; color: var(--success); font-weight: 600; }
|
||||
.item-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 1rem 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.item-photo {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.entry-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
.entry-display {
|
||||
width: 16.9rem;
|
||||
box-sizing: border-box;
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.entry-display.empty {
|
||||
color: var(--text-light);
|
||||
font-weight: 400;
|
||||
font-size: 1.4rem;
|
||||
line-height: 2.4rem;
|
||||
}
|
||||
.take-button {
|
||||
font-size: 1.4rem;
|
||||
padding: 0.85rem 0;
|
||||
width: 16.9rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.manual-row { display: flex; gap: 0.6rem; }
|
||||
.manual-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
375
plugins/printedparts/frontend/views/PrintedItemDetail.vue
Normal file
375
plugins/printedparts/frontend/views/PrintedItemDetail.vue
Normal file
@@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="item">
|
||||
<div class="hero-card">
|
||||
<img v-if="item.imageurl" :src="withBase(item.imageurl)"
|
||||
:alt="item.itemname" class="hero-image" />
|
||||
<div class="hero-content">
|
||||
<h2 class="hero-title">{{ item.itemname }}</h2>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-secondary">{{ item.itemcode }}</span>
|
||||
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
||||
{{ item.quantityonhand }} on hand
|
||||
</span>
|
||||
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
|
||||
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="openLedger('restock')">
|
||||
Restock
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="openLedger('adjust')">
|
||||
Adjust
|
||||
</button>
|
||||
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
|
||||
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>
|
||||
<button v-if="item.isactive" class="btn btn-danger btn-sm"
|
||||
@click="retireItem">Retire</button>
|
||||
<button v-else class="btn btn-primary btn-sm"
|
||||
@click="restoreItem">Restore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Details</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Item code</span>
|
||||
<span class="info-value">{{ item.itemcode }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="item.gagelabtag">
|
||||
<span class="info-label">Gage lab tag</span>
|
||||
<span class="info-value">{{ item.gagelabtag }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Bin location</span>
|
||||
<span class="info-value">{{ item.binlocation || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Quantity on hand</span>
|
||||
<span class="info-value">{{ item.quantityonhand }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Low-stock threshold</span>
|
||||
<span class="info-value">{{ item.lowstockthreshold }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="item.printnotes">
|
||||
<span class="info-label">Print notes</span>
|
||||
<span class="info-value">{{ item.printnotes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Print files</h3>
|
||||
<div class="file-upload-row">
|
||||
<input ref="fileInput" type="file"
|
||||
accept=".stl,.3mf,.gcode,.gco,.bgcode,.step,.stp,.obj,.amf" />
|
||||
<input v-model="fileNote" type="text" class="form-control"
|
||||
placeholder="What changed? (optional)" />
|
||||
<button class="btn btn-primary btn-sm" :disabled="fileUploading"
|
||||
@click="uploadRevision">
|
||||
{{ fileUploading ? 'Uploading...' : 'Upload revision' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="fileError" class="error-message">{{ fileError }}</div>
|
||||
<ul class="file-revision-list">
|
||||
<li v-for="revision in files" :key="revision.fileid"
|
||||
:class="{ 'current-revision': revision === files[0] }">
|
||||
<div class="file-main">
|
||||
<a :href="withBase(`/api/printedparts/files/${revision.fileid}/download`)"
|
||||
class="file-name">
|
||||
{{ revision.filename }}
|
||||
</a>
|
||||
<span class="badge badge-secondary">rev {{ revision.revision }}</span>
|
||||
<span v-if="revision === files[0]" class="badge badge-success">current</span>
|
||||
</div>
|
||||
<div class="file-meta">
|
||||
{{ formatSize(revision.filesize) }} -
|
||||
{{ revision.uploadedby }} -
|
||||
{{ formatDate(revision.uploadeddate) }}
|
||||
<span v-if="revision.uploadnote"> - {{ revision.uploadnote }}</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm file-delete"
|
||||
@click="removeRevision(revision)">Delete</button>
|
||||
</li>
|
||||
<li v-if="files.length === 0" class="empty-state">
|
||||
No print file uploaded yet
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Recent transactions</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Type</th>
|
||||
<th>Qty</th>
|
||||
<th>Who</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="transaction in item.recenttransactions"
|
||||
:key="transaction.transactionid">
|
||||
<td>{{ formatDate(transaction.transactiondate) }}</td>
|
||||
<td>{{ transaction.transactiontype }}</td>
|
||||
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
|
||||
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
|
||||
</td>
|
||||
<td>{{ transaction.employeename || transaction.employeesso }}</td>
|
||||
<td>{{ transaction.reason || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!item.recenttransactions?.length">
|
||||
<td colspan="5" class="empty-state">No transactions yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audit-footer">
|
||||
Created {{ formatDate(item.createddate) }} -
|
||||
Modified {{ formatDate(item.modifieddate) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">Item not found</div>
|
||||
|
||||
<Modal v-model="ledgerOpen" :title="ledgerMode === 'restock' ? 'Restock' : 'Adjust count'">
|
||||
<div v-if="ledgerError" class="error-message">{{ ledgerError }}</div>
|
||||
<div class="form-group">
|
||||
<label>{{ ledgerMode === 'restock' ? 'Quantity printed' : 'Change (+/-)' }}</label>
|
||||
<input v-model.number="ledgerQuantity" type="number" class="form-control" />
|
||||
</div>
|
||||
<div v-if="ledgerMode === 'adjust'" class="form-group">
|
||||
<label>Reason *</label>
|
||||
<input v-model="ledgerReason" type="text" class="form-control"
|
||||
placeholder="e.g., damaged parts scrapped, recount" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Your badge / SSO *</label>
|
||||
<input v-model="ledgerBadge" type="text" class="form-control"
|
||||
placeholder="Scan badge or type SSO" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-primary" :disabled="ledgerSaving" @click="submitLedger">
|
||||
{{ ledgerSaving ? 'Saving...' : 'Submit' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="ledgerOpen = false">Cancel</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { printedpartsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const item = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printedpartsApi.get(route.params.id)
|
||||
item.value = response.data.data
|
||||
await loadFiles()
|
||||
} catch (loadError) {
|
||||
console.error('Error loading printed item:', loadError)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const files = ref([])
|
||||
const fileInput = ref(null)
|
||||
const fileNote = ref('')
|
||||
const fileUploading = ref(false)
|
||||
const fileError = ref('')
|
||||
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const response = await printedpartsApi.listFiles(route.params.id)
|
||||
files.value = response.data.data || []
|
||||
} catch (filesError) {
|
||||
console.error('Error loading files:', filesError)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadRevision() {
|
||||
const file = fileInput.value?.files?.[0]
|
||||
if (!file) { fileError.value = 'Choose a file first'; return }
|
||||
fileUploading.value = true
|
||||
fileError.value = ''
|
||||
try {
|
||||
await printedpartsApi.uploadFile(route.params.id, file, fileNote.value)
|
||||
fileNote.value = ''
|
||||
fileInput.value.value = ''
|
||||
await loadFiles()
|
||||
} catch (uploadError) {
|
||||
fileError.value =
|
||||
uploadError.response?.data?.data?.error?.message || 'Upload failed'
|
||||
} finally {
|
||||
fileUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRevision(revision) {
|
||||
if (!window.confirm(
|
||||
`Delete revision ${revision.revision} (${revision.filename})?`)) return
|
||||
try {
|
||||
await printedpartsApi.removeFile(revision.fileid)
|
||||
await loadFiles()
|
||||
} catch (removeError) {
|
||||
fileError.value = 'Delete failed'
|
||||
console.error(removeError)
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes && bytes !== 0) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const ledgerOpen = ref(false)
|
||||
const ledgerMode = ref('restock')
|
||||
const ledgerQuantity = ref(null)
|
||||
const ledgerReason = ref('')
|
||||
const ledgerBadge = ref('')
|
||||
const ledgerSaving = ref(false)
|
||||
const ledgerError = ref('')
|
||||
|
||||
function openLedger(mode) {
|
||||
ledgerMode.value = mode
|
||||
ledgerQuantity.value = null
|
||||
ledgerReason.value = ''
|
||||
ledgerBadge.value = ''
|
||||
ledgerError.value = ''
|
||||
ledgerOpen.value = true
|
||||
}
|
||||
|
||||
async function submitLedger() {
|
||||
ledgerSaving.value = true
|
||||
ledgerError.value = ''
|
||||
try {
|
||||
if (ledgerMode.value === 'restock') {
|
||||
await printedpartsApi.restock(item.value.printeditemid, {
|
||||
quantity: ledgerQuantity.value, badge: ledgerBadge.value
|
||||
})
|
||||
} else {
|
||||
await printedpartsApi.adjust(item.value.printeditemid, {
|
||||
quantitychange: ledgerQuantity.value,
|
||||
reason: ledgerReason.value,
|
||||
badge: ledgerBadge.value
|
||||
})
|
||||
}
|
||||
ledgerOpen.value = false
|
||||
const response = await printedpartsApi.get(item.value.printeditemid)
|
||||
item.value = response.data.data
|
||||
} catch (submitError) {
|
||||
ledgerError.value =
|
||||
submitError.response?.data?.data?.error?.message ||
|
||||
submitError.response?.data?.error?.message || 'Submit failed'
|
||||
} finally {
|
||||
ledgerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retireItem() {
|
||||
if (!window.confirm(
|
||||
`Retire ${item.value.itemname}? It leaves the storefront and kiosk; `
|
||||
+ 'history and the bin label stay, and it can be restored later.')) return
|
||||
try {
|
||||
await printedpartsApi.remove(item.value.printeditemid)
|
||||
const response = await printedpartsApi.get(item.value.printeditemid)
|
||||
item.value = response.data.data
|
||||
} catch (retireError) {
|
||||
console.error('Retire failed:', retireError)
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreItem() {
|
||||
try {
|
||||
const response = await printedpartsApi.restore(item.value.printeditemid)
|
||||
item.value = response.data.data
|
||||
} catch (restoreError) {
|
||||
console.error('Restore failed:', restoreError)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-actions { margin-top: 0.75rem; }
|
||||
.qty-out { color: var(--danger); }
|
||||
.file-upload-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.file-revision-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.file-revision-list li {
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.6rem 5.5rem 0.6rem 0.8rem;
|
||||
}
|
||||
.file-revision-list li.current-revision { border-color: var(--primary); }
|
||||
.file-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.file-meta {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.file-delete {
|
||||
position: absolute;
|
||||
top: 0.55rem;
|
||||
right: 0.6rem;
|
||||
}
|
||||
.qty-in { color: var(--success); }
|
||||
</style>
|
||||
175
plugins/printedparts/frontend/views/PrintedItemForm.vue
Normal file
175
plugins/printedparts/frontend/views/PrintedItemForm.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Part' : 'Add Part' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<form @submit.prevent="save">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input v-model="form.itemname" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bin location</label>
|
||||
<input v-model="form.binlocation" type="text" class="form-control"
|
||||
placeholder="e.g., Bin A3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input v-model="form.itemdescription" type="text" class="form-control"
|
||||
maxlength="500" placeholder="Brief description shown on the storefront" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Low-stock threshold</label>
|
||||
<input v-model.number="form.lowstockthreshold" type="number" min="0"
|
||||
class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Gage lab asset tag</label>
|
||||
<input v-model="form.gagelabtag" type="text" class="form-control"
|
||||
placeholder="e.g. WJRP0117" />
|
||||
<p class="form-hint">
|
||||
Assigned by the gage lab; optional. The kiosk finds parts by
|
||||
this tag or by the internal code.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Print notes</label>
|
||||
<textarea v-model="form.printnotes" class="form-control" rows="3"
|
||||
placeholder="Material, print time, slicer file path"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="isEdit" class="form-group">
|
||||
<label>Photo</label>
|
||||
<div class="image-row">
|
||||
<img v-if="imageurl" :src="withBase(imageurl)" class="image-preview" />
|
||||
<input type="file" accept="image/*" @change="onImagePicked" />
|
||||
<button v-if="imageurl" type="button" class="btn btn-secondary btn-sm"
|
||||
@click="removeImage">Remove photo</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="form-hint">Save first, then add a photo from the edit page.</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<router-link :to="cancelTarget" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { printedpartsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const cancelTarget = computed(() =>
|
||||
isEdit.value ? `/printedparts/${route.params.id}` : '/printedparts')
|
||||
|
||||
const form = ref({
|
||||
itemname: '',
|
||||
gagelabtag: '',
|
||||
itemdescription: '',
|
||||
lowstockthreshold: null,
|
||||
binlocation: '',
|
||||
printnotes: ''
|
||||
})
|
||||
const imageurl = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) return
|
||||
try {
|
||||
const response = await printedpartsApi.get(route.params.id)
|
||||
const item = response.data.data
|
||||
for (const key of Object.keys(form.value)) {
|
||||
form.value[key] = item[key]
|
||||
}
|
||||
imageurl.value = item.imageurl
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load the item'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = { ...form.value }
|
||||
if (payload.lowstockthreshold === null || payload.lowstockthreshold === '') {
|
||||
delete payload.lowstockthreshold
|
||||
}
|
||||
if (!payload.gagelabtag) payload.gagelabtag = ''
|
||||
let printeditemid
|
||||
if (isEdit.value) {
|
||||
await printedpartsApi.update(route.params.id, payload)
|
||||
printeditemid = route.params.id
|
||||
} else {
|
||||
const response = await printedpartsApi.create(payload)
|
||||
printeditemid = response.data.data.printeditemid
|
||||
}
|
||||
router.push(`/printedparts/${printeditemid}`)
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onImagePicked(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const response = await printedpartsApi.uploadImage(route.params.id, file)
|
||||
imageurl.value = response.data.data.imageurl
|
||||
} catch (uploadError) {
|
||||
error.value = uploadError.response?.data?.error?.message || 'Image upload failed'
|
||||
}
|
||||
}
|
||||
|
||||
async function removeImage() {
|
||||
try {
|
||||
await printedpartsApi.deleteImage(route.params.id)
|
||||
imageurl.value = null
|
||||
} catch (deleteError) {
|
||||
error.value = 'Could not remove the image'
|
||||
console.error(deleteError)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.image-preview {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.form-hint { color: var(--text-light); }
|
||||
</style>
|
||||
156
plugins/printedparts/frontend/views/PrintedItemsList.vue
Normal file
156
plugins/printedparts/frontend/views/PrintedItemsList.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>3D Printed Parts</h2>
|
||||
<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 class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search code, name, description, bin..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<label class="lowstock-filter">
|
||||
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
|
||||
Low stock only
|
||||
</label>
|
||||
<label class="lowstock-filter">
|
||||
<input v-model="includeRetired" type="checkbox" @change="loadItems" />
|
||||
Include retired
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Quantity</th>
|
||||
<th>Bin</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.printeditemid"
|
||||
class="clickable-row"
|
||||
@click="$router.push(`/printedparts/${item.printeditemid}`)"
|
||||
>
|
||||
<td class="thumb-cell">
|
||||
<img
|
||||
v-if="item.imageurl"
|
||||
:src="withBase(item.imageurl)"
|
||||
:alt="item.itemname"
|
||||
class="item-thumb"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ item.itemcode || '-' }}</td>
|
||||
<td>
|
||||
{{ item.itemname }}
|
||||
<span v-if="!item.isactive" class="badge badge-secondary">Retired</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
||||
{{ item.quantityonhand }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.binlocation || '-' }}</td>
|
||||
<td class="truncate-cell">{{ item.itemdescription || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="6" class="empty-state">No printed parts found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
@change="setPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { printedpartsApi } from '@/api'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const lowstockOnly = ref(false)
|
||||
const includeRetired = ref(false)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
if (lowstockOnly.value) params.lowstock = 'true'
|
||||
if (includeRetired.value) params.active = 'false'
|
||||
const response = await printedpartsApi.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading printed parts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => setSearch(search.value), 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-thumb {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.thumb-cell { width: 3rem; }
|
||||
.truncate-cell {
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.lowstock-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
207
plugins/printedparts/frontend/views/PrintedPartsLabels.vue
Normal file
207
plugins/printedparts/frontend/views/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>
|
||||
177
plugins/printedparts/frontend/views/PrintedPartsSettings.vue
Normal file
177
plugins/printedparts/frontend/views/PrintedPartsSettings.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>3D Printed Parts</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Item code prefix</label>
|
||||
<input v-model="values.printedparts_code_prefix" type="text"
|
||||
class="form-control" maxlength="8" />
|
||||
<p class="field-hint">
|
||||
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042.
|
||||
Changing it does not rename existing items.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Default low-stock threshold</label>
|
||||
<input v-model.number="values.printedparts_default_threshold"
|
||||
type="number" min="0" class="form-control" />
|
||||
<p class="field-hint">Seed value for new items; each item can override.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Unknown badge at the kiosk</label>
|
||||
<select v-model="values.printedparts_unknown_badge" class="form-control">
|
||||
<option value="deny">Deny - refuse badges with no directory match</option>
|
||||
<option value="allow">Allow - record the SSO with no name</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert shopdb users</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
|
||||
<input type="checkbox" :value="String(candidate.userid)"
|
||||
v-model="selectedUserids" />
|
||||
<span>{{ candidate.username }}</span>
|
||||
<span class="user-email">{{ candidate.email }}</span>
|
||||
</label>
|
||||
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Selected users receive low-stock alerts at their account email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Alert roles</label>
|
||||
<div class="user-picker">
|
||||
<label v-for="role in roles" :key="role.roleid" class="user-row">
|
||||
<input type="checkbox" :value="String(role.roleid)"
|
||||
v-model="selectedRoleids" />
|
||||
<span>{{ role.rolename }}</span>
|
||||
<span class="user-email">{{ role.description }}</span>
|
||||
</label>
|
||||
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Every active member of a selected role receives low-stock alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Additional alert emails</label>
|
||||
<input v-model="values.printedparts_alert_email" type="text"
|
||||
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
|
||||
<p class="field-hint">
|
||||
Comma-separated. Empty uses the site-wide alert recipients
|
||||
(Settings > System > Email). Alerts fire once when an item
|
||||
crosses its threshold; restocking above re-arms.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi } from '@/api'
|
||||
|
||||
const KEYS = [
|
||||
'printedparts_code_prefix',
|
||||
'printedparts_default_threshold',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email',
|
||||
'printedparts_alert_userids',
|
||||
'printedparts_alert_roleids'
|
||||
]
|
||||
|
||||
const values = ref({
|
||||
printedparts_code_prefix: '3DP',
|
||||
printedparts_default_threshold: 5,
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: '',
|
||||
printedparts_alert_userids: '',
|
||||
printedparts_alert_roleids: ''
|
||||
})
|
||||
const users = ref([])
|
||||
const selectedUserids = ref([])
|
||||
const roles = ref([])
|
||||
const selectedRoleids = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'printedparts' })
|
||||
const rows = response.data.data || []
|
||||
for (const row of rows) {
|
||||
if (KEYS.includes(row.key)) values.value[row.key] = row.value
|
||||
}
|
||||
values.value.printedparts_default_threshold =
|
||||
parseInt(values.value.printedparts_default_threshold, 10) || 0
|
||||
selectedUserids.value = (values.value.printedparts_alert_userids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const usersResponse = await usersApi.list()
|
||||
users.value = (usersResponse.data.data || []).filter(
|
||||
candidate => candidate.isactive && candidate.email)
|
||||
selectedRoleids.value = (values.value.printedparts_alert_roleids || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean)
|
||||
const rolesResponse = await usersApi.roles.list()
|
||||
roles.value = rolesResponse.data.data || []
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
values.value.printedparts_alert_userids = selectedUserids.value.join(',')
|
||||
values.value.printedparts_alert_roleids = selectedRoleids.value.join(',')
|
||||
for (const key of KEYS) {
|
||||
await settingsApi.update(key, String(values.value[key] ?? ''))
|
||||
}
|
||||
message.value = 'Settings saved'
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.user-picker {
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-email { color: var(--text-light); font-size: 0.85rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user