The kiosk was a single column of full-width rows top-aligned on the page, so a cabinet of a dozen drives meant scrolling a wall panel, and the page itself scrolled - which takes the "Your drives" heading off screen and leaves somebody looking at a list with no idea whose drives they are. The lists now sit in a grid that fills the width and scroll INSIDE themselves against a bounded height, with the panel centred as one block the way the parts kiosk beside it already is. Two kiosks on the same floor that sit differently on their screens read as two different products. Drive ids sorted as text, which puts USB-10 ahead of USB-3. Compared with a numeric collator so the list runs in the order the labels on the drives read.
416 lines
14 KiB
Vue
416 lines
14 KiB
Vue
<template>
|
|
<div class="usb-kiosk" @click="focusEntry">
|
|
<header class="kiosk-header">
|
|
<h1>USB Drives</h1>
|
|
<button v-if="step !== 'badge'" class="btn btn-secondary" @click="reset">
|
|
Done
|
|
</button>
|
|
</header>
|
|
|
|
<div v-if="disabled" class="kiosk-step">
|
|
<p class="kiosk-prompt">This kiosk is turned off.</p>
|
|
<p class="kiosk-hint">An administrator enables it in Settings under USB.</p>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
|
|
|
<!--
|
|
Badge FIRST. The drives carry no barcode, so there is nothing to present
|
|
and the screen has to offer a list - which means the list has to cost
|
|
something. A badge that resolves to a real person is the same bar taking
|
|
a drive already has to clear.
|
|
-->
|
|
<section v-if="step === 'badge'" class="kiosk-step">
|
|
<p class="kiosk-prompt">Enter your SSO to start</p>
|
|
<div class="entry-panel">
|
|
<input ref="entryInput" v-model="manualBadge" class="entry-input"
|
|
inputmode="none" autocomplete="off" placeholder="SSO"
|
|
@keydown.enter.prevent="startSession(manualBadge)" />
|
|
<TouchKeypad @digit="manualBadge += $event"
|
|
@clear="manualBadge = ''"
|
|
@backspace="manualBadge = manualBadge.slice(0, -1)" />
|
|
<button class="btn btn-primary act-button"
|
|
:disabled="!manualBadge || busy"
|
|
@click="startSession(manualBadge)">
|
|
{{ busy ? 'Checking...' : 'Next' }}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- the picker -->
|
|
<section v-else-if="step === 'pick'" class="kiosk-step kiosk-wide">
|
|
<p class="kiosk-greeting">{{ session.name }}</p>
|
|
|
|
<div v-if="session.yours.length" class="drive-group drive-group-yours">
|
|
<h2>Your drives</h2>
|
|
<div class="drive-list">
|
|
<button v-for="drive in session.yours" :key="drive.device_id"
|
|
class="drive-row" :disabled="busy"
|
|
@click="act(drive, 'checkin')">
|
|
<span class="drive-name">
|
|
{{ drive.device_id }}
|
|
<small v-if="drive.device_desc">{{ drive.device_desc }}</small>
|
|
</span>
|
|
<span class="drive-action return">Return</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="drive-group">
|
|
<h2>Available</h2>
|
|
<p v-if="!session.available.length" class="kiosk-hint">
|
|
No drives are free right now.
|
|
</p>
|
|
<div v-else class="drive-list">
|
|
<button v-for="drive in session.available" :key="drive.device_id"
|
|
class="drive-row" :disabled="busy"
|
|
@click="act(drive, 'checkout')">
|
|
<span class="drive-name">
|
|
{{ drive.device_id }}
|
|
<small v-if="drive.device_desc">{{ drive.device_desc }}</small>
|
|
<small v-if="drive.locker_location">Locker {{ drive.locker_location }}</small>
|
|
</span>
|
|
<span class="drive-action take">Take</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Other people's drives are behind a tap, not on the main screen: a
|
|
passer-by should not read the whole cabinet's holders off a wall. -->
|
|
<button v-if="session.otherscount" class="btn btn-secondary others-button"
|
|
:disabled="busy" @click="loadOthers">
|
|
Returning a drive for somebody else?
|
|
({{ session.otherscount }} out with others)
|
|
</button>
|
|
</section>
|
|
|
|
<!-- somebody else's drives -->
|
|
<section v-else-if="step === 'others'" class="kiosk-step kiosk-wide">
|
|
<h2>Checked out to somebody else</h2>
|
|
<p class="kiosk-hint">
|
|
Returning one puts it back and records that YOU handed it in.
|
|
</p>
|
|
<div class="drive-list">
|
|
<button v-for="drive in others" :key="drive.device_id"
|
|
class="drive-row" :disabled="busy"
|
|
@click="act(drive, 'checkin', true)">
|
|
<span class="drive-name">
|
|
{{ drive.device_id }}
|
|
<small>{{ drive.holdername || 'somebody else' }}</small>
|
|
</span>
|
|
<span class="drive-action return">Return</span>
|
|
</button>
|
|
</div>
|
|
<button class="btn btn-secondary act-button" @click="step = 'pick'">
|
|
Back
|
|
</button>
|
|
</section>
|
|
|
|
<!-- done -->
|
|
<section v-else-if="step === 'done'" class="kiosk-step">
|
|
<p class="kiosk-done">{{ doneMessage }}</p>
|
|
<button class="btn btn-primary act-button" @click="reset">Finish</button>
|
|
</section>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import api, { settingsApi } from '@/api'
|
|
import TouchKeypad from '@/components/TouchKeypad.vue'
|
|
|
|
const step = ref('badge')
|
|
const badge = ref('')
|
|
const session = ref({ name: '', yours: [], available: [], otherscount: 0 })
|
|
const others = ref([])
|
|
const error = ref('')
|
|
const doneMessage = ref('')
|
|
const busy = ref(false)
|
|
const disabled = ref(false)
|
|
const manualBadge = ref('')
|
|
const entryInput = ref(null)
|
|
let resetTimer = null
|
|
|
|
onMounted(async () => {
|
|
// The kiosk ships off. Reading the flag turns "everything 404s" into a screen
|
|
// that says so - a kiosk merely switched off must not look broken.
|
|
try {
|
|
const response = await settingsApi.get('usb_kiosk_enabled')
|
|
const value = String(response.data?.data?.value ?? '').toLowerCase()
|
|
disabled.value = !(value === 'true' || value === '1')
|
|
} catch (err) {
|
|
// An unreachable setting is not proof it is off; let the API decide.
|
|
disabled.value = false
|
|
}
|
|
focusEntry()
|
|
})
|
|
onBeforeUnmount(() => clearTimeout(resetTimer))
|
|
|
|
function focusEntry (event) {
|
|
const tag = event?.target?.tagName
|
|
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|
|
|| tag === 'BUTTON' || tag === 'A') return
|
|
nextTick(() => entryInput.value?.focus())
|
|
}
|
|
|
|
// Drive ids sort as TEXT everywhere else, which puts -10 before -3 and reads as
|
|
// a jumble on a wall panel. Compare the numeric runs numerically; falls back to
|
|
// plain text for ids that carry no digits.
|
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
|
function byDeviceId (a, b) {
|
|
return collator.compare(a.device_id || '', b.device_id || '')
|
|
}
|
|
|
|
function messageFrom (err, fallback) {
|
|
return err.response?.data?.data?.error?.message || fallback
|
|
}
|
|
|
|
async function startSession (value) {
|
|
const scanned = (value || '').trim()
|
|
if (!scanned || busy.value) return
|
|
error.value = ''
|
|
busy.value = true
|
|
try {
|
|
const response = await api.post('/usb/kiosk/session', { badge: scanned })
|
|
const data = response.data.data
|
|
data.yours = (data.yours || []).sort(byDeviceId)
|
|
data.available = (data.available || []).sort(byDeviceId)
|
|
session.value = data
|
|
badge.value = scanned
|
|
manualBadge.value = ''
|
|
step.value = 'pick'
|
|
} catch (sessionError) {
|
|
error.value = messageFrom(sessionError, 'Could not look that badge up')
|
|
} finally {
|
|
busy.value = false
|
|
focusEntry()
|
|
}
|
|
}
|
|
|
|
async function loadOthers () {
|
|
error.value = ''
|
|
busy.value = true
|
|
try {
|
|
const response = await api.post('/usb/kiosk/others', { badge: badge.value })
|
|
others.value = (response.data.data || []).sort(byDeviceId)
|
|
step.value = 'others'
|
|
} catch (othersError) {
|
|
error.value = messageFrom(othersError, 'Could not load those drives')
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function act (drive, action, forsomeoneelse = false) {
|
|
if (busy.value) return
|
|
error.value = ''
|
|
busy.value = true
|
|
try {
|
|
const body = { device_id: drive.device_id, badge: badge.value }
|
|
if (forsomeoneelse) body.forsomeoneelse = true
|
|
const response = await api.post(`/usb/kiosk/${action}`, body)
|
|
doneMessage.value = response.data?.message
|
|
|| (action === 'checkin' ? 'Drive returned' : 'Drive checked out')
|
|
step.value = 'done'
|
|
// Clear the screen for the next person rather than leaving a name up.
|
|
resetTimer = setTimeout(reset, 8000)
|
|
} catch (actError) {
|
|
error.value = messageFrom(actError, 'That did not go through')
|
|
// The list is now out of date either way - somebody else may have taken it.
|
|
await refresh()
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function refresh () {
|
|
try {
|
|
const response = await api.post('/usb/kiosk/session', { badge: badge.value })
|
|
const data = response.data.data
|
|
data.yours = (data.yours || []).sort(byDeviceId)
|
|
data.available = (data.available || []).sort(byDeviceId)
|
|
session.value = data
|
|
step.value = 'pick'
|
|
} catch (refreshError) {
|
|
reset()
|
|
}
|
|
}
|
|
|
|
function reset () {
|
|
clearTimeout(resetTimer)
|
|
step.value = 'badge'
|
|
badge.value = ''
|
|
session.value = { name: '', yours: [], available: [], otherscount: 0 }
|
|
others.value = []
|
|
error.value = ''
|
|
doneMessage.value = ''
|
|
manualBadge.value = ''
|
|
focusEntry()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Touch-sized throughout: a wall panel, often operated with gloves on.
|
|
Laid out to match the parts kiosk beside it - two kiosks on the same floor
|
|
that sit differently on their screens read as two different products. */
|
|
.usb-kiosk {
|
|
min-height: 100vh;
|
|
padding: 2rem;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
/* Centered as one block on a wall-mounted display rather than hugging the
|
|
top. `safe` so a short screen - or a long drive list - 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 centered over the panel; Done 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-header h1 { margin: 0; font-size: 2rem; }
|
|
.kiosk-step {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
max-width: 40rem;
|
|
width: 100%;
|
|
}
|
|
/* The picker holds lists, so its rows stretch - but it keeps the same width as
|
|
every other step so the panel does not jump when the step changes.
|
|
It is also height-bounded: the lists absorb whatever space is left over and
|
|
scroll inside themselves, so the PAGE never scrolls and the heading saying
|
|
whose drives these are stays on screen. A fixed max-height cannot do this -
|
|
it either clips with room to spare or overflows once both groups are
|
|
present, which is exactly what it did. */
|
|
.kiosk-wide {
|
|
align-items: stretch;
|
|
max-height: calc(100vh - 9rem);
|
|
min-height: 0;
|
|
}
|
|
.kiosk-wide .drive-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
/* Your own drives size to their content - usually one or two - and the
|
|
available list absorbs what is left. Sharing the space evenly clipped a
|
|
single row of "Your drives" in half while the other list had room. */
|
|
.kiosk-wide .drive-group-yours { flex: 0 0 auto; }
|
|
.kiosk-wide .drive-group-yours .drive-list { max-height: 30vh; }
|
|
.kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; }
|
|
.kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; }
|
|
.kiosk-hint { color: var(--textmuted); text-align: center; margin: 0; }
|
|
.kiosk-error {
|
|
padding: 0.9rem 1.1rem;
|
|
border: 1px solid var(--danger, #b3261e);
|
|
border-left-width: 5px;
|
|
border-radius: 4px;
|
|
color: var(--danger, #b3261e);
|
|
font-size: 1.1rem;
|
|
text-align: center;
|
|
}
|
|
.kiosk-done { font-size: 1.6rem; text-align: center; margin: 2rem 0; }
|
|
|
|
.drive-group { width: 100%; }
|
|
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
|
|
/* Two columns once there is room, so a cabinet of a dozen drives is one glance
|
|
rather than a scroll. The list itself scrolls when it has to - the PAGE must
|
|
not, or the person loses the heading telling them whose drives these are. */
|
|
.drive-list {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
|
|
gap: 0.6rem;
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
padding-right: 0.2rem;
|
|
}
|
|
.drive-row {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
padding: 0.9rem 1rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.75rem;
|
|
background: var(--bg-card);
|
|
color: var(--text);
|
|
font-size: 1.05rem;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
.drive-row:disabled { opacity: 0.5; }
|
|
.drive-name { display: flex; flex-direction: column; gap: 0.15rem; }
|
|
.drive-name small { color: var(--textmuted); font-size: 0.85rem; }
|
|
.drive-action {
|
|
flex: 0 0 auto;
|
|
padding: 0.5rem 1.1rem;
|
|
border-radius: 4px;
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
}
|
|
.take { background: var(--primary, #2f6fed); color: #fff; }
|
|
.return { background: var(--warning, #b26a00); color: #fff; }
|
|
.others-button { width: 100%; padding: 0.9rem; margin-top: 0.5rem; }
|
|
|
|
.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::placeholder {
|
|
color: var(--text-light);
|
|
font-weight: 400;
|
|
font-size: 1.4rem;
|
|
}
|
|
.act-button {
|
|
font-size: 1.4rem;
|
|
padding: 0.85rem 0;
|
|
width: 16.9rem;
|
|
border-radius: 0.75rem;
|
|
}
|
|
</style>
|