Two things a second site ran into. The network device form asked for the map position as two raw numbers, so placing a device meant reading coordinates off another screen and typing them in. Machines, PCs and printers have had a "Set Location on Map" picker all along, and the network API already accepted mapx and mapy - only the form was missing. Same picker, same modal. The 3D parts kiosk hardcoded 'WJ' as the prefix shown before the number box, with a comment inviting whoever needed something else to edit the source. That is West Jefferson's gage-lab tag format and nobody else's, so another site's operators were told to expect letters that are not on their labels. It is now printedparts_label_prefix, set in Settings, defaulting to EMPTY - a site that has not set one sees no prefix rather than inheriting another site's convention. West Jefferson sets it to WJ once. The kiosk hides the prefix entirely when unset and falls back to no prefix if the setting cannot be read, because a cosmetic hint must never stop a kiosk working. Not to be confused with printedparts_code_prefix, which mints item codes like 3DP0042 and was already configurable. That is the code we generate; this is the tag already printed on the label.
328 lines
10 KiB
Vue
328 lines
10 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 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 } 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 West Jefferson'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('')
|
|
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 () => {
|
|
// 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
|
|
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),
|
|
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>
|
|
.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-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>
|