Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
+55
-1
@@ -268,6 +268,7 @@ async function loadRecurring() {
|
||||
<button class="ghost" data-pass="${p.id}">Print card</button>
|
||||
<button class="ghost" data-pin="${p.id}">New PIN</button>
|
||||
<button class="ghost" data-edit-freq="${p.id}">Edit</button>
|
||||
<button class="ghost danger" data-remove-freq="${p.id}">Remove</button>
|
||||
</td>
|
||||
</tr>`
|
||||
),
|
||||
@@ -289,6 +290,15 @@ async function loadRecurring() {
|
||||
openRecurringModal(await api(`/frequent/${btn.dataset.editFreq}`));
|
||||
})
|
||||
);
|
||||
$$('[data-remove-freq]').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
confirmRemoveFrequent(await api(`/frequent/${btn.dataset.removeFreq}`));
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function hostOptions(selectedId) {
|
||||
@@ -316,18 +326,25 @@ function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false, onOpen, onClose } = {}) {
|
||||
function openModal(
|
||||
title,
|
||||
bodyHtml,
|
||||
onSave,
|
||||
{ saveLabel = 'Save', hideCancel = false, destructive = false, onOpen, onClose } = {}
|
||||
) {
|
||||
$('#modal-title').textContent = title;
|
||||
$('#modal-body').innerHTML = bodyHtml;
|
||||
$('#modal-save').textContent = saveLabel;
|
||||
$('#modal-cancel').hidden = hideCancel;
|
||||
const modal = $('#modal');
|
||||
modal.classList.toggle('destructive', destructive);
|
||||
modal.returnValue = '';
|
||||
modal.showModal();
|
||||
onOpen?.();
|
||||
modal.onclose = () => {
|
||||
$('#modal-cancel').hidden = false;
|
||||
$('#modal-save').textContent = 'Save';
|
||||
modal.classList.remove('destructive');
|
||||
onClose?.();
|
||||
if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form')));
|
||||
};
|
||||
@@ -494,6 +511,43 @@ function openRecurringModal(person = null) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removing a saved visitor is permanent, so the confirmation spells out what
|
||||
* happens: the record and its PIN go, the visit history stays. Deactivating is
|
||||
* offered alongside for the common case of someone who has simply stopped coming.
|
||||
*/
|
||||
function confirmRemoveFrequent(person) {
|
||||
const name = `${person.firstName} ${person.lastName}`;
|
||||
openModal(
|
||||
`Remove ${name}?`,
|
||||
`${
|
||||
person.onSite
|
||||
? `<p class="notice">${esc(person.firstName)} is signed in right now. Removing the
|
||||
record will not sign them out — their visit stays open and they can still sign out
|
||||
with their last name and mobile number.</p>`
|
||||
: ''
|
||||
}
|
||||
<ul class="plain-list">
|
||||
<li>Their saved record, PIN and photo are deleted for good.</li>
|
||||
<li>Their ${person.visitCount} past ${person.visitCount === 1 ? 'visit stays' : 'visits stay'} in the visit log.</li>
|
||||
<li>${esc(person.phone)}${person.email ? ` and ${esc(person.email)}` : ''} become available for someone else.</li>
|
||||
<li>Any card they are carrying stops working.</li>
|
||||
</ul>
|
||||
<p class="hint">If they might come back, untick <strong>Active</strong> in Edit instead —
|
||||
that keeps the record and their history intact.</p>`,
|
||||
async () => {
|
||||
try {
|
||||
const result = await api(`/frequent/${person.id}?force=1`, { method: 'DELETE' });
|
||||
toast(`${result.name} removed. ${result.visitsKept} past visit(s) kept in the log.`);
|
||||
loadRecurring();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
},
|
||||
{ saveLabel: 'Remove permanently', destructive: true }
|
||||
);
|
||||
}
|
||||
|
||||
function showPin(pin, id) {
|
||||
openModal(
|
||||
'PIN issued',
|
||||
|
||||
+59
-48
@@ -186,12 +186,12 @@ tickClock();
|
||||
/* --------------------------------------------------------------- hosts */
|
||||
|
||||
/**
|
||||
* Two ways to say who you are visiting, because neither suits everyone: a dropdown
|
||||
* for someone who wants to browse a short list, and a filter box for someone who
|
||||
* knows the name and would rather type it. The filter narrows both, so the two
|
||||
* never disagree about what is on offer.
|
||||
* Who you are here to see: type to narrow, then choose from the dropdown. The
|
||||
* dropdown is a native select, so a tablet gives it a proper full-screen picker
|
||||
* with its own scrolling, which handles a long staff list better than a page of
|
||||
* buttons ever did.
|
||||
*/
|
||||
function renderHosts(listEl, searchValue, onPick, selectEl = null) {
|
||||
function renderHosts(selectEl, searchValue, onPick, emptyEl = null) {
|
||||
const term = String(searchValue || '').trim().toLowerCase();
|
||||
const matches = term
|
||||
? hosts.filter(
|
||||
@@ -199,49 +199,48 @@ function renderHosts(listEl, searchValue, onPick, selectEl = null) {
|
||||
)
|
||||
: hosts;
|
||||
|
||||
const choose = (id, name) => {
|
||||
state.hostId = Number(id);
|
||||
state.hostName = name;
|
||||
const label = !hosts.length
|
||||
? 'Nobody has been added yet'
|
||||
: matches.length === hosts.length
|
||||
? `Choose one of ${hosts.length}`
|
||||
: `${matches.length} ${matches.length === 1 ? 'match' : 'matches'} — choose one`;
|
||||
|
||||
selectEl.innerHTML =
|
||||
`<option value="">${escapeHtml(label)}</option>` +
|
||||
matches
|
||||
.map(
|
||||
(h) =>
|
||||
`<option value="${h.id}">${escapeHtml(h.name)}${h.area ? ` — ${escapeHtml(h.area)}` : ''}</option>`
|
||||
)
|
||||
.join('');
|
||||
selectEl.disabled = matches.length === 0;
|
||||
|
||||
selectEl.onchange = () => {
|
||||
const picked = hosts.find((h) => h.id === Number(selectEl.value));
|
||||
if (!picked) return;
|
||||
state.hostId = picked.id;
|
||||
state.hostName = picked.name;
|
||||
onPick();
|
||||
};
|
||||
|
||||
if (selectEl) {
|
||||
const placeholder =
|
||||
matches.length === hosts.length
|
||||
? `${hosts.length} ${hosts.length === 1 ? 'person' : 'people'} — choose a name`
|
||||
: `${matches.length} ${matches.length === 1 ? 'match' : 'matches'}`;
|
||||
selectEl.innerHTML =
|
||||
`<option value="">${escapeHtml(placeholder)}</option>` +
|
||||
matches
|
||||
.map(
|
||||
(h) =>
|
||||
`<option value="${h.id}">${escapeHtml(h.name)}${h.area ? ` — ${escapeHtml(h.area)}` : ''}</option>`
|
||||
)
|
||||
.join('');
|
||||
selectEl.disabled = matches.length === 0;
|
||||
selectEl.onchange = () => {
|
||||
const picked = hosts.find((h) => h.id === Number(selectEl.value));
|
||||
if (picked) choose(picked.id, picked.name);
|
||||
};
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = matches.length > 0;
|
||||
emptyEl.textContent = hosts.length
|
||||
? 'No one matches that. Check the spelling, or ask the front desk.'
|
||||
: 'No one has been added for this site yet. Please see the front desk.';
|
||||
}
|
||||
|
||||
if (!matches.length) {
|
||||
listEl.innerHTML = `<p class="host-empty">No one matches that. Check the spelling, or ask the front desk.</p>`;
|
||||
return;
|
||||
}
|
||||
// Typing a name and pressing enter should just work when only one person is left.
|
||||
selectEl.dataset.only = matches.length === 1 ? String(matches[0].id) : '';
|
||||
}
|
||||
|
||||
listEl.innerHTML = matches
|
||||
.slice(0, 60)
|
||||
.map(
|
||||
(h) =>
|
||||
`<button class="host-option" data-host-id="${h.id}" data-host-name="${escapeHtml(h.name)}">
|
||||
${escapeHtml(h.name)}${h.area ? `<small>${escapeHtml(h.area)}</small>` : ''}
|
||||
</button>`
|
||||
)
|
||||
.join('');
|
||||
listEl.querySelectorAll('[data-host-id]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => choose(btn.dataset.hostId, btn.dataset.hostName));
|
||||
});
|
||||
/** Enter in the filter box picks the person when the filter leaves exactly one. */
|
||||
function pickOnlyMatch(selectEl) {
|
||||
const only = selectEl.dataset.only;
|
||||
if (!only) return false;
|
||||
selectEl.value = only;
|
||||
selectEl.dispatchEvent(new Event('change'));
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
@@ -447,7 +446,7 @@ async function loadSiteContext() {
|
||||
change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site';
|
||||
|
||||
hosts = await api('/api/hosts');
|
||||
renderHosts($('#host-list'), '', () => show('guest-contact'), $('#host-select'));
|
||||
renderHosts($('#host-select'), '', () => show('guest-contact'), $('#host-empty'));
|
||||
}
|
||||
|
||||
$('#change-site').addEventListener('click', chooseSite);
|
||||
@@ -482,8 +481,14 @@ $('[data-next="guest-name"]').addEventListener('click', () => {
|
||||
});
|
||||
|
||||
$('#in-host-search').addEventListener('input', (e) =>
|
||||
renderHosts($('#host-list'), e.target.value, () => show('guest-contact'), $('#host-select'))
|
||||
renderHosts($('#host-select'), e.target.value, () => show('guest-contact'), $('#host-empty'))
|
||||
);
|
||||
$('#in-host-search').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
pickOnlyMatch($('#host-select'));
|
||||
}
|
||||
});
|
||||
|
||||
$('[data-next="guest-contact"]').addEventListener('click', () => {
|
||||
const phone = $('#in-phone').value.trim();
|
||||
@@ -557,10 +562,10 @@ $('#do-freq-auth').addEventListener('click', async () => {
|
||||
$('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
|
||||
$('#in-freq-host-search').value = '';
|
||||
renderHosts(
|
||||
$('#freq-host-list'),
|
||||
$('#freq-host-select'),
|
||||
'',
|
||||
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
|
||||
$('#freq-host-select')
|
||||
$('#freq-host-empty')
|
||||
);
|
||||
show('freq-host');
|
||||
} catch (err) {
|
||||
@@ -573,12 +578,18 @@ $('#do-freq-auth').addEventListener('click', async () => {
|
||||
|
||||
$('#in-freq-host-search').addEventListener('input', (e) =>
|
||||
renderHosts(
|
||||
$('#freq-host-list'),
|
||||
$('#freq-host-select'),
|
||||
e.target.value,
|
||||
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
|
||||
$('#freq-host-select')
|
||||
$('#freq-host-empty')
|
||||
)
|
||||
);
|
||||
$('#in-freq-host-search').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
pickOnlyMatch($('#freq-host-select'));
|
||||
}
|
||||
});
|
||||
|
||||
/* sign out */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user