/* Key register — admin panel */
"use strict";
let S = null, page = "items", q = "";
const $body = document.getElementById("body");
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 cab = (id) => S.cabinets.find((c) => c.id === id) || { name: "—", full_name: "Unassigned" };
const door = (id) => S.doors.find((d) => d.id === +id);
let tt;
function toast(m, err) {
$toast.textContent = m; $toast.className = "toast on" + (err ? " err" : "");
clearTimeout(tt); tt = setTimeout(() => ($toast.className = "toast"), 3000);
}
async function api(url, opts) {
const r = await fetch(url, opts);
const b = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(b.error || r.statusText);
return b;
}
async function load() {
try {
S = await api("/api/state");
setLang(S.lang);
chrome();
render();
} catch (e) { $body.innerHTML = `
${esc(T.noServer)}
${esc(e.message)}
`; }
}
function openSheet(h) {
$sheet.innerHTML = '' + h;
$sheet.classList.add("on"); $scrim.classList.add("on"); $sheet.scrollTop = 0;
}
function closeSheet() { $sheet.classList.remove("on"); $scrim.classList.remove("on"); }
$scrim.onclick = closeSheet;
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSheet(); });
const val = (id) => (document.getElementById(id) || {}).value || "";
const checked = (id) => !!(document.getElementById(id) || {}).checked;
async function post(url, data, msg) {
try {
const r = await api(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
closeSheet(); await load(); toast(r.note || msg || T.tSaved);
} catch (e) { toast(errMsg(e.message), true); }
}
async function del(url, msg) {
try {
const r = await api(url, { method: "DELETE" });
closeSheet(); await load(); toast(r.note || msg || T.tRemoved);
} catch (e) { toast(errMsg(e.message), true); }
}
/* ------------------------------------------------------------- keys & tags */
function pageItems() {
const term = q.trim().toLowerCase();
const list = S.items.filter((i) => !term ||
(i.tag + " " + (i.door_id ? door(i.door_id).name : i.zone) + " " + i.building + " " + i.notes).toLowerCase().includes(term));
$body.innerHTML = `
| ${T.aTag} | ${T.aDoor} | ${T.aCabinet} | ${T.aHook} | ${T.aStatus} | |
${list.map((i) => `
| ${esc(i.tag)}${i.is_master ? ' M' : ""}
${esc((S.kinds.find((k) => k.id === i.kind) || {}).name || i.kind)} |
${esc(i.door_id ? door(i.door_id).name : i.zone)} ${esc(i.building)} |
${esc(cab(i.cabinet_id).name)} |
${esc(i.hook)} |
${esc((T.statuses && T.statuses[i.status]) || i.status)} |
|
`).join("") || `| ${T.aNothing} |
`}
`;
const el = document.getElementById("q");
el.oninput = (e) => { q = e.target.value; pageItems(); const f = document.getElementById("q"); f.focus(); f.setSelectionRange(q.length, q.length); };
}
function sheetItem(id) {
const i = id ? S.items.find((x) => x.id === +id) : { tag: "", kind: "key", door_id: "", zone: "", cabinet_id: "", hook: "", is_master: 0, uid: "", status: "in", notes: "" };
openSheet(`${id ? T.aEdit + " " + esc(i.tag) : T.aAddItem.replace("+ ", "")}
${T.vKey} / ${T.vTag}
${T.fZoneKeys}
${T.fZoneTags}
${T.fPermNote}
${id ? `
${T.retireNote}
` : ""}`);
document.getElementById("save").onclick = () => post("/api/admin/items", {
id: id || undefined, tag: val("f_tag").trim(), kind: val("f_kind"),
door_id: val("f_door") ? +val("f_door") : null, zone: val("f_zone").trim(),
cabinet_id: val("f_cab") || null, hook: val("f_hook").trim(),
is_master: checked("f_master") ? 1 : 0, uid: val("f_uid").trim(),
status: val("f_status"), notes: val("f_notes").trim(),
});
if (id) document.getElementById("rm").onclick = () => del("/api/admin/items/" + id);
}
/* -------------------------------------------------------------------- doors */
function pageDoors() {
const groups = {};
S.doors.forEach((d) => (groups[d.building] = groups[d.building] || []).push(d));
$body.innerHTML = `
${T.aDoors}
${T.aDoorsHelp(S.doors.length)}
${Object.entries(groups).map(([b, list]) => `
${esc(b)}
| ${T.aNo} | ${T.aDoors} | ${T.aLock} | ${T.aCopies} | |
${list.map((d) => `| ${esc(d.num)} | ${esc(d.name)} |
${esc(d.lock_group)} |
${S.items.filter((i) => i.door_id === d.id).length} |
|
`).join("")}
`).join("") || `${T.aNothing}
`}`;
}
function sheetDoor(id) {
const d = id ? door(id) : { num: "", building: "", name: "", lock_group: "", notes: "" };
const buildings = [...new Set(S.doors.map((x) => x.building))];
openSheet(`${id ? T.aEdit + " " + esc(d.num) : T.aAddDoor.replace("+ ", "")}
${T.fBuilding}
${T.fDoorName}
${T.fNote}
${id ? `` : ""}
${T.lockGroupNote}
`);
document.getElementById("save").onclick = () => post("/api/admin/doors", {
id: id || undefined, num: val("f_num").trim(), building: val("f_building").trim(),
name: val("f_name").trim(), lock_group: val("f_group").trim(), notes: val("f_notes").trim(),
});
if (id) document.getElementById("rm").onclick = () => del("/api/admin/doors/" + id);
}
/* ------------------------------------------------------------------- people */
function pagePeople() {
$body.innerHTML = `
${T.aPeople}
${T.aPeopleHelp}
| ${T.aName} | ${T.aDept} | ${T.aHolding} | |
${S.people.map((p) => {
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out").length;
return `| ${esc(p.name)}${p.active ? "" : ` ${T.inactive}`}
${esc(p.role)} |
${esc(p.dept)} | ${held || "—"} |
|
`;
}).join("") || `| ${T.aNothing} |
`}
`;
}
function sheetPerson(id) {
const p = id ? S.people.find((x) => x.id === +id) : { name: "", dept: "", role: "", phone: "", email: "", active: 1 };
const held = id ? S.items.filter((i) => i.loan && i.loan.person_id === +id && i.status === "out") : [];
openSheet(`${id ? T.aEdit : T.aAddPerson.replace("+ ", "")}
${T.aName}
${held.length ? `${esc(T.holdsWarn(held.length, held.map((i) => i.tag).join(", ")))}
` : ""}
${id ? `
${T.peopleNote}
` : ""}`);
document.getElementById("save").onclick = () => post("/api/admin/people", {
id: id || undefined, name: val("f_name").trim(), dept: val("f_dept").trim(),
role: val("f_role").trim(), phone: val("f_phone").trim(), email: val("f_email").trim(),
active: checked("f_active") ? 1 : 0,
});
if (id) document.getElementById("rm").onclick = () => del("/api/admin/people/" + id);
}
/* ----------------------------------------------------------------- cabinets */
function pageCabinets() {
$body.innerHTML = `
${T.aCabinets}
${T.aCabinetsHelp}
${S.cabinets.map((c) => `
${esc(c.full_name)}
${esc(c.id)}${c.off_site ? " · " + T.aOffSite : ""}
${esc(c.note)}
${T.aItemsCount(S.items.filter((i) => i.cabinet_id === c.id).length)}
`).join("")
|| `${T.aNothing}
`}`;
}
function sheetCabinet(id) {
const c = id ? S.cabinets.find((x) => x.id === id) : { id: "", name: "", full_name: "", note: "", off_site: 0, sort: S.cabinets.length + 1 };
openSheet(`${id ? T.aEdit : T.aAddCabinet.replace("+ ", "")}
${T.fFullName}
${T.fWhereIs}
${T.offSiteNote}
${T.fSort}
${T.fSortHelp}
${id ? `` : ""}`);
document.getElementById("save").onclick = () => post("/api/admin/cabinets", {
id: val("f_id").trim().toUpperCase(), name: val("f_name").trim(),
full_name: val("f_full").trim() || val("f_name").trim(), note: val("f_note").trim(),
off_site: checked("f_off") ? 1 : 0, sort: +val("f_sort") || 0,
});
if (id) document.getElementById("rm").onclick = () => del("/api/admin/cabinets/" + id);
}
/* -------------------------------------------------------------------- types */
const SHAPE_CSS = { circle: "border-radius:50%", rounded: "border-radius:5px", square: "border-radius:0" };
function pageKinds() {
$body.innerHTML = `
${T.aKinds}
${T.aKindsHelp}
${S.kinds.map((k) => {
const n = S.items.filter((i) => i.kind === k.id).length;
return `
${esc(k.name)}
${esc(k.id)} · ${esc(k.colour)}
${T.kindInUse(n)}
`;
}).join("")}`;
}
function sheetKind(id) {
const k = id ? S.kinds.find((x) => x.id === id)
: { id: "", name: "", shape: "circle", colour: "#2E6E60", sort: S.kinds.length + 1 };
const used = id ? S.items.filter((i) => i.kind === id).length : 0;
openSheet(`${id ? T.aEdit : T.aAddKind.replace("+ ", "")}
${T.fSort}
${T.kindNote}
${used ? `${T.kindInUse(used)}
` : ""}
${id ? `` : ""}`);
document.getElementById("save").onclick = () => post("/api/admin/kinds", {
id: val("f_id"), name: val("f_name").trim(), shape: val("f_shape"),
colour: val("f_colour"), sort: +val("f_sort") || 0,
});
if (id) document.getElementById("rm").onclick = () => del("/api/admin/kinds/" + id);
}
/* --------------------------------------------------------- import / backup */
let impKind = "items";
const IMP_SPEC = {
items: { url: "/api/admin/import", req: "tag, door, building",
opt: "lock_group, cabinet, hook, master, status, kind, notes",
ex: "tag,door,building,lock_group,cabinet,hook\n12-A,Sacristia,Igreja,B,SAC,SAC·R1·03\n12-B,Sacristia,Igreja,B,SEC,SEC·R2·01" },
doors: { url: "/api/admin/import/doors", req: "num, building, name",
opt: "lock_group, notes",
ex: "num,building,name,lock_group\n01,Igreja,Entrada principal,A\n02,Igreja,Sacristia,B" },
people: { url: "/api/admin/import/people", req: "name",
opt: "dept, role, phone, email, active",
ex: "name,dept,role,phone\nMaria Fonseca,Secretaria,Secretária,910000000\nJoão Ribeiro,Manutenção,Sacristão," },
cabinets: { url: "/api/admin/import/cabinets", req: "id, name",
opt: "full_name, note, off_site, sort",
ex: "id,name,full_name,note,off_site\nSAC,Sacristia,Armário da sacristia,Atrás da porta,0\nCASA,Reserva,Conjunto de reserva,Caixa selada fora,1" },
};
function pageData() {
const spec = IMP_SPEC[impKind];
$body.innerHTML = `
${T.impTitle}
${T.impWhat}
${[["items", T.impItems], ["doors", T.impDoors], ["people", T.impPeople], ["cabinets", T.impCabinets]]
.map(([k, l]) => ``).join("")}
${esc(spec.req)}${spec.opt ? " · " + esc(spec.opt) : ""}
`;
document.getElementById("imp").onclick = async () => {
const csv = document.getElementById("csv").value;
if (!csv.trim()) return toast(T.impNothing, true);
try {
const r = await api(spec.url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ csv }) });
/* the combined importer reports items/doors/cabinets, the single-table
ones report created/updated — show whichever came back */
const body = r.items !== undefined
? T.impResult(r.items, r.doors, r.cabinets)
: T.impResult2(r.created, r.updated);
document.getElementById("impres").innerHTML =
`${body}${
r.skipped && r.skipped.length
? T.impSkipped(r.skipped.length, esc(r.skipped.slice(0, 12).join(", ")) + (r.skipped.length > 12 ? "…" : ""))
: ""}
`;
await load(); toast(T.imported);
} catch (e) { toast(errMsg(e.message), true); }
};
document.getElementById("wipebtn").onclick = async () => {
try {
await api("/api/admin/wipe", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: document.getElementById("wipe").value }) });
await load(); toast(T.wipeDone);
} catch (e) { toast(errMsg(e.message), true); }
};
}
/* -------------------------------------------------------------------- audit */
async function pageAudit() {
let rows = [];
try { rows = await api("/api/admin/audit"); } catch (e) { /* non-fatal */ }
$body.innerHTML = `${T.auditTitle}
${T.auditHelp}
| ${T.aWhen} | ${T.aWhat} | ${T.aDetail} |
${rows.map((r) => `| ${esc(r.ts.slice(0, 16).replace("T", " "))} |
${esc(r.action)} | ${esc(r.detail)} |
`).join("")
|| `| ${T.aNothing} |
`}
`;
}
/* ------------------------------------------------------------------ events */
document.addEventListener("click", (e) => {
const t = e.target.closest("[data-p],[data-imp],[data-new],[data-edit-item],[data-edit-door],[data-edit-person],[data-edit-cab],[data-edit-kind],[data-close]");
if (!t) return;
if (t.dataset.close !== undefined) return closeSheet();
if (t.dataset.imp) { impKind = t.dataset.imp; return pageData(); }
if (t.dataset.p) {
page = t.dataset.p; q = "";
document.querySelectorAll("#nav button").forEach((b) => b.setAttribute("aria-pressed", b.dataset.p === page));
return render();
}
if (t.dataset.new === "item") return sheetItem(null);
if (t.dataset.new === "door") return sheetDoor(null);
if (t.dataset.new === "person") return sheetPerson(null);
if (t.dataset.new === "cabinet") return sheetCabinet(null);
if (t.dataset.new === "kind") return sheetKind(null);
if (t.dataset.editItem) return sheetItem(t.dataset.editItem);
if (t.dataset.editDoor) return sheetDoor(t.dataset.editDoor);
if (t.dataset.editPerson) return sheetPerson(t.dataset.editPerson);
if (t.dataset.editCab) return sheetCabinet(t.dataset.editCab);
if (t.dataset.editKind) return sheetKind(t.dataset.editKind);
});
function chrome() {
document.getElementById("brand").textContent = T.brand;
document.getElementById("apptitle").textContent = T.admin;
document.getElementById("backlink").title = T.back;
document.querySelectorAll("[data-t]").forEach((el) => (el.textContent = T[el.dataset.t]));
document.title = T.brand + " — " + T.admin;
}
function render() {
({ items: pageItems, doors: pageDoors, people: pagePeople, cabinets: pageCabinets, kinds: pageKinds, data: pageData, audit: pageAudit })[page]();
}
load();