lxc-keyreg/public/app.js
2026-08-24 15:21:24 +01:00

595 lines
32 KiB
JavaScript

/* 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 = `<div class="pane"><div class="empty">${esc(T.noServer)}<br>${esc(e.message)}</div></div>`;
}
}
/* ------------------------------------------------------------------ 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 = `
<span class="count">${T.cabinetsN(S.cabinets.length)}</span>
<span class="count"><span class="swatch in"></span>${T.keysIn(live.filter((i) => i.status === "in").length, live.length)}</span>
<span class="count"><span class="swatch out"></span><b>${out}</b> ${T.issuedN}</span>
${late ? `<span class="count warn"><b>${late}</b> ${T.overdueN}</span>` : ""}
${reserve ? `<span class="count"><span class="swatch res"></span>${reserve} ${T.reserveN}</span>` : ""}`;
}
/* ------------------------------------------------------------------- board */
function peg(i) {
const late = isLate(i);
return `<button class="peg ${kindClass(i)} st-${i.status}${late ? " late" : ""}"
style="${kindStyle(i)}" data-item="${i.id}"
aria-label="${esc(i.tag)}, ${esc(i.door_id ? door(i.door_id).name : i.zone)}, ${esc(kindOf(i.kind).name)}, ${late ? "overdue" : i.status}">
${esc(i.tag)}${i.is_master ? "<small>MASTER</small>" : ""}
${late ? '<span class="flag"></span>' : ""}${i.notes ? '<span class="memo">✎</span>' : ""}</button>`;
}
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 `<button class="cab" data-cab="${esc(c.id)}" aria-pressed="${cabinet === c.id}">
<span class="cn">${esc(c.name)}</span>
<span class="cs">${T.aItemsCount(list.length)} · <span class="o">${T.outCount(list.filter((i) => i.status === "out").length)}</span></span></button>`;
}).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]) => `<div class="rail">
<div class="rail-label">${esc(label)} <span>${T.outCount(list.filter((i) => i.status === "out").length)} · ${list.length}</span></div>
<div class="pegs">${list.map(peg).join("")}</div></div>`).join("")
|| `<div class="empty" style="background:transparent;border-color:#3A4C46;color:#8A968F">
${S.items.length ? T.noMatch : T.noKeysYet + " <a href='/admin' style='color:#9FADA7'>" + T.admin + "</a>."}</div>`;
$scroll.innerHTML = `<div class="board">
<div class="cabs">${tiles}</div>
${cabinet !== "ALL" && cab(cabinet).note ? `<p class="where">${esc(cab(cabinet).note)}</p>` : ""}
<div class="filters">${[["all", T.fEverything]]
.concat((S.kinds || []).map((k) => ["k:" + k.id, k.name]))
.concat([["out", T.fOut], ["late", T.fLate]])
.map(([k, l]) => `<button class="chip" data-filter="${esc(k)}" aria-pressed="${filter === k}">${esc(l)}</button>`).join("")}</div>
${rails}
<div class="legend">
<span><i class="lg-in"></i>${T.lgIn}</span><span><i class="lg-out"></i>${T.lgOut}</span>
<span><i class="lg-res"></i>${T.lgRes}</span><span><i class="lg-late"></i>${T.lgLate}</span>
${(S.kinds || []).map((k) => `<span><i class="lg-kind" style="background:${esc(k.colour)};border-color:${esc(k.colour)};${
k.shape === "circle" ? "border-radius:50%" : k.shape === "square" ? "border-radius:0" : ""}"></i>${esc(k.name)}</span>`).join("")}
</div>
<p class="note">${T.boardNote}</p></div>`;
}
/* ------------------------------------------------------------------- rows */
function itemRow(i) {
const late = isLate(i), l = i.loan;
const name = i.door_id ? door(i.door_id).name : i.zone;
return `<button class="row${late ? " late" : ""}" data-item="${i.id}">
<span class="badge ${kindClass(i)} st-${i.status}" style="${kindStyle(i)}">${esc(i.tag)}</span>
<span class="row-main"><span class="row-t">${esc(name)}</span>
<span class="row-s">${l && i.status === "out"
? `<span class="${late ? "od" : "held"}">${esc(person(l.person_id) ? person(l.person_id).name : "?")}</span>${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}</span></span>
<span class="row-n">${esc(cab(i.cabinet_id).name)}</span></button>`;
}
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 `<button class="row${late ? " late" : ""}" data-door="${d.id}">
<span class="badge door">${esc(d.num)}</span>
<span class="row-main"><span class="row-t">${esc(d.name)}</span>
<span class="row-s">${esc(d.building)}${who.length ? ` · <span class="${late ? "od" : "held"}">${esc(who.join(", "))}</span>` : " · " + T.allIn}</span></span>
<span class="row-n">${T.copies(c.length)}
<span class="dots">${c.map((i) => `<span class="dot ${isLate(i) ? "out" : i.status}" style="${kindStyle(i)}"></span>`).join("")}</span></span></button>`;
}
/* ------------------------------------------------------------------- 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 = `<div class="pane">
<input class="search" id="q" placeholder="${esc(T.searchPlaceholder)}" value="${esc(query)}">
${match.length || tags.length
? Object.entries(groups).map(([b, list]) =>
`<div class="sec">${esc(b)} · ${T.doorsN(list.length)}</div>${list.map(doorRow).join("")}`).join("")
+ (tags.length ? `<div class="sec">${T.tagsSection} · ${tags.length}</div>${tags.map(itemRow).join("")}` : "")
: `<div class="empty">${esc(T.nothingFound(query))}</div>`}
<p class="note">${T.doorsNote}</p></div>`;
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) => `<button class="row${r.late ? " late" : ""}" data-person="${r.p.id}">
<span class="badge person">${esc(initials(r.p.name))}</span>
<span class="row-main"><span class="row-t">${esc(r.p.name)}${r.p.active ? "" : ` (${T.inactive})`}</span>
<span class="row-s">${esc(r.p.dept)}${r.p.role ? " · " + esc(r.p.role) : ""}${r.late ? ` · <span class="od">${r.late} ${T.overdueN}</span>` : ""}</span></span>
<span class="row-n">${r.held.length ? T.itemsN(r.held.length) : "—"}</span></button>`;
const holding = rows.filter((r) => r.held.length), free = rows.filter((r) => !r.held.length);
$scroll.innerHTML = `<div class="pane">
${holding.length ? `<div class="sec">${T.holdingNow}</div>${holding.map(row).join("")}` : ""}
${free.length ? `<div class="sec">${T.nothingOut}</div>${free.map(row).join("")}` : ""}
${rows.length ? "" : `<div class="empty">${T.noPeopleYet} <a href="/admin">${T.admin}</a>.</div>`}</div>`;
}
/* --------------------------------------------------------------------- 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 = `<div class="pane"><div class="sec">${T.logTitle}</div>
${ev.length ? `<div class="log">${ev.map((e) => {
const it = item(e.h.item_id), p = person(e.h.person_id);
return `<div class="log-row">
<span class="log-d">${short(e.d)}</span>
<span class="log-b"><span class="log-tag">${esc(it ? it.tag : "?")}</span>
${e.dir === "out" ? T.toWord : T.backFrom} ${esc(p ? p.name : "?")}
${e.h.has_sig ? ` <span style="color:var(--verdigris)">✓ ${T.signedMark}</span>` : ""}
${e.h.note ? `<div class="memo-line">${esc(e.h.note)}</div>` : ""}</span>
<span class="dir ${e.dir}">${e.dir === "out" ? T.dirOut : T.dirIn}</span></div>`;
}).join("")}</div>` : `<div class="empty">${T.noHandovers}</div>`}
<p class="note">${T.logNote}</p></div>`;
}
/* ----------------------------------------------------------------- journal */
let requests = [];
function requestCard(r) {
const done = r.status === "handled";
return `<div class="req-card${done ? " done" : ""}">
<h4>${esc(r.name)}</h4>
<div class="meta">${esc(r.dept || "")}${r.dept && r.contact ? " · " : ""}${esc(r.contact || "")}</div>
<div class="meta">${esc(done ? T.reqHandledOn(short(r.handled)) : T.reqOn(short(r.created.slice(0, 10))))}</div>
${r.areas ? `<p class="areas-line">${esc(r.areas)}</p>` : ""}
${r.detail ? `<p class="detail">${esc(r.detail)}</p>` : ""}
${r.note ? `<p class="detail" style="font-style:italic">${esc(r.note)}</p>` : ""}
${done ? "" : `<div style="margin-top:10px">
<input class="search" id="rn${r.id}" placeholder="${esc(T.reqNotePh)}" style="margin-bottom:7px">
<button class="mini" data-handle="${r.id}">${T.reqMarkDone}</button></div>`}
</div>`;
}
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
? `<div class="status-line in" style="font-size:12.5px">${T.reqFormPrivate}</div>`
: rf.pin
? `<div class="status-line in" style="font-size:12.5px">${T.reqFormPublic}
· <a href="/pedido" style="color:inherit">${T.reqFormOpenIt}</a></div>`
: `<div class="status-line late" style="font-size:12.5px"><b>${T.reqFormOpen}</b>
· <a href="/pedido" style="color:inherit">${T.reqFormOpenIt}</a></div>`;
$scroll.innerHTML = `<div class="pane">
<div class="sec">${T.reqInbox}${pending.length ? `<span class="badge-count">${pending.length}</span>` : ""}</div>
${status}
${pending.length ? pending.map(requestCard).join("")
: `<div class="empty">${T.reqNone}</div>`}
${done.length ? `<div class="sec">${T.reqHandled}</div>${done.map(requestCard).join("")}` : ""}
<div class="sec">${T.newEntry}</div>
<div class="note-box">
<textarea id="jt" placeholder="${esc(T.journalPlaceholder)}"></textarea>
<div style="text-align:right;margin-top:8px"><button class="mini" id="jadd">${T.addJournal}</button></div></div>
<div class="sec">${T.journalTitle}</div>
${S.journal.length ? S.journal.map((j) => `<div class="row" style="align-items:flex-start;cursor:default">
<span class="row-n" style="width:52px;text-align:left;padding-top:2px">${short(j.date)}</span>
<span class="row-main" style="white-space:normal">
<span style="font-size:14px;line-height:1.5">${esc(j.text)}</span>
${j.author ? `<span class="row-s">${esc(j.author)}</span>` : ""}</span></div>`).join("")
: `<div class="empty">${T.journalEmpty}</div>`}
<p class="note">${T.journalNote}</p></div>`;
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 = '<div class="grab"></div>' + 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(`<h2>${esc(d.num)} · ${esc(d.name)}</h2><p class="sub">${esc(d.building)}</p>
<div class="status-line ${out.length ? "out" : "in"}">
${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)}</div>
<dl class="facts">
<div class="fact"><dt>${T.fLockGroup}</dt><dd class="mono">${esc(d.lock_group || "—")}</dd></div>
<div class="fact"><dt>${T.fCopiesLiveIn}</dt><dd>${esc([...new Set(c.map((i) => cab(i.cabinet_id).name))].join(", ") || "—")}</dd></div></dl>
${d.notes ? `<div class="note-box"><span class="cap">${T.noteOnDoor}</span><div style="font-size:14px">${esc(d.notes)}</div></div>` : ""}
<div class="sec">${T.everyCopy}</div>${c.map(itemRow).join("")}
${sameLock.length ? `<div class="sec">${T.sameLock}</div>${sameLock.map(doorRow).join("")}` : ""}
<div class="actions" style="margin-top:16px"><button class="btn btn-g" data-close>${T.close}</button></div>`);
}
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
? `<div class="status-line 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))}</div>`
: i.status === "out"
? `<div class="status-line 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}</div>`
: i.status === "reserve"
? `<div class="status-line reserve">${T.reserveLine}</div>`
: i.status === "lost" ? `<div class="status-line lost">${T.lostLine}</div>`
: i.status === "retired" ? `<div class="status-line retired">${T.retiredLine}</div>`
: `<div class="status-line in">${T.inCabinet(`<b>${esc(c.full_name)}</b>`)}${i.hook ? T.atHook(esc(i.hook)) : ""}</div>`;
openSheet(`<h2>${esc(i.tag)}</h2>
<p class="sub">${esc(d ? d.name : i.zone)}${i.building ? " · " + esc(i.building) : ""}</p>${status}
${l && l.note && i.status === "out" ? `<div class="note-box"><span class="cap">${T.noteOnHandover}</span>
<div style="font-size:14px">${esc(l.note)}</div></div>` : ""}
<dl class="facts">
<div class="fact"><dt>${T.fType}</dt><dd>${esc(kindOf(i.kind).name)}</dd></div>
<div class="fact"><dt>${T.fKeptIn}</dt><dd>${esc(c.full_name)}${c.note ? `<span class="tiny">${esc(c.note)}</span>` : ""}</dd></div>
${i.hook ? `<div class="fact"><dt>${T.fHook}</dt><dd class="mono">${esc(i.hook)}</dd></div>` : ""}
${d ? `<div class="fact"><dt>${T.fLockGroup}</dt><dd class="mono">${esc(d.lock_group || "—")}</dd></div>` : ""}
${i.uid ? `<div class="fact"><dt>${T.fChipUid}</dt><dd class="mono">${esc(i.uid)}</dd></div>` : ""}
${i.is_master ? `<div class="fact"><dt>${T.fMaster}</dt><dd>${T.yes}</dd></div>` : ""}</dl>
${d ? `<button class="row" data-door="${d.id}" style="margin-bottom:14px">
<span class="badge door">${esc(d.num)}</span>
<span class="row-main"><span class="row-t">${T.seeAllCopies(copiesOf(d.id).length)}</span>
<span class="row-s">${esc(d.name)}</span></span></button>` : ""}
<div class="note-box"><span class="cap">${T.noteOnKey}</span>
<textarea id="inote" placeholder="${esc(T.notePlaceholder)}">${esc(i.notes)}</textarea>
<div style="text-align:right;margin-top:6px"><button class="mini" data-savenote="${i.id}">${T.saveNote}</button></div></div>
<div class="actions">
${i.status === "in" || i.status === "reserve"
? `<button class="btn btn-o" data-issue="${i.id}">${i.status === "reserve" ? T.breakSeal : T.issueTo}</button>`
: i.status === "out" ? `<button class="btn btn-p" data-return="${i.id}">${T.takeBack}</button>` : ""}
<button class="btn btn-g" data-close>${T.close}</button></div>
<div class="sec">${T.history}</div>
${hist.length ? `<div class="log">${hist.map((h) => {
const p = person(h.person_id);
const hLate = !h.in_date && h.due_date && h.due_date < S.today;
return `<div class="log-row"><span class="log-d">${short(h.out_date)}</span>
<span class="log-b">${esc(p ? p.name : "?")}
<div style="color:var(--slate);font-size:12px">${h.in_date ? T.returnedOn(fmt(h.in_date)) : T.stillHolding}</div>
${h.note ? `<div class="memo-line">${esc(h.note)}</div>` : ""}
${h.signature ? `<div class="sigshot"><img src="${esc(h.signature)}" alt="Signature"></div>` : ""}</span>
<span class="dir ${h.in_date ? "in" : hLate ? "late" : "out"}">${h.in_date ? T.stClosed : hLate ? T.stLate : T.stOpen}</span></div>`;
}).join("")}</div>` : `<div class="empty">${T.neverIssued}</div>`}
<div style="height:8px"></div>`);
}
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(`<h2>${esc(p.name)}</h2><p class="sub">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}</p>
<dl class="facts">
<div class="fact"><dt>${T.fHoldingNow}</dt><dd>${T.itemsN(held.length)}${
held.length ? `<span class="tiny">${esc(T.fromCabinets([...new Set(held.map((i) => cab(i.cabinet_id).name))].join(", ")))}</span>` : ""}</dd></div>
<div class="fact"><dt>${T.fReturnedPast}</dt><dd>${past.length}</dd></div>
${p.phone ? `<div class="fact"><dt>${T.fPhone}</dt><dd class="mono">${esc(p.phone)}</dd></div>` : ""}</dl>
${held.length ? `<div class="sec">${T.currentlyHolds}</div>${held.map(itemRow).join("")}
<a class="btn btn-g" style="display:block;text-decoration:none;margin:10px 0 4px"
href="/report?view=person&id=${p.id}">${T.printPdf} — ${T.repReceiptTitle}</a>`
: `<div class="empty">${T.holdsNothing}</div>`}
${past.length ? `<div class="sec">${T.returnedSection}</div><div class="log">${past.map((h) => {
const it = item(h.item_id);
return `<div class="log-row"><span class="log-d">${short(h.in_date)}</span>
<span class="log-b"><span class="log-tag">${esc(it ? it.tag : "?")}</span>
<div style="color:var(--slate);font-size:12px">${T.heldFromTo(fmt(h.out_date), fmt(h.in_date))}</div></span></div>`;
}).join("")}</div>` : ""}
<div class="actions" style="margin-top:16px"><button class="btn btn-g" data-close>${T.close}</button></div>`);
}
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 `<div class="empty">${q ? esc(T.nothingFound(pickQuery))
: T.noActivePeople + ` <a href="/admin">${T.admin}</a>.`}</div>`;
}
return active.map((p) => {
const holds = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out").length;
return `<button class="row" data-give="${p.id}" data-on="${itemId}">
<span class="badge person">${esc(initials(p.name))}</span>
<span class="row-main"><span class="row-t">${esc(p.name)}</span>
<span class="row-s">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}</span></span>
<span class="row-n">${holds ? T.itemsN(holds) : ""}</span></button>`;
}).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(`<h2>${esc(T.issueTitle(i.tag))}</h2>
<p class="sub">${esc(i.door_id ? door(i.door_id).name : i.zone)} · ${esc(T.fromThe(cab(i.cabinet_id).full_name))}</p>
${i.status === "reserve" ? `<div class="status-line reserve">${T.reserveWarn}</div>` : ""}
<div class="sec">${T.whoTakes}</div>
<input class="search" id="pq" placeholder="${esc(T.searchPerson)}" autocomplete="off">
<div id="plist">${personPickList(i.id)}</div>
<div class="actions" style="margin-top:14px"><button class="btn btn-g" data-close>${T.cancel}</button></div>`);
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(`<h2>${T.handover}</h2>
<p class="sub">${esc(i.tag)} · ${esc(i.door_id ? door(i.door_id).name : i.zone)} → ${esc(p.name)}</p>
<div class="sec">${T.backBy}</div>
<div class="due-opts">${[["none", T.dNone], ["7", T.d7], ["30", T.d30], ["90", T.d90]]
.map(([k, l]) => `<button class="due" data-due="${k}" aria-pressed="${pending.dueKey === k}">${l}</button>`).join("")}</div>
<div class="note-box"><span class="cap">${T.whyNote}</span>
<textarea id="hnote" placeholder="${esc(T.whyPlaceholder)}">${esc(pending.note)}</textarea></div>
<div class="sec">${T.signature}</div>
<div class="sigpad"><canvas id="sig"></canvas><div class="sigline"></div>
${pending.sig ? "" : `<div class="hint" id="sighint">${T.signHint}</div>`}</div>
<div style="text-align:right;margin-bottom:14px">
<button class="mini" id="sigclear" style="background:var(--card);color:var(--slate);border:1px solid var(--line)">${T.clearSig}</button></div>
<div class="actions">
<button class="btn btn-o" id="confirm">${T.confirmHandover}</button>
<button class="btn btn-g" data-close>${T.cancel}</button></div>`);
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}<span class="badge-count">${S.pending}</span>`;
document.title = T.brand;
}
function render() {
({ board: viewBoard, doors: viewDoors, people: viewPeople, log: viewLog, journal: viewJournal })[tab]();
}
load();