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:
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>
|
||||
Reference in New Issue
Block a user