The badge step card still showed the internal item code; show the gage lab tag (fallback to item code) to match the label and list. The take still posts the item code as the stable identifier.
302 lines
8.8 KiB
Vue
302 lines
8.8 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 }}</div>
|
|
|
|
<!-- step 1: the bin label -->
|
|
<section v-if="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 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>
|
|
</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>
|
|
</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 class="kiosk-hint">Starting over in a few seconds...</p>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import { printedpartsApi } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import TouchKeypad from '@/components/TouchKeypad.vue'
|
|
|
|
// Fixed tag prefix shown before the label number at the kiosk. Change here if
|
|
// the gage lab tags use a different leading string.
|
|
const LABEL_PREFIX = 'WJ'
|
|
|
|
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 manualCode = ref('')
|
|
const manualBadge = ref('')
|
|
const entryInput = ref(null)
|
|
let resetTimer = null
|
|
|
|
onMounted(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
|
|
try {
|
|
const response = await printedpartsApi.kioskItem(code)
|
|
item.value = response.data.data
|
|
step.value = 'badge'
|
|
manualCode.value = ''
|
|
} catch (lookupError) {
|
|
error.value = lookupError.response?.data?.data?.error?.message ||
|
|
'No part matches that barcode'
|
|
}
|
|
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)
|
|
})
|
|
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 = ''
|
|
error.value = ''
|
|
doneMessage.value = ''
|
|
focusEntry()
|
|
}
|
|
</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;
|
|
}
|
|
.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-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>
|