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>
|