/* ================================================================== 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 (
{err ?
{err}
: }
); } 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 })}/>
))}
{ e.preventDefault(); addColumn(newColLabel, newColType); }}> setNewColLabel(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 ? ( ) : (
{fieldCols.map((f) => )} {customCols.map((c) => ( ))} {shown.map((r, i) => ( {fieldCols.map((f) => ( f.key === "sl" ? : ))} {customCols.map((c) => { const v = sheet.cell_values?.[r.id]?.[c.id]; return c.type === "check" ? ( ) : ( ); })} ))}
{f.label} {c.label} {filledCount(c.id)}/{ordered.length}
{i + 1}{r[f.key] || —} setCell(r.id, c, e.target.checked)}/> setCell(r.id, c, nv)}/>
)}
); }; /* ---------- 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;