Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA

This commit is contained in:
2026-09-01 15:14:46 +10:00
parent a011587d66
commit 9da7e88eb3
11 changed files with 629 additions and 99 deletions
+73 -34
View File
@@ -53,6 +53,7 @@ export function shapeSite(site) {
widthMm: site.badge_width_mm,
heightMm: site.badge_height_mm,
showPhoto: Boolean(site.badge_show_photo),
accent: Boolean(site.badge_accent),
note: site.badge_note,
},
};
@@ -70,14 +71,43 @@ const esc = (value) =>
* on load so a kiosk can drop it into a hidden iframe and get one badge out.
*/
export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {}) {
const width = Number(site.badge_width_mm) || 86;
const height = Number(site.badge_height_mm) || 54;
const width = Number(site.badge_width_mm) || 62;
const height = Number(site.badge_height_mm) || 100;
const showPhoto = Boolean(site.badge_show_photo) && Boolean(photoUrl);
// Scale the type with the smaller dimension so tiny labels stay legible.
const unit = Math.min(width, height);
const nameSize = Math.max(3.4, unit * 0.115);
const bodySize = Math.max(2.1, unit * 0.062);
// A label noticeably taller than it is wide gets a stacked layout. That is the
// normal case on a 62mm roll printer like the Brother QL-820NWB, where the roll
// fixes the width and the length runs down the badge.
const portrait = height >= width * 1.2;
// Type scales with the dimension that constrains it: the width on a portrait
// badge, the shorter side on a wide one. Keeps small stock legible.
const unit = portrait ? width : Math.min(width, height);
const pad = unit * 0.07;
const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115));
const bodySize = Math.max(2.0, unit * (portrait ? 0.055 : 0.062));
const photoWidth = portrait ? unit * 0.52 : unit * 0.42;
// Red only appears on a two-colour roll (DK-22251 on the QL-820NWB). Anywhere
// else it prints as grey, so it is off unless the site opts in.
const accent = site.badge_accent ? '#d00019' : '#000';
const timeIn = new Date(visit.signed_in_at);
const noCheck = visit.check_type === 'NONE';
const photo = showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : '';
const details = `
<div class="rows">
<div>Visiting <b>${esc(visit.host_name)}</b></div>
<div>In at <b>${esc(
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
<div>${
noCheck
? '<span class="flag">No WWCC / VIT</span>'
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
}</div>
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
</div>`;
return `<!doctype html>
<html lang="en-AU">
@@ -91,27 +121,40 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
.badge {
width: ${width}mm;
height: ${height}mm;
padding: ${unit * 0.075}mm ${unit * 0.09}mm;
padding: ${pad}mm;
display: flex;
gap: ${unit * 0.07}mm;
align-items: stretch;
flex-direction: ${portrait ? 'column' : 'row'};
align-items: ${portrait ? 'center' : 'stretch'};
text-align: ${portrait ? 'center' : 'left'};
gap: ${unit * 0.05}mm;
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
color: #000;
overflow: hidden;
}
.photo {
width: ${unit * 0.42}mm;
width: ${photoWidth}mm;
${portrait ? `height: ${photoWidth * 0.78}mm;` : ''}
flex: 0 0 auto;
object-fit: cover;
border: 0.3mm solid #000;
}
.body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.body {
flex: 1 1 auto;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
${portrait ? 'align-items: center;' : ''}
}
.site {
font-size: ${bodySize * 0.85}mm;
letter-spacing: 0.02em;
border-bottom: 0.35mm solid #000;
padding-bottom: ${unit * 0.025}mm;
margin-bottom: ${unit * 0.045}mm;
width: 100%;
font-size: ${bodySize * 0.8}mm;
letter-spacing: 0.03em;
text-transform: uppercase;
color: ${accent};
border-bottom: 0.35mm solid ${accent};
padding-bottom: ${unit * 0.02}mm;
margin-bottom: ${unit * 0.035}mm;
}
.name {
font-size: ${nameSize}mm;
@@ -120,14 +163,21 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.rows { margin-top: auto; font-size: ${bodySize}mm; line-height: 1.35; }
.rows {
margin-top: auto;
padding-top: ${unit * 0.04}mm;
font-size: ${bodySize}mm;
line-height: 1.35;
}
.rows b { font-weight: 700; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.03}mm; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.025}mm; }
.flag {
display: inline-block;
padding: 0 ${unit * 0.03}mm;
border: 0.3mm solid #000;
font-size: ${bodySize * 0.85}mm;
border: 0.35mm solid ${accent};
color: ${accent};
font-weight: 700;
font-size: ${bodySize * 0.9}mm;
}
@media screen {
body { background: #e7ecf0; padding: 12mm; }
@@ -137,22 +187,11 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
</head>
<body>
<div class="badge">
${showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : ''}
${photo}
<div class="body">
<div class="site">${esc(site.name)} &middot; VISITOR</div>
<div class="site">${esc(site.name)} &middot; Visitor</div>
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
<div class="rows">
<div>Visiting <b>${esc(visit.host_name)}</b></div>
<div>In at <b>${esc(
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
<div>${
visit.check_type === 'NONE'
? '<span class="flag">No WWCC / VIT</span>'
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
}</div>
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
</div>
${details}
</div>
</div>
${autoPrint ? '<script>window.addEventListener("load", () => window.print());</script>' : ''}