/* Key register — front end. Talks to the API, holds no truth of its own. */
"use strict";
let S = null; // server state
let tab = "board", cabinet = "ALL", filter = "all", query = "", pending = null;
const $scroll = document.getElementById("scroll");
const $sheet = document.getElementById("sheet");
const $scrim = document.getElementById("scrim");
const $toast = document.getElementById("toast");
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
const fmt = (d) => { if (!d) return "—"; const [y, m, dd] = d.split("-"); return `${dd} ${T._months[+m - 1]} ${y}`; };
const short = (d) => { if (!d) return ""; const [y, m, dd] = d.split("-"); return `${dd}/${m}/${y.slice(2)}`; };
const initials = (n) => n.split(" ").filter((w) => w.length > 2).slice(0, 2).map((w) => w[0]).join("") || n[0];
const days = (a, b) => Math.round((new Date(b) - new Date(a)) / 864e5);
const item = (id) => S.items.find((i) => i.id === +id);
const person = (id) => S.people.find((p) => p.id === +id);
const door = (id) => S.doors.find((d) => d.id === +id);
const cab = (id) => S.cabinets.find((c) => c.id === id) || { id: "", name: "—", full_name: "Unassigned", note: "" };
const copiesOf = (did) => S.items.filter((i) => i.door_id === +did);
const kindOf = (id) => (S.kinds || []).find((k) => k.id === id)
|| { id, name: id, shape: "circle", colour: "#2E6E60" };
/* one place that decides how any item is drawn */
const kindClass = (i) => `shape-${kindOf(i.kind).shape}`;
const kindStyle = (i) => `--k:${kindOf(i.kind).colour}`;
const isLate = (i) => i.loan && i.loan.due_date && i.loan.due_date < S.today;
/* Hook order, so the board reads in the same order as the physical hooks and
you can count along during an audit. Numeric-aware, so R2 sorts before R10.
Falls back to the tag when hooks are not filled in. */
const byHook = (a, b) =>
(a.hook || "").localeCompare(b.hook || "", undefined, { numeric: true }) ||
a.tag.localeCompare(b.tag, undefined, { numeric: true });
let toastTimer;
function toast(msg, err) {
$toast.textContent = msg;
$toast.className = "toast on" + (err ? " err" : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => ($toast.className = "toast"), 2600);
}
async function api(url, opts) {
const r = await fetch(url, opts);
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.error || r.statusText);
return body;
}
async function load() {
try {
S = await api("/api/state");
setLang(S.lang);
chrome();
counts();
render();
} catch (e) {
$scroll.innerHTML = `
${esc(T.noServer)} ${esc(e.message)}
`;
}
}
/* ------------------------------------------------------------------ header */
function counts() {
const live = S.items.filter((i) => i.status !== "reserve" && i.status !== "retired");
const reserve = S.items.filter((i) => i.status === "reserve").length;
const late = S.items.filter(isLate).length;
const out = S.items.filter((i) => i.status === "out").length;
document.getElementById("counts").innerHTML = `
${T.cabinetsN(S.cabinets.length)}
${T.keysIn(live.filter((i) => i.status === "in").length, live.length)}
${out} ${T.issuedN}
${late ? `${late} ${T.overdueN} ` : ""}
${reserve ? ` ${reserve} ${T.reserveN} ` : ""}`;
}
/* ------------------------------------------------------------------- board */
function peg(i) {
const late = isLate(i);
return `
${esc(i.tag)}${i.is_master ? "MASTER " : ""}
${late ? ' ' : ""}${i.notes ? '✎ ' : ""} `;
}
function viewBoard() {
const pass = (i) =>
filter === "all" ? i.status !== "retired" :
filter === "out" ? i.status === "out" :
filter === "late" ? isLate(i) :
filter.startsWith("k:") ? i.kind === filter.slice(2) && i.status !== "retired" :
true;
const show = S.items.filter((i) => pass(i) && (cabinet === "ALL" || i.cabinet_id === cabinet));
const tiles = [{ id: "ALL", name: T.allTile }].concat(S.cabinets).map((c) => {
const list = c.id === "ALL" ? S.items : S.items.filter((i) => i.cabinet_id === c.id);
return `
${esc(c.name)}
${T.aItemsCount(list.length)} · ${T.outCount(list.filter((i) => i.status === "out").length)} `;
}).join("");
/* Section order comes from the cabinets themselves, not from the order keys
happen to appear in. Grouping by insertion order used to put a cabinet
wherever its lowest-numbered key fell, ignoring its sort value entirely. */
let sections;
if (cabinet === "ALL") {
sections = S.cabinets
.map((c) => [c.full_name, show.filter((i) => i.cabinet_id === c.id).sort(byHook)])
.filter(([, list]) => list.length);
const orphans = show.filter((i) => !S.cabinets.some((c) => c.id === i.cabinet_id));
if (orphans.length) sections.push(["—", orphans.sort(byHook)]);
} else {
/* Inside one cabinet, group by building in the order buildings appear in
the door list, so it is stable rather than dependent on key numbering. */
const order = [];
S.doors.forEach((d) => { if (!order.includes(d.building)) order.push(d.building); });
show.forEach((i) => { if (i.building && !order.includes(i.building)) order.push(i.building); });
sections = order
.map((b) => [b, show.filter((i) => i.building === b).sort(byHook)])
.filter(([, list]) => list.length);
}
const rails = sections.map(([label, list]) => `
${esc(label)} ${T.outCount(list.filter((i) => i.status === "out").length)} · ${list.length}
${list.map(peg).join("")}
`).join("")
|| ``;
$scroll.innerHTML = `
${tiles}
${cabinet !== "ALL" && cab(cabinet).note ? `
${esc(cab(cabinet).note)}
` : ""}
${[["all", T.fEverything]]
.concat((S.kinds || []).map((k) => ["k:" + k.id, k.name]))
.concat([["out", T.fOut], ["late", T.fLate]])
.map(([k, l]) => `${esc(l)} `).join("")}
${rails}
${T.lgIn} ${T.lgOut}
${T.lgRes} ${T.lgLate}
${(S.kinds || []).map((k) => ` ${esc(k.name)} `).join("")}
${T.boardNote}
`;
}
/* ------------------------------------------------------------------- rows */
function itemRow(i) {
const late = isLate(i), l = i.loan;
const name = i.door_id ? door(i.door_id).name : i.zone;
return `
${esc(i.tag)}
${esc(name)}
${l && i.status === "out"
? `${esc(person(l.person_id) ? person(l.person_id).name : "?")} ${late ? ` · ${days(l.due_date, S.today)}d` : ""}`
: i.status === "reserve" ? T.reserveCopy : i.status === "lost" ? T.lost
: i.status === "retired" ? T.retired : T.onHook}
${esc(cab(i.cabinet_id).name)} `;
}
function doorRow(d) {
const c = copiesOf(d.id).filter((i) => i.status !== "retired");
const out = c.filter((i) => i.status === "out");
const late = c.filter(isLate).length;
const who = out.map((i) => { const p = person(i.loan.person_id); return p ? p.name.split(" ")[0] : "?"; });
return `
${esc(d.num)}
${esc(d.name)}
${esc(d.building)}${who.length ? ` · ${esc(who.join(", "))} ` : " · " + T.allIn}
${T.copies(c.length)}
${c.map((i) => ` `).join("")} `;
}
/* ------------------------------------------------------------------- doors */
function viewDoors() {
const q = query.trim().toLowerCase();
const hay = (d) => {
const c = copiesOf(d.id);
const who = c.map((i) => { if (!i.loan) return ""; const p = person(i.loan.person_id); return p ? p.name + " " + p.dept : ""; }).join(" ");
return (d.num + " " + d.name + " " + d.building + " " + d.lock_group + " " + who + " " +
c.map((i) => i.tag + " " + i.notes).join(" ")).toLowerCase();
};
const match = S.doors.filter((d) => !q || hay(d).includes(q));
/* items not attached to a door — alarm tags, remotes, anything zone-based */
const tags = S.items.filter((i) => !i.door_id &&
(!q || (i.tag + " " + i.zone + " " + i.building + " " + kindOf(i.kind).name).toLowerCase().includes(q)));
const groups = {};
match.forEach((d) => (groups[d.building] = groups[d.building] || []).push(d));
$scroll.innerHTML = `
${match.length || tags.length
? Object.entries(groups).map(([b, list]) =>
`
${esc(b)} · ${T.doorsN(list.length)}
${list.map(doorRow).join("")}`).join("")
+ (tags.length ? `
${T.tagsSection} · ${tags.length}
${tags.map(itemRow).join("")}` : "")
: `
${esc(T.nothingFound(query))}
`}
${T.doorsNote}
`;
const el = document.getElementById("q");
el.oninput = (e) => {
query = e.target.value; viewDoors();
const f = document.getElementById("q"); f.focus(); f.setSelectionRange(query.length, query.length);
};
}
/* ------------------------------------------------------------------ people */
function viewPeople() {
const rows = S.people.map((p) => {
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out");
return { p, held, late: held.filter(isLate).length };
}).sort((a, b) => b.held.length - a.held.length);
const row = (r) => `
${esc(initials(r.p.name))}
${esc(r.p.name)}${r.p.active ? "" : ` (${T.inactive})`}
${esc(r.p.dept)}${r.p.role ? " · " + esc(r.p.role) : ""}${r.late ? ` · ${r.late} ${T.overdueN} ` : ""}
${r.held.length ? T.itemsN(r.held.length) : "—"} `;
const holding = rows.filter((r) => r.held.length), free = rows.filter((r) => !r.held.length);
$scroll.innerHTML = `
${holding.length ? `
${T.holdingNow}
${holding.map(row).join("")}` : ""}
${free.length ? `
${T.nothingOut}
${free.map(row).join("")}` : ""}
${rows.length ? "" : `
`}
`;
}
/* --------------------------------------------------------------------- log */
function viewLog() {
const ev = [];
S.handovers.forEach((h) => {
ev.push({ d: h.out_date, dir: "out", h });
if (h.in_date) ev.push({ d: h.in_date, dir: "in", h });
});
ev.sort((a, b) => b.d.localeCompare(a.d));
$scroll.innerHTML = `${T.logTitle}
${ev.length ? `
${ev.map((e) => {
const it = item(e.h.item_id), p = person(e.h.person_id);
return `
${short(e.d)}
${esc(it ? it.tag : "?")}
${e.dir === "out" ? T.toWord : T.backFrom} ${esc(p ? p.name : "?")}
${e.h.has_sig ? ` ✓ ${T.signedMark} ` : ""}
${e.h.note ? `${esc(e.h.note)}
` : ""}
${e.dir === "out" ? T.dirOut : T.dirIn} `;
}).join("")}
` : `
${T.noHandovers}
`}
${T.logNote}
`;
}
/* ----------------------------------------------------------------- journal */
let requests = [];
function requestCard(r) {
const done = r.status === "handled";
return `
${esc(r.name)}
${esc(r.dept || "")}${r.dept && r.contact ? " · " : ""}${esc(r.contact || "")}
${esc(done ? T.reqHandledOn(short(r.handled)) : T.reqOn(short(r.created.slice(0, 10))))}
${r.areas ? `
${esc(r.areas)}
` : ""}
${r.detail ? `
${esc(r.detail)}
` : ""}
${r.note ? `
${esc(r.note)}
` : ""}
${done ? "" : `
${T.reqMarkDone}
`}
`;
}
async function viewJournal() {
try { requests = await api("/api/requests"); } catch (e) { requests = []; }
const pending = requests.filter((r) => r.status === "pending");
const done = requests.filter((r) => r.status === "handled").slice(0, 8);
/* State of the public form, read from the server rather than from memory —
a form that is public with no PIN is the one thing worth noticing. */
const rf = S.reqForm || { public: false, pin: false };
const status = !rf.public
? `${T.reqFormPrivate}
`
: rf.pin
? ``
: ``;
$scroll.innerHTML = `
${T.reqInbox}${pending.length ? `${pending.length} ` : ""}
${status}
${pending.length ? pending.map(requestCard).join("")
: `
${T.reqNone}
`}
${done.length ? `
${T.reqHandled}
${done.map(requestCard).join("")}` : ""}
${T.newEntry}
${T.journalTitle}
${S.journal.length ? S.journal.map((j) => `
${short(j.date)}
${esc(j.text)}
${j.author ? `${esc(j.author)} ` : ""}
`).join("")
: `
${T.journalEmpty}
`}
${T.journalNote}
`;
document.getElementById("jadd").onclick = async () => {
const t = document.getElementById("jt").value.trim();
if (!t) return;
try {
await api("/api/journal", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: t }) });
await load(); toast(T.tJournalAdded);
} catch (e) { toast(errMsg(e.message), true); }
};
}
/* ------------------------------------------------------------------ sheets */
function openSheet(html) {
$sheet.innerHTML = '
' + html;
$sheet.classList.add("on"); $scrim.classList.add("on"); $sheet.scrollTop = 0;
}
function closeSheet() { $sheet.classList.remove("on"); $scrim.classList.remove("on"); pending = null; }
$scrim.onclick = closeSheet;
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSheet(); });
function sheetDoor(did) {
const d = door(did), c = copiesOf(did).filter((i) => i.status !== "retired");
const out = c.filter((i) => i.status === "out");
const sameLock = d.lock_group ? S.doors.filter((x) => x.lock_group === d.lock_group && x.id !== d.id) : [];
openSheet(`${esc(d.num)} · ${esc(d.name)} ${esc(d.building)}
${T.doorSummary(c.length, out.length, c.filter((i) => i.status === "in").length,
c.filter((i) => i.status === "reserve").length, c.filter((i) => i.status === "lost").length)}
${T.fLockGroup} ${esc(d.lock_group || "—")}
${T.fCopiesLiveIn} ${esc([...new Set(c.map((i) => cab(i.cabinet_id).name))].join(", ") || "—")}
${d.notes ? `${T.noteOnDoor} ${esc(d.notes)}
` : ""}
${T.everyCopy}
${c.map(itemRow).join("")}
${sameLock.length ? `${T.sameLock}
${sameLock.map(doorRow).join("")}` : ""}
${T.close}
`);
}
async function sheetItem(id) {
const i = item(id);
if (!i) return;
const l = i.loan, late = isLate(i), c = cab(i.cabinet_id);
const d = i.door_id ? door(i.door_id) : null;
let hist = [];
try { hist = await api(`/api/items/${i.id}/history`); } catch (e) { /* non-fatal */ }
const status = late
? `${T.withSince(esc(person(l.person_id).name), fmt(l.out_date))}${T.dueWas(fmt(l.due_date), days(l.due_date, S.today))}
`
: i.status === "out"
? `${T.issuedToOn(esc(person(l.person_id) ? person(l.person_id).name : "?"), fmt(l.out_date))}${
l.due_date ? T.dueBack(fmt(l.due_date)) : T.noDueDate}
`
: i.status === "reserve"
? `${T.reserveLine}
`
: i.status === "lost" ? `${T.lostLine}
`
: i.status === "retired" ? `${T.retiredLine}
`
: `${T.inCabinet(`${esc(c.full_name)} `)}${i.hook ? T.atHook(esc(i.hook)) : ""}
`;
openSheet(`${esc(i.tag)}
${esc(d ? d.name : i.zone)}${i.building ? " · " + esc(i.building) : ""}
${status}
${l && l.note && i.status === "out" ? `${T.noteOnHandover}
${esc(l.note)}
` : ""}
${T.fType} ${esc(kindOf(i.kind).name)}
${T.fKeptIn} ${esc(c.full_name)}${c.note ? `${esc(c.note)} ` : ""}
${i.hook ? `
${T.fHook} ${esc(i.hook)} ` : ""}
${d ? `
${T.fLockGroup} ${esc(d.lock_group || "—")} ` : ""}
${i.uid ? `
${T.fChipUid} ${esc(i.uid)} ` : ""}
${i.is_master ? `
${T.fMaster} ${T.yes} ` : ""}
${d ? `
${esc(d.num)}
${T.seeAllCopies(copiesOf(d.id).length)}
${esc(d.name)} ` : ""}
${T.noteOnKey}
${T.saveNote}
${i.status === "in" || i.status === "reserve"
? `${i.status === "reserve" ? T.breakSeal : T.issueTo} `
: i.status === "out" ? `${T.takeBack} ` : ""}
${T.close}
${T.history}
${hist.length ? `${hist.map((h) => {
const p = person(h.person_id);
const hLate = !h.in_date && h.due_date && h.due_date < S.today;
return `
${short(h.out_date)}
${esc(p ? p.name : "?")}
${h.in_date ? T.returnedOn(fmt(h.in_date)) : T.stillHolding}
${h.note ? `${esc(h.note)}
` : ""}
${h.signature ? `` : ""}
${h.in_date ? T.stClosed : hLate ? T.stLate : T.stOpen} `;
}).join("")}
` : `${T.neverIssued}
`}
`);
}
function sheetPerson(id) {
const p = person(id);
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out");
const past = S.handovers.filter((h) => h.person_id === p.id && h.in_date);
openSheet(`${esc(p.name)} ${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}
${T.fHoldingNow} ${T.itemsN(held.length)}${
held.length ? `${esc(T.fromCabinets([...new Set(held.map((i) => cab(i.cabinet_id).name))].join(", ")))} ` : ""}
${T.fReturnedPast} ${past.length}
${p.phone ? `
${T.fPhone} ${esc(p.phone)} ` : ""}
${held.length ? `${T.currentlyHolds}
${held.map(itemRow).join("")}
${T.printPdf} — ${T.repReceiptTitle} `
: `${T.holdsNothing}
`}
${past.length ? `${T.returnedSection}
${past.map((h) => {
const it = item(h.item_id);
return `
${short(h.in_date)}
${esc(it ? it.tag : "?")}
${T.heldFromTo(fmt(h.out_date), fmt(h.in_date))}
`;
}).join("")}
` : ""}
${T.close}
`);
}
let pickQuery = "";
function personPickList(itemId) {
const q = pickQuery.trim().toLowerCase();
const active = S.people.filter((p) => p.active &&
(!q || (p.name + " " + p.dept + " " + p.role).toLowerCase().includes(q)));
if (!active.length) {
return `${q ? esc(T.nothingFound(pickQuery))
: T.noActivePeople + `
${T.admin} .`}
`;
}
return active.map((p) => {
const holds = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out").length;
return `
${esc(initials(p.name))}
${esc(p.name)}
${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}
${holds ? T.itemsN(holds) : ""} `;
}).join("");
}
function bindPicker(itemId) {
const el = document.getElementById("pq");
if (!el) return;
el.oninput = (e) => {
pickQuery = e.target.value;
document.getElementById("plist").innerHTML = personPickList(itemId);
};
}
function sheetIssue(id) {
const i = item(id);
pickQuery = "";
openSheet(`${esc(T.issueTitle(i.tag))}
${esc(i.door_id ? door(i.door_id).name : i.zone)} · ${esc(T.fromThe(cab(i.cabinet_id).full_name))}
${i.status === "reserve" ? `${T.reserveWarn}
` : ""}
${T.whoTakes}
${personPickList(i.id)}
${T.cancel}
`);
bindPicker(i.id);
}
const addDays = (d, k) => { const t = new Date(d); t.setDate(t.getDate() + k); return t.toISOString().slice(0, 10); };
function sheetConfirm() {
const i = item(pending.item), p = person(pending.person);
openSheet(`${T.handover}
${esc(i.tag)} · ${esc(i.door_id ? door(i.door_id).name : i.zone)} → ${esc(p.name)}
${T.backBy}
${[["none", T.dNone], ["7", T.d7], ["30", T.d30], ["90", T.d90]]
.map(([k, l]) => `${l} `).join("")}
${T.whyNote}
${T.signature}
${pending.sig ? "" : `
${T.signHint}
`}
${T.clearSig}
${T.confirmHandover}
${T.cancel}
`);
const c = document.getElementById("sig"), ctx = c.getContext("2d"), dpr = window.devicePixelRatio || 1;
requestAnimationFrame(() => {
c.width = c.offsetWidth * dpr; c.height = c.offsetHeight * dpr; ctx.scale(dpr, dpr);
ctx.lineWidth = 2.2; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "#131E1B";
if (pending && pending.sig) {
const im = new Image();
im.onload = () => ctx.drawImage(im, 0, 0, c.offsetWidth, c.offsetHeight);
im.src = pending.sig;
}
});
let drawing = false;
const pos = (e) => { const r = c.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
c.addEventListener("pointerdown", (e) => {
drawing = true; c.setPointerCapture(e.pointerId);
const h = document.getElementById("sighint"); if (h) h.remove();
const [x, y] = pos(e); ctx.beginPath(); ctx.moveTo(x, y);
});
c.addEventListener("pointermove", (e) => {
if (!drawing) return;
const [x, y] = pos(e); ctx.lineTo(x, y); ctx.stroke();
pending.sig = c.toDataURL("image/png");
});
c.addEventListener("pointerup", () => { drawing = false; });
document.getElementById("sigclear").onclick = () => { ctx.clearRect(0, 0, c.width, c.height); pending.sig = null; };
document.getElementById("confirm").onclick = async (ev) => {
ev.target.disabled = true;
const note = document.getElementById("hnote").value.trim();
const due = pending.dueKey === "none" ? null : addDays(S.today, +pending.dueKey);
const id = pending.item;
try {
await api(`/api/items/${id}/issue`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ person_id: pending.person, due_date: due, note, signature: pending.sig }),
});
pending = null;
await load();
toast(T.tHandedOver);
sheetItem(id);
} catch (e) { toast(errMsg(e.message), true); ev.target.disabled = false; }
};
}
/* ------------------------------------------------------------------ events */
document.addEventListener("click", async (e) => {
const t = e.target.closest("[data-handle],[data-item],[data-door],[data-person],[data-filter],[data-tab],[data-cab],[data-issue],[data-return],[data-give],[data-close],[data-savenote],[data-due]");
if (!t) return;
if (t.dataset.close !== undefined) return closeSheet();
if (t.dataset.savenote) {
const id = +t.dataset.savenote, notes = document.getElementById("inote").value.trim();
try {
await api(`/api/items/${id}/notes`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notes }) });
await load(); toast(T.tNoteSaved); sheetItem(id);
} catch (err) { toast(errMsg(err.message), true); }
return;
}
if (t.dataset.due) {
pending.note = document.getElementById("hnote").value;
pending.dueKey = t.dataset.due;
return sheetConfirm();
}
if (t.dataset.give) {
pending = { item: +t.dataset.on, person: +t.dataset.give, dueKey: "none", note: "", sig: null };
return sheetConfirm();
}
if (t.dataset.door !== undefined && t.dataset.door !== "") return sheetDoor(+t.dataset.door);
if (t.dataset.item) return sheetItem(+t.dataset.item);
if (t.dataset.person) return sheetPerson(+t.dataset.person);
if (t.dataset.issue) return sheetIssue(+t.dataset.issue);
if (t.dataset.return) {
const id = +t.dataset.return;
try {
await api(`/api/items/${id}/return`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
await load(); toast(T.tBackIn); sheetItem(id);
} catch (err) { toast(errMsg(err.message), true); }
return;
}
if (t.dataset.handle) {
const id = +t.dataset.handle;
const note = (document.getElementById("rn" + id) || {}).value || "";
try {
await api(`/api/requests/${id}/handle`, { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note }) });
await load(); toast(T.tSaved);
} catch (err) { toast(errMsg(err.message), true); }
return;
}
if (t.dataset.cab) { cabinet = t.dataset.cab; return render(); }
if (t.dataset.filter) { filter = t.dataset.filter; return render(); }
if (t.dataset.tab) {
tab = t.dataset.tab;
document.querySelectorAll(".tab").forEach((x) => x.setAttribute("aria-selected", x.dataset.tab === tab));
$scroll.scrollTop = 0;
return render();
}
});
function chrome() {
document.getElementById("brand").textContent = T.brand;
document.getElementById("apptitle").textContent = T.appTitle;
[["gear", T.admin], ["reports", T.reports], ["askform", T.reqHandPhone]].forEach(([id, label]) => {
const el = document.getElementById(id);
if (!el) return;
el.title = label;
el.setAttribute("aria-label", label);
});
document.querySelectorAll("[data-t]").forEach((el) => (el.textContent = T[el.dataset.t]));
/* pending requests are easy to miss on a tab you rarely open */
const jt = document.querySelector('[data-tab="journal"] [data-t]');
if (jt && S.pending) jt.innerHTML = `${T.tabJournal}${S.pending} `;
document.title = T.brand;
}
function render() {
({ board: viewBoard, doors: viewDoors, people: viewPeople, log: viewLog, journal: viewJournal })[tab]();
}
load();