/* ==================================================================
Operations → Sheet Builder
------------------------------------------------------------------
The office's hand-made checklists, built over the live roster:
1. pick classes (and boarders / day scholars),
2. pick roster fields — Roll, Name, Father's name, …,
3. add hand-entered columns — "Collected marksheet" as a tick box,
"Remarks" as text, …,
then fill it in on screen and export CSV / Excel / PDF.
A sheet is saved once (Save sheet); after that every change saves
itself — config with a short debounce, each typed cell on its own
(PATCH …/cells), so two people ticking the same list never overwrite
each other. Roster fields are never stored: they are read live, so
an old sheet picks up a name corrected in the Ledger.
Backend: backend/src/routes/admin_sheets.ts. Every top-level name
here is prefixed Sb/SB_ — the cockpit's JSX files share one global
scope, and a clashing `const` kills the whole later file.
================================================================== */
const SB_COLUMN_TYPES = [
{ value: "check", label: "Tick box" },
{ value: "text", label: "Text" },
{ value: "number", label: "Number" },
{ value: "date", label: "Date" },
];
// The columns the office keeps asking for, one tap each.
const SB_PRESETS = [
{ label: "Collected marksheet", type: "check" },
{ label: "Applied for scholarship", type: "check" },
{ label: "Documents submitted", type: "check" },
{ label: "Fees cleared", type: "check" },
{ label: "Signature", type: "text" },
{ label: "Remarks", type: "text" },
];
const SB_DEFAULT_FIELDS = ["sl", "roll_no", "name", "class_label"];
// Classes in school order, not alphabetical ("IX" must not sort before "V").
const SB_CLASS_ORDER = ["NURSERY", "INFANT I", "INFANT II", "I", "II", "III", "IV", "V",
"VI", "VII", "VIII", "IX", "X", "XI", "XII"];
const sbClassRank = (level) => {
const i = SB_CLASS_ORDER.indexOf(String(level || "").trim().toUpperCase());
return i === -1 ? 999 : i;
};
const sbRollKey = (roll) => {
const m = String(roll || "").match(/(\d+)\s*$/);
return m ? Number(m[1]) : Number.MAX_SAFE_INTEGER;
};
const sbNewId = () => "c" + Math.random().toString(36).slice(2, 9);
const sbBlankSheet = () => ({
id: null,
title: "",
class_section_ids: [],
student_type: "",
fields: [...SB_DEFAULT_FIELDS],
custom_columns: [{ id: sbNewId(), label: "Collected marksheet", type: "check" }],
cell_values: {},
});
const sbCustomWidth = (c) =>
c.type === "check" ? Math.min(16, Math.max(8, Math.ceil(c.label.length * 0.75)))
: c.type === "number" ? 10
: c.type === "date" ? 12
: 22;
// Same quoting rule as CX.exportCsv, plus a byte-order mark: without it Excel
// opens a UTF-8 file as Latin-1 and every Bengali name turns to mojibake.
const sbDownloadCsv = (filename, rows) => {
const csv = "" + rows
.map((r) => r.map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`).join(","))
.join("\r\n");
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
};
const sbSlug = (s) =>
(String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "sheet")
+ "-" + new Date().toISOString().slice(0, 10);
const SB_CSS = `
.sb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.sb-tile { text-align: left; cursor: pointer; padding: 18px 20px; display: flex; flex-direction: column; gap: 6px;
border: 1px solid transparent; transition: border-color .12s; font: inherit; color: inherit; }
.sb-tile:hover { border-color: var(--line-strong); }
.sb-tile-title { font-size: 15px; font-weight: 600; color: var(--ink-0); overflow-wrap: anywhere; }
.sb-tile-meta { font-size: 12px; color: var(--ink-3); }
.sb-setup { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 22px; }
.sb-step-label { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 10px; }
.sb-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.sb-chip { border: 1px solid var(--line-strong); background: var(--surface); color: var(--ink-1); border-radius: 999px;
padding: 5px 11px; font-size: 12px; cursor: pointer; font: inherit; font-size: 12px; line-height: 1.3; }
.sb-chip:hover { background: var(--bg-2); }
.sb-chip.on { background: var(--ink-btn); color: var(--on-solid); border-color: transparent; }
.sb-chip.add { border-style: dashed; color: var(--ink-2); }
.sb-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.sb-item { display: flex; align-items: center; gap: 6px; background: var(--bg-2); border-radius: 10px; padding: 5px 6px 5px 10px; }
.sb-item-label { flex: 1; min-width: 0; font-size: 12.5px; color: var(--ink-0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sb-item .input, .sb-item .select { padding: 5px 8px; font-size: 12.5px; border-radius: 8px; }
.sb-item .select { width: auto; flex: none; }
.sb-ibtn { border: 0; background: transparent; color: var(--ink-3); cursor: pointer; width: 26px; height: 26px;
border-radius: 7px; font-size: 13px; line-height: 1; flex: none; }
.sb-ibtn:hover:not(:disabled) { background: var(--bg-3); color: var(--ink-0); }
.sb-ibtn:disabled { opacity: .3; cursor: default; }
.sb-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; }
.sb-table-wrap { overflow: auto; max-height: 70vh; border: 1px solid var(--line); border-radius: 12px; }
.sb-table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 12.5px; }
.sb-table th { position: sticky; top: 0; z-index: 1; background: var(--bg-2); text-align: left; font-weight: 600;
color: var(--ink-1); padding: 9px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
.sb-table th.sb-own { background: var(--accent-bg); min-width: 120px; }
.sb-table td { padding: 5px 10px; border-bottom: 1px solid var(--line); color: var(--ink-0); vertical-align: middle; }
.sb-table tr:last-child td { border-bottom: 0; }
.sb-table td.sb-num { text-align: right; color: var(--ink-3); font-variant-numeric: tabular-nums; }
.sb-table td.sb-tick, .sb-table th.sb-tick { text-align: center; }
.sb-table td.sb-own { background: color-mix(in srgb, var(--blue-solid) 3%, transparent); }
.sb-table input[type=checkbox] { width: 17px; height: 17px; cursor: pointer; accent-color: var(--accent); }
.sb-cell { border: 1px solid transparent; background: transparent; border-radius: 6px; padding: 4px 6px;
font: inherit; color: inherit; width: 100%; min-width: 120px; }
.sb-cell:hover { border-color: var(--line-strong); background: var(--surface); }
.sb-cell:focus { outline: none; border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-bg); }
.sb-count { font-weight: 500; color: var(--ink-3); margin-left: 4px; }
.sb-status { font-size: 12px; color: var(--ink-3); white-space: nowrap; }
.sb-err { margin-bottom: 14px; padding: 10px 14px; border-radius: 10px; background: var(--red-bg); color: var(--red); font-size: 13px; }
@media (max-width: 1100px) { .sb-setup { grid-template-columns: 1fr 1fr; } }
@media (max-width: 760px) {
.sb-setup { grid-template-columns: 1fr; }
.sb-toolbar > * { flex: 1 1 100%; }
.sb-table-wrap { max-height: none; }
}
`;
/* ---------- Saved sheets list ---------- */
const SbList = ({ setScreen, onOpen, onNew }) => {
const [sheets, setSheets] = React.useState(null);
const [err, setErr] = React.useState(null);
const sections = window.APP?.CLASS_SECTIONS || [];
const labelOf = (id) => sections.find((c) => c.id === id)?.label || "?";
const load = React.useCallback(() => {
window.AppApi.get("/admin/sheets").then(setSheets).catch((e) => setErr(String(e.message || e)));
}, []);
React.useEffect(load, [load]);
const remove = async (e, s) => {
e.stopPropagation();
if (!window.confirm(`Delete "${s.title}"? Everything typed into it goes too.`)) return;
try { await window.AppApi.del(`/admin/sheets/${s.id}`); load(); }
catch (ex) { setErr(String(ex.message || ex)); }
};
return (
setScreen("finance")}
actions={}
/>
{err && {err}
}
{!sheets && !err && }
{sheets && sheets.length === 0 && (
)}
{sheets && sheets.length > 0 && (
{sheets.map((s) => (
))}
)}
);
};
/* ---------- One text/number/date cell ----------
Holds its own draft so typing never re-renders the whole table, and
commits on blur or Enter — one save per finished entry, not per key. */
const SbCellInput = ({ type, value, onCommit }) => {
const [draft, setDraft] = React.useState(value ?? "");
React.useEffect(() => { setDraft(value ?? ""); }, [value]);
const commit = () => { if (String(draft) !== String(value ?? "")) onCommit(draft); };
return (
setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}/>
);
};
/* ---------- Editor ---------- */
const SbEditor = ({ sheetId, onBack, onCreated }) => {
const [sheet, setSheet] = React.useState(sheetId ? null : sbBlankSheet());
const [fieldsCatalog, setFieldsCatalog] = React.useState([]);
const [roster, setRoster] = React.useState(null);
const [err, setErr] = React.useState(null);
const [status, setStatus] = React.useState(sheetId ? "saved" : "new"); // new | saving | saved | error
const [showSetup, setShowSetup] = React.useState(!sheetId);
const [search, setSearch] = React.useState("");
const [onlyMissing, setOnlyMissing] = React.useState(""); // column id
const [exporting, setExporting] = React.useState(null);
const [newColLabel, setNewColLabel] = React.useState("");
const [newColType, setNewColType] = React.useState("check");
const sections = React.useMemo(() => {
const all = window.APP?.CLASS_SECTIONS || [];
return [...all].sort((a, b) =>
String(b.academic_year).localeCompare(String(a.academic_year))
|| sbClassRank(a.class_level) - sbClassRank(b.class_level)
|| String(a.section || "").localeCompare(String(b.section || "")));
}, []);
const years = React.useMemo(() => [...new Set(sections.map((c) => c.academic_year))], [sections]);
const [year, setYear] = React.useState(years[0] || "");
// ── Load ──
React.useEffect(() => {
window.AppApi.get("/admin/sheets/fields").then(setFieldsCatalog).catch((e) => setErr(String(e.message || e)));
}, []);
React.useEffect(() => {
if (!sheetId) return;
window.AppApi.get(`/admin/sheets/${sheetId}`)
.then((s) => {
setSheet({ ...s, cell_values: s.cell_values || {} });
// Open on the year the sheet's classes belong to.
const y = sections.find((c) => c.id === s.class_section_ids?.[0])?.academic_year;
if (y) setYear(y);
})
.catch((e) => setErr(String(e.message || e)));
}, [sheetId]);
const rosterKey = sheet ? `${[...sheet.class_section_ids].sort().join(",")}|${sheet.student_type}` : null;
React.useEffect(() => {
if (!sheet) return;
let alive = true;
setRoster(null);
const qs = new URLSearchParams();
if (sheet.class_section_ids.length) qs.set("class_section_ids", sheet.class_section_ids.join(","));
if (sheet.student_type) qs.set("student_type", sheet.student_type);
window.AppApi.get(`/admin/sheets/roster?${qs}`)
.then((r) => { if (alive) setRoster(r); })
.catch((e) => { if (alive) setErr(String(e.message || e)); });
return () => { alive = false; };
}, [rosterKey]);
// ── Config autosave (saved sheets only) ──
const configKey = sheet ? JSON.stringify([sheet.title, sheet.class_section_ids, sheet.student_type, sheet.fields, sheet.custom_columns]) : "";
const lastSavedConfig = React.useRef(null);
React.useEffect(() => {
if (!sheet?.id) return;
if (lastSavedConfig.current === null) { lastSavedConfig.current = configKey; return; }
if (lastSavedConfig.current === configKey) return;
if (!sheet.title.trim()) return; // the server refuses an untitled sheet
if (!sheet.fields.length && !sheet.custom_columns.some((c) => c.label.trim())) return;
setStatus("saving");
const t = setTimeout(async () => {
try {
const cfg = sheetConfig(sheet);
await window.AppApi.put(`/admin/sheets/${sheet.id}`, cfg);
lastSavedConfig.current = configKey;
// The server drops the cells of removed columns; do the same locally.
// Filtering rather than adopting the response's cell_values, so a tick
// PATCHed while this PUT was in flight is not rolled back on screen.
const keep = new Set(cfg.custom_columns.map((c) => c.id));
setSheet((s) => ({
...s,
cell_values: Object.fromEntries(Object.entries(s.cell_values || {}).map(([sid, row]) => [
sid, Object.fromEntries(Object.entries(row).filter(([cid]) => keep.has(cid))),
])),
}));
setStatus("saved"); setErr(null);
} catch (e) { setStatus("error"); setErr(String(e.message || e)); }
}, 700);
return () => clearTimeout(t);
}, [configKey, sheet?.id]);
const sheetConfig = (s) => ({
title: s.title.trim(),
class_section_ids: s.class_section_ids,
student_type: s.student_type,
fields: s.fields,
custom_columns: s.custom_columns.filter((c) => c.label.trim()).map((c) => ({ ...c, label: c.label.trim() })),
});
const saveNew = async () => {
if (!sheet.title.trim()) { setErr("Give the sheet a name first — e.g. \"Class X marksheet collection\"."); return; }
setStatus("saving"); setErr(null);
try {
const out = await window.AppApi.post("/admin/sheets", { ...sheetConfig(sheet), cell_values: sheet.cell_values });
lastSavedConfig.current = null;
setSheet({ ...out, cell_values: out.cell_values || {} });
setStatus("saved");
onCreated(out.id);
} catch (e) { setStatus("error"); setErr(String(e.message || e)); }
};
// ── Edits ──
const patch = (fn) => setSheet((s) => ({ ...s, ...fn(s) }));
const setCell = async (studentId, col, raw) => {
const value = col.type === "check" ? (raw ? true : null)
: (raw === "" || raw === null || raw === undefined) ? null
: col.type === "number" ? Number(raw) : raw;
const prev = sheet.cell_values?.[studentId]?.[col.id];
const write = (v) => setSheet((s) => {
const row = { ...(s.cell_values[studentId] || {}) };
if (v === null || v === undefined) delete row[col.id]; else row[col.id] = v;
return { ...s, cell_values: { ...s.cell_values, [studentId]: row } };
});
write(value);
if (!sheet.id) return; // unsaved: kept locally, sent with the first save
try {
setStatus("saving");
await window.AppApi.patch(`/admin/sheets/${sheet.id}/cells`, { student_id: studentId, column_id: col.id, value });
setStatus("saved");
} catch (e) {
write(prev ?? null);
setStatus("error"); setErr(`Not saved: ${String(e.message || e)}`);
}
};
const toggleSection = (id) => patch((s) => ({
class_section_ids: s.class_section_ids.includes(id)
? s.class_section_ids.filter((x) => x !== id)
: [...s.class_section_ids, id],
}));
const toggleField = (key) => patch((s) => ({
fields: s.fields.includes(key) ? s.fields.filter((k) => k !== key) : [...s.fields, key],
}));
const moveIn = (listKey, idx, dir) => patch((s) => {
const arr = [...s[listKey]];
const j = idx + dir;
if (j < 0 || j >= arr.length) return {};
[arr[idx], arr[j]] = [arr[j], arr[idx]];
return { [listKey]: arr };
});
const addColumn = (label, type) => {
const l = String(label || "").trim();
if (!l) return;
patch((s) => ({ custom_columns: [...s.custom_columns, { id: sbNewId(), label: l, type }] }));
setNewColLabel("");
};
const updateColumn = (id, change) => patch((s) => ({
custom_columns: s.custom_columns.map((c) => (c.id === id ? { ...c, ...change } : c)),
}));
const removeColumn = (col) => {
const filled = Object.values(sheet.cell_values || {}).filter((r) => r[col.id] != null).length;
if (filled && !window.confirm(`Remove "${col.label}"? The ${filled} entr${filled === 1 ? "y" : "ies"} in it will be deleted.`)) return;
patch((s) => ({ custom_columns: s.custom_columns.filter((c) => c.id !== col.id) }));
if (onlyMissing === col.id) setOnlyMissing("");
};
// ── Derived table ──
const fieldByKey = React.useMemo(() => Object.fromEntries(fieldsCatalog.map((f) => [f.key, f])), [fieldsCatalog]);
const sectionById = React.useMemo(() => Object.fromEntries(sections.map((c) => [c.id, c])), [sections]);
const fieldCols = sheet ? sheet.fields.map((k) => fieldByKey[k]).filter(Boolean) : [];
const customCols = sheet ? sheet.custom_columns.filter((c) => c.label.trim()) : [];
const ordered = React.useMemo(() => {
if (!roster) return [];
return [...roster].sort((a, b) => {
const ca = sectionById[a.class_section_id], cb = sectionById[b.class_section_id];
return sbClassRank(ca?.class_level) - sbClassRank(cb?.class_level)
|| String(ca?.section || "").localeCompare(String(cb?.section || ""))
|| sbRollKey(a.roll_no) - sbRollKey(b.roll_no)
|| String(a.roll_no).localeCompare(String(b.roll_no))
|| String(a.name).localeCompare(String(b.name));
});
}, [roster, sectionById]);
const shown = React.useMemo(() => {
const q = search.trim().toLowerCase();
return ordered.filter((r) => {
if (onlyMissing && sheet?.cell_values?.[r.id]?.[onlyMissing] != null) return false;
if (!q) return true;
return [r.name, r.roll_no, r.admission_no, r.ledger_student_uid, r.father_name, r.mother_name, r.guardian_name]
.some((v) => String(v || "").toLowerCase().includes(q));
});
}, [ordered, search, onlyMissing, sheet?.cell_values]);
const filledCount = (colId) => ordered.filter((r) => sheet.cell_values?.[r.id]?.[colId] != null).length;
// ── Export ──
const describe = () => {
const cls = sheet.class_section_ids.length
? sheet.class_section_ids.map((id) => sectionById[id]?.label || "?").join(", ")
: "All classes";
const type = sheet.student_type === "boarder" ? " · boarders" : sheet.student_type === "day_scholar" ? " · day scholars" : "";
const filt = onlyMissing ? ` · only rows missing "${customCols.find((c) => c.id === onlyMissing)?.label}"` : "";
const q = search.trim() ? ` · matching "${search.trim()}"` : "";
return `${cls}${type}${filt}${q} · ${shown.length} student${shown.length === 1 ? "" : "s"}`;
};
const exportColumns = () => [
...fieldCols.map((f) => ({ header: f.label, width: f.width, type: f.type || "text" })),
...customCols.map((c) => ({ header: c.label, width: sbCustomWidth(c), type: c.type })),
];
const exportRows = () => shown.map((r, i) => [
...fieldCols.map((f) => (f.key === "sl" ? i + 1 : r[f.key] ?? "")),
...customCols.map((c) => {
const v = sheet.cell_values?.[r.id]?.[c.id];
return c.type === "check" ? v === true : v ?? "";
}),
]);
const doExport = async (kind) => {
if (!fieldCols.length && !customCols.length) { setErr("Pick at least one column to export."); return; }
const title = sheet.title.trim() || "Sheet";
setErr(null);
if (kind === "csv") {
const cols = exportColumns();
sbDownloadCsv(`${sbSlug(title)}.csv`, [
cols.map((c) => c.header),
...exportRows().map((row) => row.map((v) => (v === true ? "Yes" : v === false ? "" : v))),
]);
return;
}
setExporting(kind);
try {
const { blobUrl, filename } = await window.AppApi.download(`/admin/sheets/export.${kind}`, {
title, subtitle: describe(), columns: exportColumns(), rows: exportRows(),
});
const a = document.createElement("a");
if (kind === "pdf" && window.open(blobUrl, "_blank")) {
// Opened in the browser's viewer, where Print is.
} else {
a.href = blobUrl; a.download = filename; a.click();
}
// Revoked late — Safari drops the blob under a viewer still loading it.
setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
} catch (e) { setErr(String(e.message || e)); }
finally { setExporting(null); }
};
if (!sheet) {
return (
);
}
const yearSections = sections.filter((c) => c.academic_year === year);
const unusedFields = fieldsCatalog.filter((f) => !sheet.fields.includes(f.key));
const unusedPresets = SB_PRESETS.filter((p) => !sheet.custom_columns.some((c) => c.label.trim().toLowerCase() === p.label.toLowerCase()));
const statusText = { new: "Not saved yet", saving: "Saving…", saved: "All changes saved", error: "Not saved" }[status];
return (
{statusText}
{!sheet.id && (
)}
>
}
/>
{err && {err}
}
{/* ── Setup ── */}
patch(() => ({ title: e.target.value }))}/>
{showSetup && (
{/* 1. Students */}
1 · Students
{years.length > 1 && (
)}
patch(() => ({ student_type: v }))}
options={[{ value: "", label: "All" }, { value: "boarder", label: "Boarders" }, { value: "day_scholar", label: "Day" }]}/>
{yearSections.map((c) => (
))}
{/* 2. Roster fields */}
2 · From the roster
{sheet.fields.map((k, i) => (
{fieldByKey[k]?.label || k}
))}
{unusedFields.length > 0 && (
{unusedFields.map((f) => (
))}
)}
{/* 3. Hand-entered columns */}
3 · Your columns (typed or ticked)
{sheet.custom_columns.map((c, i) => (
updateColumn(c.id, { label: e.target.value })}/>
))}
{unusedPresets.length > 0 && (
{unusedPresets.map((p) => (
))}
)}
)}
{/* ── Table ── */}
setSearch(e.target.value)}/>
{customCols.length > 0 && (
)}
{roster ? `${shown.length} of ${ordered.length} students · exports what is shown` : "Loading students…"}
{!roster ? : ordered.length === 0 ? (
) : (
)}
);
};
/* ---------- Screen ---------- */
const SheetBuilderScreen = ({ setScreen }) => {
// undefined = the list; null = a new, unsaved sheet; "" = a saved one.
const [openId, setOpenId] = React.useState(undefined);
// A new sheet's first save hands back an id; the editor keeps running
// (same component instance) rather than remounting and losing its state.
const [editorKey, setEditorKey] = React.useState(0);
if (openId === undefined) {
return { setOpenId(id); setEditorKey((k) => k + 1); }}
onNew={() => { setOpenId(null); setEditorKey((k) => k + 1); }}/>;
}
return setOpenId(undefined)}
onCreated={() => { /* stays open; the editor now holds the id */ }}/>;
};
window.SheetBuilderScreen = SheetBuilderScreen;