Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -20,6 +20,7 @@ two factor.
|
||||
| | Guest sign in | Recurring visitor |
|
||||
|---|---|---|
|
||||
| First and last name | typed each visit | on file |
|
||||
| Company or organisation | optional, typed each visit | on file |
|
||||
| Person being visited | picked from the list | picked each visit |
|
||||
| Photo | taken at the kiosk | on file if saved, otherwise taken at the kiosk |
|
||||
| WWCC / VIT / none | typed each visit | on file |
|
||||
@@ -27,6 +28,11 @@ two factor.
|
||||
|
||||
Sign out only needs a **last name** plus a **mobile number or email**, which works for both.
|
||||
|
||||
The company field is optional and clearly marked as such — plenty of visitors are not from
|
||||
anywhere in particular. When it is filled in it appears on the badge, in the on-site list, on the
|
||||
evacuation sheet, and in the visit log, and the log search matches on it, so you can pull up every
|
||||
visit from one contractor.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
@@ -439,6 +445,24 @@ docker compose up -d --build
|
||||
**Changes to the code do nothing** — Compose reuses the existing image. Always
|
||||
`docker compose up -d --build` after a `git pull`.
|
||||
|
||||
**Google Sheet says "The caller does not have permission"** — the app authenticated fine and
|
||||
Google refused the spreadsheet. Work through these in order:
|
||||
|
||||
1. **Admin → System** shows the service account address. Open the sheet, press Share, paste that
|
||||
address, set it to **Editor**, and untick "Notify people". This is the cause about nine times
|
||||
in ten.
|
||||
2. If your Google Workspace blocks sharing outside the organisation, the share will silently fail
|
||||
or be refused — a service account address is external. Ask your Workspace admin to allow it,
|
||||
or create the sheet in an account that permits external sharing.
|
||||
3. If the sheet lives in a **Shared drive**, share the drive with the service account, not just
|
||||
the file.
|
||||
4. Check the spreadsheet ID matches the one in the sheet's URL. A wrong ID usually gives a 404,
|
||||
but a valid ID for someone else's sheet gives this same 403.
|
||||
5. Confirm the **Google Sheets API** is enabled on the project the key belongs to. A key from
|
||||
project A cannot use an API enabled only on project B.
|
||||
|
||||
Press **Test the sheet connection** after each step.
|
||||
|
||||
**Browser still warns about the certificate** — the authority is installed but not trusted. On
|
||||
iOS that is a second, separate step under Settings → General → About → Certificate Trust
|
||||
Settings. On Android, use a hostname rather than a bare IP.
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@
|
||||
<div class="filters">
|
||||
<label><span>From</span><input type="date" id="log-from"></label>
|
||||
<label><span>To</span><input type="date" id="log-to"></label>
|
||||
<label class="grow"><span>Search name, host or contact</span><input id="log-q"></label>
|
||||
<label class="grow"><span>Search name, company, host or contact</span><input id="log-q"></label>
|
||||
<button class="ghost" id="log-search">Apply</button>
|
||||
</div>
|
||||
<div id="log-table"></div>
|
||||
|
||||
@@ -397,3 +397,6 @@ a:focus-visible {
|
||||
min-height: 58px;
|
||||
}
|
||||
.picker:disabled { color: var(--muted); }
|
||||
|
||||
/* An optional field says so quietly, without shouting for attention. */
|
||||
.field > span em { font-style: normal; opacity: 0.75; }
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
<span>Last name</span>
|
||||
<input id="in-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Company or organisation <em>(optional)</em></span>
|
||||
<input id="in-company" autocomplete="organization" autocapitalize="words" enterkeyhint="next"
|
||||
placeholder="Leave blank if you're not here for work">
|
||||
</label>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-go="home">Back</button>
|
||||
<button class="primary" data-next="guest-name">Continue</button>
|
||||
|
||||
+20
-3
@@ -175,7 +175,8 @@ async function loadOnsite() {
|
||||
(v) => `<tr>
|
||||
<td>${v.hasPhoto ? `<img class="thumb" src="/admin/api/photo/${v.id}" alt="">` : ''}</td>
|
||||
<td><strong>${esc(v.firstName)} ${esc(v.lastName)}</strong>
|
||||
${v.visitorType === 'frequent' ? '<span class="pill">Recurring</span>' : ''}</td>
|
||||
${v.visitorType === 'frequent' ? '<span class="pill">Recurring</span>' : ''}
|
||||
${v.company ? `<small>${esc(v.company)}</small>` : ''}</td>
|
||||
${showSite ? `<td>${esc(v.siteName || '—')}</td>` : ''}
|
||||
<td>${esc(v.hostName)}</td>
|
||||
<td>${v.checkType === 'NONE' ? '<span class="pill off">None</span>' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</td>
|
||||
@@ -220,7 +221,8 @@ async function loadLog() {
|
||||
rows.map(
|
||||
(v) => `<tr>
|
||||
${showSite ? `<td>${esc(v.siteName || '—')}</td>` : ''}
|
||||
<td>${esc(v.firstName)} ${esc(v.lastName)}</td>
|
||||
<td>${esc(v.firstName)} ${esc(v.lastName)}
|
||||
${v.company ? `<small>${esc(v.company)}</small>` : ''}</td>
|
||||
<td>${esc(v.hostName)}</td>
|
||||
<td>${v.checkType === 'NONE' ? '—' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</td>
|
||||
<td>${esc(v.phone || v.email || '—')}</td>
|
||||
@@ -258,6 +260,7 @@ async function loadRecurring() {
|
||||
rows.map(
|
||||
(p) => `<tr class="${p.expiry.status === 'expired' ? 'row-bad' : p.expiry.status === 'expiring' ? 'row-warn' : ''}">
|
||||
<td><strong>${esc(p.firstName)} ${esc(p.lastName)}</strong>
|
||||
${p.company ? `<small>${esc(p.company)}</small>` : ''}
|
||||
${p.email ? `<small>${esc(p.email)}</small>` : ''}</td>
|
||||
<td class="mono">${esc(p.phone)}</td>
|
||||
<td>${p.siteId ? esc(siteName(p.siteId)) : '<span class="pill off">Any site</span>'}</td>
|
||||
@@ -470,6 +473,7 @@ function openRecurringModal(person = null) {
|
||||
`
|
||||
${field('First name', 'firstName', person?.firstName)}
|
||||
${field('Last name', 'lastName', person?.lastName)}
|
||||
${field('Company or organisation (optional)', 'company', person?.company)}
|
||||
${field('Mobile number (their username)', 'phone', person?.phone, 'tel')}
|
||||
${field('Email address', 'email', person?.email, 'email')}
|
||||
<label class="modal-field"><span>Check held</span>
|
||||
@@ -1155,7 +1159,20 @@ async function loadSystem() {
|
||||
s.sheets.enabled
|
||||
? `<dt>On the sheet</dt><dd>${
|
||||
s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site`
|
||||
}, last written ${stamp(s.sheets.lastOk)}</dd>`
|
||||
}, last written ${stamp(s.sheets.lastOk)}</dd>
|
||||
<dt>Service account</dt>
|
||||
<dd>${
|
||||
s.sheets.serviceAccount
|
||||
? `<code>${esc(s.sheets.serviceAccount)}</code>
|
||||
<br><span class="hint">The spreadsheet must be shared with this address, with Editor access.</span>`
|
||||
: '<span class="pill bad">No key file could be read</span>'
|
||||
}</dd>
|
||||
<dt>Spreadsheet</dt>
|
||||
<dd>${
|
||||
s.sheets.spreadsheetId
|
||||
? `<a href="https://docs.google.com/spreadsheets/d/${esc(s.sheets.spreadsheetId)}/edit" target="_blank" rel="noopener">open the sheet</a>`
|
||||
: '<span class="pill warn">SHEETS_SPREADSHEET_ID is not set</span>'
|
||||
}</dd>`
|
||||
: ''
|
||||
}
|
||||
</dl>
|
||||
|
||||
@@ -10,6 +10,7 @@ const state = {
|
||||
mode: 'guest',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
company: '',
|
||||
hostId: null,
|
||||
hostName: '',
|
||||
phone: '',
|
||||
@@ -136,6 +137,7 @@ function resetState() {
|
||||
mode: 'guest',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
company: '',
|
||||
hostId: null,
|
||||
hostName: '',
|
||||
phone: '',
|
||||
@@ -338,6 +340,7 @@ function afterPhoto() {
|
||||
function buildReview() {
|
||||
const rows = [
|
||||
['Name', `${state.firstName} ${state.lastName}`],
|
||||
...(state.company ? [['From', state.company]] : []),
|
||||
['Visiting', state.hostName],
|
||||
['Mobile', state.phone || '—'],
|
||||
['Email', state.email || '—'],
|
||||
@@ -369,6 +372,7 @@ async function submitSignIn() {
|
||||
frequentVisitorId: state.frequentVisitorId,
|
||||
firstName: state.firstName,
|
||||
lastName: state.lastName,
|
||||
company: state.company,
|
||||
hostId: state.hostId,
|
||||
phone: state.phone,
|
||||
email: state.email,
|
||||
@@ -520,6 +524,7 @@ $('[data-next="guest-name"]').addEventListener('click', () => {
|
||||
if (!last) return say('Enter your last name.');
|
||||
state.firstName = first;
|
||||
state.lastName = last;
|
||||
state.company = $('#in-company').value.trim();
|
||||
show('guest-host');
|
||||
});
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS frequent_visitors (
|
||||
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
company TEXT,
|
||||
phone TEXT NOT NULL UNIQUE,
|
||||
email TEXT,
|
||||
check_type TEXT NOT NULL DEFAULT 'NONE',
|
||||
@@ -71,6 +72,7 @@ CREATE TABLE IF NOT EXISTS visits (
|
||||
frequent_visitor_id INTEGER REFERENCES frequent_visitors(id) ON DELETE SET NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
company TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
check_type TEXT NOT NULL DEFAULT 'NONE',
|
||||
@@ -168,6 +170,9 @@ addColumn('sites', 'colour_brand', 'TEXT');
|
||||
addColumn('sites', 'colour_signout', 'TEXT');
|
||||
addColumn('sites', 'colour_page', 'TEXT');
|
||||
addColumn('sites', 'colour_text', 'TEXT');
|
||||
// Optional "who are you from", handy for contractors and visiting staff.
|
||||
addColumn('visits', 'company', 'TEXT');
|
||||
addColumn('frequent_visitors', 'company', 'TEXT');
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
|
||||
|
||||
+19
-10
@@ -606,6 +606,7 @@ function shapeFrequent(row, includePin = false) {
|
||||
id: row.id,
|
||||
firstName: row.first_name,
|
||||
lastName: row.last_name,
|
||||
company: row.company,
|
||||
phone: row.phone,
|
||||
email: row.email,
|
||||
checkType: row.check_type,
|
||||
@@ -737,6 +738,7 @@ function validateFrequent(body, { existingPhone = null, existingId = null } = {}
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
company: clean(body?.company, 80) || null,
|
||||
phone,
|
||||
email: email || null,
|
||||
checkType,
|
||||
@@ -759,13 +761,13 @@ router.post('/frequent', (req, res) => {
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO frequent_visitors
|
||||
(first_name, last_name, phone, email, check_type, check_number, check_expiry,
|
||||
(first_name, last_name, company, phone, email, check_type, check_number, check_expiry,
|
||||
default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
||||
)
|
||||
.run(
|
||||
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
|
||||
v.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes
|
||||
v.firstName, v.lastName, v.company, v.phone, v.email, v.checkType, v.checkNumber,
|
||||
v.checkExpiry, v.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes
|
||||
);
|
||||
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true));
|
||||
} catch (err) {
|
||||
@@ -794,11 +796,11 @@ router.patch('/frequent/:id', (req, res) => {
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, phone = ?, email = ?,
|
||||
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, company = ?, phone = ?, email = ?,
|
||||
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
|
||||
photo_path = ?, notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).run(
|
||||
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
|
||||
v.firstName, v.lastName, v.company, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
|
||||
v.defaultHostId,
|
||||
scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id),
|
||||
photoPath,
|
||||
@@ -892,6 +894,7 @@ function shapeVisit(v) {
|
||||
visitorType: v.visitor_type,
|
||||
firstName: v.first_name,
|
||||
lastName: v.last_name,
|
||||
company: v.company,
|
||||
phone: v.phone,
|
||||
email: v.email,
|
||||
checkType: v.check_type,
|
||||
@@ -932,8 +935,10 @@ router.get('/visits', (req, res) => {
|
||||
params.push(`${to}T23:59:59.999Z`);
|
||||
}
|
||||
if (q) {
|
||||
where.push('(last_name LIKE ? OR first_name LIKE ? OR host_name LIKE ? OR phone LIKE ? OR email LIKE ?)');
|
||||
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
|
||||
where.push(
|
||||
'(last_name LIKE ? OR first_name LIKE ? OR company LIKE ? OR host_name LIKE ? OR phone LIKE ? OR email LIKE ?)'
|
||||
);
|
||||
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
|
||||
}
|
||||
const sql = `SELECT * FROM visits ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY signed_in_at DESC LIMIT 500`;
|
||||
res.json(db.prepare(sql).all(...params).map(shapeVisit));
|
||||
@@ -969,9 +974,9 @@ router.get('/visits.csv', (req, res) => {
|
||||
.all(...params);
|
||||
|
||||
const csv = toCsv([
|
||||
['Visit ID', 'Site', 'Type', 'First name', 'Last name', 'Phone', 'Email', 'Check type', 'Check number', 'Visiting', 'Reason', 'Signed in', 'Signed out', 'Closed by', 'Photo'],
|
||||
['Visit ID', 'Site', 'Type', 'First name', 'Last name', 'Company', 'Phone', 'Email', 'Check type', 'Check number', 'Visiting', 'Reason', 'Signed in', 'Signed out', 'Closed by', 'Photo'],
|
||||
...rows.map((v) => [
|
||||
v.id, v.site_name, v.visitor_type, v.first_name, v.last_name, v.phone, v.email,
|
||||
v.id, v.site_name, v.visitor_type, v.first_name, v.last_name, v.company, v.phone, v.email,
|
||||
v.check_type, v.check_number, v.host_name, v.visit_reason,
|
||||
localStamp(v.signed_in_at), localStamp(v.signed_out_at), v.signed_out_by,
|
||||
v.photo_path ? 'yes' : 'no',
|
||||
@@ -1041,6 +1046,7 @@ router.get('/pass/:id', (req, res) => {
|
||||
border:1px solid var(--rule); }
|
||||
h1 { margin:0; font-size: 21px; letter-spacing:-0.01em; }
|
||||
.site { margin:0 0 14px; font-size:12px; color:var(--muted); }
|
||||
.org { margin: 3px 0 0; font-size: 13px; color: var(--muted); }
|
||||
.pin { margin: 14px 0 4px; font-size: 46px; font-weight: 700; letter-spacing: 0.22em;
|
||||
font-variant-numeric: tabular-nums; color: var(--deep); }
|
||||
.pin-label { margin:0 0 16px; font-size:12px; color:var(--muted); }
|
||||
@@ -1064,6 +1070,7 @@ router.get('/pass/:id', (req, res) => {
|
||||
<div class="card">
|
||||
<p class="site">${esc(site ? site.name : config.siteName)}</p>
|
||||
<h1>${esc(row.first_name)} ${esc(row.last_name)}</h1>
|
||||
${row.company ? `<p class="org">${esc(row.company)}</p>` : ''}
|
||||
<p class="pin">${esc(pin)}</p>
|
||||
<p class="pin-label">Your PIN. Keep this card, it is not sent to you again.</p>
|
||||
<dl>
|
||||
@@ -1105,6 +1112,8 @@ router.get('/status', (req, res) => {
|
||||
lastError: sheets.status.lastError,
|
||||
tab: sheets.tabName(),
|
||||
stale: sheets.isStale(),
|
||||
serviceAccount: sheets.serviceAccountEmail(),
|
||||
spreadsheetId: config.sheets.spreadsheetId || null,
|
||||
lastOnSiteSync: sheets.status.lastOnSiteSync,
|
||||
onSiteCount: sheets.status.onSiteCount,
|
||||
onSiteError: sheets.status.onSiteError,
|
||||
|
||||
+8
-3
@@ -141,6 +141,8 @@ router.post('/signin', signInLimiter, (req, res) => {
|
||||
const checkType = isFrequent ? frequent.check_type : clean(body.checkType, 10).toUpperCase();
|
||||
const checkNumber = clean(isFrequent ? frequent.check_number : body.checkNumber, 40);
|
||||
const checkExpiry = isFrequent ? frequent.check_expiry : clean(body.checkExpiry, 20);
|
||||
// Optional: plenty of visitors are not from anywhere in particular.
|
||||
const company = clean(isFrequent ? frequent.company : body.company, 80);
|
||||
const visitReason = clean(body.visitReason, 120);
|
||||
|
||||
if (!firstName) return res.status(400).json({ error: 'First name is required.' });
|
||||
@@ -184,9 +186,10 @@ router.post('/signin', signInLimiter, (req, res) => {
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO visits
|
||||
(site_id, site_name, visitor_type, frequent_visitor_id, first_name, last_name, phone, email,
|
||||
check_type, check_number, check_expiry, host_id, host_name, visit_reason, photo_path, signed_in_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
(site_id, site_name, visitor_type, frequent_visitor_id, first_name, last_name, company,
|
||||
phone, email, check_type, check_number, check_expiry, host_id, host_name, visit_reason,
|
||||
photo_path, signed_in_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
site.id,
|
||||
@@ -195,6 +198,7 @@ router.post('/signin', signInLimiter, (req, res) => {
|
||||
isFrequent ? frequent.id : null,
|
||||
firstName,
|
||||
lastName,
|
||||
company || null,
|
||||
phone || null,
|
||||
email || null,
|
||||
checkType,
|
||||
@@ -358,6 +362,7 @@ router.post('/frequent/auth', pinLimiter, (req, res) => {
|
||||
lastName: person.last_name,
|
||||
checkType: person.check_type,
|
||||
checkNumber: person.check_number,
|
||||
company: person.company,
|
||||
defaultHostId: person.default_host_id,
|
||||
// Tells the kiosk it can skip the camera step entirely.
|
||||
hasPhoto: Boolean(person.photo_path),
|
||||
|
||||
+56
-3
@@ -19,6 +19,7 @@ const HEADER = [
|
||||
'Site',
|
||||
'First name',
|
||||
'Last name',
|
||||
'Company',
|
||||
'Visiting',
|
||||
'Phone',
|
||||
'Email',
|
||||
@@ -65,6 +66,49 @@ function getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service account's own address. Nothing works until the spreadsheet is
|
||||
* shared with it, and it is buried in a JSON key file nobody wants to open on a
|
||||
* server, so the admin console shows it.
|
||||
*/
|
||||
export function serviceAccountEmail() {
|
||||
try {
|
||||
return loadCredentials().client_email || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google's own wording for these failures says what went wrong but never what to
|
||||
* do about it, so the common ones are rewritten with the actual next step.
|
||||
*/
|
||||
function explain(err) {
|
||||
const code = err?.code || err?.response?.status;
|
||||
const raw = String(err?.message || '');
|
||||
const email = serviceAccountEmail();
|
||||
|
||||
if (code === 403 && /caller does not have permission|permission/i.test(raw)) {
|
||||
return (
|
||||
`The service account cannot open this spreadsheet. Share the sheet with ` +
|
||||
`${email || 'the service account address'} and give it Editor access.`
|
||||
);
|
||||
}
|
||||
if (code === 403 && /has not been used|accessNotConfigured|disabled/i.test(raw)) {
|
||||
return 'The Google Sheets API is not enabled on that Google Cloud project. Enable it, then wait a minute and retry.';
|
||||
}
|
||||
if (code === 404) {
|
||||
return 'No spreadsheet was found with that ID. Check SHEETS_SPREADSHEET_ID against the sheet URL.';
|
||||
}
|
||||
if (code === 400 && /Unable to parse range/i.test(raw)) {
|
||||
return `The tab "${config.sheets.onSiteTab}" could not be addressed. Check SHEETS_ONSITE_TAB matches the tab name exactly.`;
|
||||
}
|
||||
if (/invalid_grant|Invalid JWT|clock/i.test(raw)) {
|
||||
return "Google rejected the credentials. Check the server's clock is correct and the service account key has not been deleted.";
|
||||
}
|
||||
return raw || 'Unknown error talking to Google Sheets.';
|
||||
}
|
||||
|
||||
export function isEnabled() {
|
||||
return Boolean(config.sheets.enabled && config.sheets.spreadsheetId);
|
||||
}
|
||||
@@ -116,6 +160,7 @@ function onSiteRows() {
|
||||
v.site_name || '',
|
||||
v.first_name,
|
||||
v.last_name,
|
||||
v.company || '',
|
||||
v.host_name,
|
||||
v.phone || '',
|
||||
v.email || '',
|
||||
@@ -146,7 +191,7 @@ export async function syncOnSite() {
|
||||
|
||||
await sheets.spreadsheets.values.clear({
|
||||
spreadsheetId: config.sheets.spreadsheetId,
|
||||
range: `${config.sheets.onSiteTab}!A1:J${MAX_ROWS + 10}`,
|
||||
range: `${config.sheets.onSiteTab}!A1:K${MAX_ROWS + 10}`,
|
||||
});
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId: config.sheets.spreadsheetId,
|
||||
@@ -162,8 +207,10 @@ export async function syncOnSite() {
|
||||
return { rows: rows.length };
|
||||
} catch (err) {
|
||||
dirty = true;
|
||||
status.lastError = err.message;
|
||||
throw err;
|
||||
status.lastError = explain(err);
|
||||
const wrapped = new Error(status.lastError);
|
||||
wrapped.cause = err;
|
||||
throw wrapped;
|
||||
} finally {
|
||||
syncing = false;
|
||||
}
|
||||
@@ -182,11 +229,17 @@ export function mirror() {
|
||||
|
||||
export async function testConnection() {
|
||||
if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.');
|
||||
try {
|
||||
const sheets = getClient();
|
||||
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
|
||||
await ensureTab(sheets, { force: true });
|
||||
await syncOnSite();
|
||||
status.lastError = null;
|
||||
return { title: meta.data.properties.title, tab: config.sheets.onSiteTab };
|
||||
} catch (err) {
|
||||
status.lastError = explain(err);
|
||||
throw new Error(status.lastError);
|
||||
}
|
||||
}
|
||||
|
||||
export function tabName() {
|
||||
|
||||
+18
-6
@@ -97,9 +97,12 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
|
||||
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));
|
||||
// Square, matching the crop taken at the kiosk. Sized so that even a long name
|
||||
// wrapping onto two lines still fits above the detail rows on a 62 x 90 mm label.
|
||||
const photoWidth = portrait ? unit * 0.52 : unit * 0.5;
|
||||
// Square, matching the crop taken at the kiosk. A company adds a line under the
|
||||
// name, so on a portrait badge the photo gives that line back rather than
|
||||
// letting a long name get clipped at the bottom edge.
|
||||
const roomy = height >= width * 1.5;
|
||||
const portraitPhoto = visit.company && !roomy ? 0.44 : 0.52;
|
||||
const photoWidth = portrait ? unit * portraitPhoto : unit * 0.5;
|
||||
|
||||
// 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.
|
||||
@@ -178,13 +181,21 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
|
||||
letter-spacing: -0.01em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.org {
|
||||
font-size: ${bodySize * 0.95}mm;
|
||||
line-height: 1.2;
|
||||
margin-top: ${unit * 0.02}mm;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.rows {
|
||||
/* Portrait badges centre the whole block; wider ones push the detail rows to
|
||||
the bottom edge, which is where the eye expects them beside a photo. */
|
||||
margin-top: ${portrait ? `${unit * 0.05}mm` : 'auto'};
|
||||
padding-top: ${unit * 0.04}mm;
|
||||
margin-top: ${portrait ? `${unit * 0.045}mm` : 'auto'};
|
||||
padding-top: ${unit * 0.035}mm;
|
||||
font-size: ${bodySize}mm;
|
||||
line-height: 1.35;
|
||||
/* Tightened from 1.35 to make room for the company line without shrinking
|
||||
the photo, which is the thing people actually look at. */
|
||||
line-height: 1.28;
|
||||
}
|
||||
.rows b { font-weight: 700; }
|
||||
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.025}mm; }
|
||||
@@ -208,6 +219,7 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
|
||||
<div class="body">
|
||||
<div class="site">${esc(site.name)} · Visitor</div>
|
||||
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
|
||||
${visit.company ? `<div class="org">${esc(visit.company)}</div>` : ''}
|
||||
${details}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user