The last two kiosk findings. Keypad entry collected EVERY digit in an item's code, and the shipped prefix contains one: '3DP0004' collapsed to 30004, so an operator typing the 4 printed on the label was told no part matches that barcode and had to work out that the leading 3 belonged to the prefix. The number on a label is the number at the end, so the trailing digit run is what is compared now. An ambiguous number is still refused rather than guessed - taking from the wrong bin is worse than being asked to scan the label. A parts kiosk that had never been provisioned drew its normal "Scan the barcode on the bin" screen and answered "Not found" to every scan, which reads as "we do not stock that part" rather than "this screen was never set up". The server cannot say which: the routes answer a bare 404 on purpose, so a caller without the device token does not learn the door exists, and that stays. But the BROWSER knows whether it holds a token, and saying so discloses nothing - an unprovisioned screen now says it is not set up and how to fix it. For the case the client genuinely cannot distinguish - kiosk switched off versus a part that is not stocked, both a 404 by design - three misses in a row add a line suggesting the kiosk may have been switched off. Truthful without the server having to leak anything. Three tests, verified by planting the all-digits match back. Scope: product
395 lines
13 KiB
Vue
395 lines
13 KiB
Vue
<template>
|
|
<div class="parts-kiosk" @click="focusEntry">
|
|
<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 }}
|
|
<p v-if="misses >= 3" class="kiosk-hint">
|
|
Several scans in a row have found nothing. The kiosk may have been
|
|
switched off, or this screen's setup link may have been replaced - ask
|
|
an administrator to check Settings > 3D Printed Parts.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- This screen was never provisioned. The SERVER cannot say so - the
|
|
routes answer a bare 404 on purpose, so a caller without the token
|
|
does not learn the door exists - but the browser knows whether it
|
|
holds one, and that is not a disclosure. Without this the kiosk drew
|
|
its normal scan prompt and answered "Not found" to every scan, which
|
|
reads as "we do not stock that part". -->
|
|
<section v-if="!provisioned" class="kiosk-step kiosk-unprovisioned">
|
|
<p class="kiosk-prompt">This screen is not set up yet</p>
|
|
<p class="kiosk-hint">
|
|
An administrator opens it once using the kiosk link from
|
|
Settings > 3D Printed Parts. The screen remembers it after that.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- step 1: the bin label -->
|
|
<section v-if="provisioned && step === 'item'" class="kiosk-step">
|
|
<p class="kiosk-prompt">Scan the barcode on the bin</p>
|
|
<!-- one visible field: keyboard-wedge scanners, a plugged-in keyboard or
|
|
numpad, and the on-screen keypad all type into it. -->
|
|
<div class="entry-panel">
|
|
<!-- The tag prefix is shown as a fixed addon so operators type only
|
|
the number part off the label. -->
|
|
<div class="entry-input-group">
|
|
<span v-if="LABEL_PREFIX" class="entry-prefix">{{ LABEL_PREFIX }}</span>
|
|
<input ref="entryInput" v-model="manualCode" class="entry-input has-prefix"
|
|
inputmode="none" autocomplete="off" placeholder="number"
|
|
@keydown.enter.prevent="lookupItem(manualCode)" />
|
|
</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">Scan the label, type on a keyboard, or tap it in;
|
|
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.gagelabtag || item.itemcode }} - {{ item.quantityonhand }} on hand</p>
|
|
<p v-if="scannedRevision != null" class="kiosk-rev">Revision {{ scannedRevision }}</p>
|
|
</div>
|
|
</div>
|
|
<p class="kiosk-prompt">Scan your badge or tap in your SSO</p>
|
|
<div class="entry-panel">
|
|
<input ref="entryInput" v-model="manualBadge" class="entry-input"
|
|
inputmode="none" autocomplete="off" placeholder="SSO"
|
|
@keydown.enter.prevent="acceptBadge(manualBadge)" />
|
|
<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>
|
|
<p v-if="scannedRevision != null" class="kiosk-rev">Revision {{ scannedRevision }}</p>
|
|
</div>
|
|
</div>
|
|
<p class="kiosk-prompt">How many are you taking?</p>
|
|
<div class="entry-panel">
|
|
<input ref="entryInput" v-model="quantity" class="entry-input"
|
|
inputmode="none" autocomplete="off" placeholder="0"
|
|
@keydown.enter.prevent="submitTake" />
|
|
<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 v-if="scannedRevision != null" class="kiosk-hint">Checked out revision {{ scannedRevision }}</p>
|
|
<p class="kiosk-hint">Starting over in a few seconds...</p>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import { printedpartsApi, settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import TouchKeypad from '@/components/TouchKeypad.vue'
|
|
|
|
// Leading text on the physical labels, shown before the number box so operators
|
|
// type only the digits. It was hardcoded to 'WJ', which is one site's tag
|
|
// format and nobody else's - on another site's kiosk it told operators to expect
|
|
// letters that are not on their labels. Display only: the lookup sends whatever
|
|
// was typed, unprefixed.
|
|
const LABEL_PREFIX = ref('')
|
|
|
|
const step = ref('item')
|
|
const item = ref(null)
|
|
const badge = ref('')
|
|
const quantity = ref('')
|
|
const error = ref('')
|
|
// Whether THIS browser holds the kiosk's device token. The server will not say
|
|
// (see the template), and it does not need to: an unprovisioned screen should
|
|
// say so rather than pretending to be a working kiosk with an empty catalog.
|
|
const provisioned = ref(true)
|
|
// Consecutive lookups that found nothing. Every scan missing usually means the
|
|
// kiosk was switched off, not that no bin is known.
|
|
const misses = ref(0)
|
|
const doneMessage = ref('')
|
|
const submitting = ref(false)
|
|
const manualCode = ref('')
|
|
const manualBadge = ref('')
|
|
const scannedRevision = ref(null)
|
|
const entryInput = ref(null)
|
|
let resetTimer = null
|
|
|
|
onMounted(async () => {
|
|
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
|
|
try {
|
|
provisioned.value = Boolean(
|
|
localStorage.getItem(KIOSK_TOKEN_KEYS['/printedparts/kiosk/']))
|
|
} catch {
|
|
// A browser refusing localStorage cannot be provisioned at all, and
|
|
// saying so is more use than a scan box that answers nothing.
|
|
provisioned.value = false
|
|
}
|
|
// A kiosk left running for weeks reads this once at load, which is fine: the
|
|
// label format is a property of the site's physical tags, not something that
|
|
// changes during a shift.
|
|
try {
|
|
const response = await settingsApi.get('printedparts_label_prefix')
|
|
LABEL_PREFIX.value = (response.data?.data?.value || '').trim()
|
|
} catch (err) {
|
|
// Never block the kiosk over a cosmetic hint. An unreachable or unseeded
|
|
// setting simply means no prefix is shown.
|
|
LABEL_PREFIX.value = ''
|
|
}
|
|
focusEntry()
|
|
})
|
|
onBeforeUnmount(() => clearTimeout(resetTimer))
|
|
|
|
function focusEntry(event) {
|
|
// Keep focus on the entry input so a wedge scanner or plugged-in keyboard
|
|
// always types into it. Only reclaim focus from dead space - never steal it
|
|
// mid-tap from a button or another control.
|
|
const tag = event?.target?.tagName
|
|
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|
|
|| tag === 'BUTTON' || tag === 'A') return
|
|
nextTick(() => entryInput.value?.focus())
|
|
}
|
|
|
|
async function lookupItem(itemcode) {
|
|
error.value = ''
|
|
const code = (itemcode || '').trim()
|
|
if (!code) return
|
|
// A label QR carries "TAG|rev"; capture the revision, resolve by the TAG part.
|
|
const pipe = code.indexOf('|')
|
|
scannedRevision.value = pipe >= 0 ? (parseInt(code.slice(pipe + 1), 10) || null) : null
|
|
try {
|
|
const response = await printedpartsApi.kioskItem(code)
|
|
item.value = response.data.data
|
|
misses.value = 0
|
|
step.value = 'badge'
|
|
manualCode.value = ''
|
|
} catch (lookupError) {
|
|
error.value = lookupError.response?.data?.data?.error?.message ||
|
|
'No part matches that barcode'
|
|
// The gate and a genuine miss both answer 404, deliberately, so the client
|
|
// cannot tell them apart from one response. What it CAN say is that
|
|
// repeated misses may mean the kiosk was switched off rather than that
|
|
// every bin is unknown.
|
|
misses.value += 1
|
|
}
|
|
focusEntry()
|
|
}
|
|
|
|
function acceptBadge(value) {
|
|
error.value = ''
|
|
const scanned = (value || '').trim()
|
|
if (!scanned) return
|
|
badge.value = scanned
|
|
manualBadge.value = ''
|
|
step.value = 'quantity'
|
|
focusEntry()
|
|
}
|
|
|
|
async function submitTake() {
|
|
if (!quantity.value || submitting.value) return
|
|
submitting.value = true
|
|
error.value = ''
|
|
try {
|
|
const response = await printedpartsApi.kioskTake({
|
|
itemcode: item.value.itemcode,
|
|
badge: badge.value,
|
|
quantity: parseInt(quantity.value, 10),
|
|
revision: scannedRevision.value
|
|
})
|
|
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'
|
|
focusEntry()
|
|
}
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function reset() {
|
|
clearTimeout(resetTimer)
|
|
step.value = 'item'
|
|
item.value = null
|
|
badge.value = ''
|
|
quantity.value = ''
|
|
manualCode.value = ''
|
|
manualBadge.value = ''
|
|
scannedRevision.value = null
|
|
error.value = ''
|
|
doneMessage.value = ''
|
|
focusEntry()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.kiosk-unprovisioned {
|
|
text-align: center;
|
|
}
|
|
.kiosk-hint {
|
|
color: var(--text-light);
|
|
font-size: 1rem;
|
|
margin-top: 0.6rem;
|
|
}
|
|
.parts-kiosk {
|
|
min-height: 100vh;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
padding: 2rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
/* Centered as one block on the wall-mounted display, not hugging the top.
|
|
`safe` so a short screen (or a tall error + item card) scrolls from the
|
|
top instead of clipping the header off the edge, which plain `center`
|
|
does with no way to scroll back up. */
|
|
justify-content: center;
|
|
justify-content: safe center;
|
|
gap: 1.5rem;
|
|
}
|
|
.kiosk-header {
|
|
width: 100%;
|
|
max-width: 40rem;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
/* Title sits centered over the panel; Start over is pulled out of the flow
|
|
so it keeps its top-right corner instead of pushing the title off-center
|
|
on the steps that show it. */
|
|
position: relative;
|
|
}
|
|
.kiosk-header .btn {
|
|
position: absolute;
|
|
right: 0;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
.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-rev { font-size: 1.3rem; font-weight: 700; color: var(--primary); margin-top: 0.25rem; }
|
|
.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-input {
|
|
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);
|
|
color: var(--text);
|
|
outline: none;
|
|
}
|
|
.entry-input:focus { border-color: var(--primary); }
|
|
.entry-input-group {
|
|
display: flex;
|
|
align-items: stretch;
|
|
width: 16.9rem;
|
|
}
|
|
.entry-prefix {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 0.7rem;
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
color: var(--text-light);
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-right: none;
|
|
border-radius: 0.75rem 0 0 0.75rem;
|
|
}
|
|
.entry-input.has-prefix {
|
|
width: auto;
|
|
flex: 1;
|
|
min-width: 0;
|
|
border-radius: 0 0.75rem 0.75rem 0;
|
|
}
|
|
.entry-input::placeholder {
|
|
color: var(--text-light);
|
|
font-weight: 400;
|
|
font-size: 1.4rem;
|
|
}
|
|
.take-button {
|
|
font-size: 1.4rem;
|
|
padding: 0.85rem 0;
|
|
width: 16.9rem;
|
|
border-radius: 0.75rem;
|
|
}
|
|
</style>
|