POST /api/reports/email has always taken any {columns, rows} from any caller,
but the Email report button existed on the toner and warranty pages only - not
because those reports are special, but because they happen to be their own
routed pages and the reports page had no button at all. It is now beside Export
CSV for every report the page can build a table from, which is all of them:
plugin reports through their declared columns, core reports through the flat
list they return, and the asset inventory summary by flattening its three
tables into one with the grouping named in a column. Presentation-only fields -
a status colour, a link id - are left out; a column of hex codes is worse than
no column. The shapes live in a module with tests rather than in the view.
The button is now hidden without reports.export on all three pages. Offering
somebody a button that can only answer 403 is worse than not offering it.
Separately, on the USB kiosk a long drive name pushed Take or Return out
through the side of its card, leaving a drive nobody could press. The name is a
flex item and so defaulted to min-width:auto, keeping its full intrinsic width;
it now yields, wraps mid-word for the serial-style names that carry no spaces,
and clamps its description to two lines so one long name cannot make every card
in the row tall. The locker line is never clipped - it is how somebody finds
the drive.
510 lines
17 KiB
Vue
510 lines
17 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" class="drive-desc">{{ drive.device_desc }}</small>
|
|
</span>
|
|
<span class="drive-action return">Return</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="drive-group">
|
|
<div class="drive-group-head">
|
|
<h2>Available</h2>
|
|
<!-- Typed, not tapped: this panel has a keyboard, and a cabinet of
|
|
thirty drives is faster to narrow than to read. -->
|
|
<div v-if="session.available.length" class="drive-filter">
|
|
<input ref="filterInput" v-model="drivefilter"
|
|
class="drive-filter-input"
|
|
autocomplete="off" spellcheck="false"
|
|
placeholder="Find a drive, a locker..." />
|
|
<button v-if="drivefilter" class="drive-filter-clear"
|
|
title="Clear" @click="drivefilter = ''">Clear</button>
|
|
</div>
|
|
</div>
|
|
<p v-if="!session.available.length" class="kiosk-hint">
|
|
No drives are free right now.
|
|
</p>
|
|
<p v-else-if="!availabledrives.length" class="kiosk-hint">
|
|
No free drive matches "{{ drivefilter }}".
|
|
</p>
|
|
<div v-else class="drive-list">
|
|
<button v-for="drive in availabledrives" :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" class="drive-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 class="drive-desc">{{ 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, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import api, { settingsApi } from '@/api'
|
|
import TouchKeypad from '@/components/TouchKeypad.vue'
|
|
import { filterdrives } from '../drivefilter'
|
|
|
|
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())
|
|
}
|
|
|
|
// Narrowing the free drives. The matcher lives in its own module so it can be
|
|
// tested without mounting a kiosk.
|
|
const drivefilter = ref('')
|
|
const filterInput = ref(null)
|
|
|
|
// Focused as the picker opens, so somebody can simply start typing. The badge
|
|
// step owns the keyboard until then.
|
|
function focusFilter () {
|
|
nextTick(() => filterInput.value?.focus())
|
|
}
|
|
|
|
const availabledrives = computed(
|
|
() => filterdrives(session.value.available, drivefilter.value))
|
|
|
|
// 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'
|
|
focusFilter()
|
|
} 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 = ''
|
|
// The next person at the cabinet must not inherit somebody else's filter and
|
|
// conclude there are no drives left.
|
|
drivefilter.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: 72rem;
|
|
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, and it is WIDER than the steps
|
|
that hold a single control - a cabinet of thirty drives is three or four
|
|
columns of one glance rather than a scroll. The steps that are just a keypad
|
|
or a message stay narrow, because stretching one input across a 24 inch panel
|
|
reads as a broken page.
|
|
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-width: 72rem;
|
|
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(--text-light); 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; }
|
|
/* Heading and filter on one line: the filter belongs to the list under it, and
|
|
a row of its own would cost height the lists need. */
|
|
.drive-group-head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.drive-filter { display: flex; align-items: center; gap: 0.5rem; }
|
|
.drive-filter-input {
|
|
/* Touch-sized like everything else here, and wide enough for a locker name
|
|
rather than just a drive number. */
|
|
min-width: 16rem;
|
|
padding: 0.6rem 0.8rem;
|
|
font-size: 1.1rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: var(--bg-card-solid);
|
|
color: var(--text);
|
|
}
|
|
.drive-filter-input:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
}
|
|
.drive-filter-clear {
|
|
padding: 0.6rem 0.9rem;
|
|
font-size: 1rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
/* As many columns as the panel has room for - four on a wall display, one on a
|
|
small screen - 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(16rem, 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; }
|
|
/* The name has to YIELD. A flex item defaults to min-width:auto, so a long
|
|
label kept its intrinsic width and shoved Take or Return out through the side
|
|
of the card - a drive nobody could take. `anywhere` breaks the labels that
|
|
carry no spaces at all, which a serial-style name often does not. */
|
|
.drive-name {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.drive-name small { color: var(--text-light); font-size: 0.85rem; }
|
|
/* Two lines of description is enough to tell two drives apart; more just makes
|
|
one card tall and every card in its row tall with it. The locker line below
|
|
is never clipped - it is how somebody finds the thing. */
|
|
.drive-desc {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
.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>
|