/* 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 ``; } 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 ``; }).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("") || `
${S.items.length ? T.noMatch : T.noKeysYet + " " + T.admin + "."}
`; $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]) => ``).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 ``; } 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 ``; } /* ------------------------------------------------------------------- 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) => ``; 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 ? "" : `
${T.noPeopleYet} ${T.admin}.
`}
`; } /* --------------------------------------------------------------------- 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 ? "" : `
`}
`; } 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 ? `
${T.reqFormPublic} · ${T.reqFormOpenIt}
` : `
${T.reqFormOpen} · ${T.reqFormOpenIt}
`; $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("")}` : ""}
`); } 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 ? `` : ""}
${T.noteOnKey}
${i.status === "in" || i.status === "reserve" ? `` : i.status === "out" ? `` : ""}
${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 ? `
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("")}
` : ""}
`); } 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 ``; }).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)}
`); 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]) => ``).join("")}
${T.whyNote}
${T.signature}
${pending.sig ? "" : `
${T.signHint}
`}
`); 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();