/* ================================================================== Overview → Student Attendance ------------------------------------------------------------------ The whole-school register, driven by the same server-side report the teacher app, the PDF and the spreadsheet all render. Nothing on this page recomputes a total the server already gives — the figures in the totals row, the by-class table and the % column are the server's. Three things worth knowing before reading a number here: 1. A cell whose `source` is 'rfid' was filled in by the gate reader and NO TEACHER HAS CONFIRMED IT. Such cells are drawn dotted, and the figure "awaiting teacher" counts them. 2. A child with no cell for a day did not tap and was not marked. That is blank — never absent. "Not yet tapped" today is exactly that set. 3. Dates are 'YYYY-MM-DD' strings end to end, and "today" is the school day in the school's time zone (BRAND.timeZone) regardless of the device clock. Global-scope rule: every top-level identifier in this file is prefixed SA / Sa / sa. A duplicate top-level `const` anywhere in admin-panel kills the whole later script file silently. ================================================================== */ const SA_STATUSES = ["present", "absent", "late", "half_day", "excused"]; const SA_STATUS_LABEL = { present: "Present", absent: "Absent", late: "Late", half_day: "Half day", excused: "Excused", }; const SA_STATUS_CODE = { present: "P", absent: "A", late: "L", half_day: "H", excused: "E" }; /* ---------- Dates (pinned to BRAND.timeZone, like the Teacher app) ---------- */ const SA_TZ = (window.BRAND && window.BRAND.timeZone) || "Asia/Kolkata"; const saIstDay = (d) => new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: SA_TZ, }).format(d); const saIstToday = () => saIstDay(new Date()); const saShiftDay = (by) => { const d = new Date(`${saIstToday()}T00:00:00`); d.setDate(d.getDate() + by); const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; }; const saMonthStart = () => `${saIstToday().slice(0, 7)}-01`; const SA_PRESETS = [ { key: "today", label: "Today", from: () => saShiftDay(0), to: () => saShiftDay(0) }, { key: "yesterday", label: "Yesterday", from: () => saShiftDay(-1), to: () => saShiftDay(-1) }, { key: "7", label: "7 days", from: () => saShiftDay(-6), to: () => saShiftDay(0) }, { key: "30", label: "30 days", from: () => saShiftDay(-29), to: () => saShiftDay(0) }, { key: "month", label: "This month", from: saMonthStart, to: () => saShiftDay(0) }, ]; const saPrettyDate = (day, opts) => { const d = new Date(`${day}T00:00:00`); return isNaN(d.getTime()) ? day : d.toLocaleDateString("en-IN", opts || { day: "numeric", month: "short", year: "numeric" }); }; const saMonthAbbr = (day) => { const m = Number(day.slice(5, 7)); return ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m] || ""; }; const saWeekday = (day) => { const d = new Date(`${day}T00:00:00`); return isNaN(d.getTime()) ? "" : d.toLocaleDateString("en-IN", { weekday: "short" }); }; // Timestamps arrive as whatever the server's Date stringified to; the browser // parses either ISO or the long form. Rendered in BRAND.timeZone whatever the device says. const saTime = (ts) => { if (!ts) return "—"; const d = new Date(ts); if (isNaN(d.getTime())) return "—"; return d.toLocaleTimeString("en-IN", { hour: "2-digit", minute: "2-digit", timeZone: SA_TZ }); }; const saDateTime = (ts) => { if (!ts) return "—"; const d = new Date(ts); if (isNaN(d.getTime())) return "—"; return d.toLocaleString("en-IN", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", timeZone: SA_TZ, }); }; const saAgo = (minutes) => { if (minutes == null) return "never seen"; if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes} min ago`; if (minutes < 48 * 60) return `${Math.floor(minutes / 60)} h ago`; return `${Math.floor(minutes / 1440)} d ago`; }; /* ---------- Query plumbing ---------- */ const saQs = (q) => { const p = new URLSearchParams(); for (const [k, v] of Object.entries(q)) if (v) p.set(k, String(v)); const s = p.toString(); return s ? `?${s}` : ""; }; const saCaption = (r) => { const bits = []; bits.push(r.filters.from === r.filters.to ? saPrettyDate(r.filters.from) : `${saPrettyDate(r.filters.from)} — ${saPrettyDate(r.filters.to)}`); if (r.labels.class_section) bits.push(`Class ${r.labels.class_section}`); if (r.labels.status) bits.push(`only ${r.labels.status.toLowerCase()}`); if (r.filters.q) bits.push(`matching "${r.filters.q}"`); return bits.join(" · "); }; // Fallback for a blocked popup, and the only path for the spreadsheet. const saTriggerDownload = (blobUrl, filename) => { const a = document.createElement("a"); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); }; // "08:31 via Main gate · confirmed by A. Teacher". What a grid cell says on hover // and what the one-day list prints beside the status. const saCellTitle = (cell, scan) => { if (!cell) return "Not tapped, not marked"; const bits = []; if (cell.first_scan_at) { bits.push(`${saTime(cell.first_scan_at)} via ${scan?.reader_label || "the gate"}`); } else { bits.push(`marked ${saTime(cell.marked_at)}`); } if (cell.source === "rfid") bits.push("awaiting teacher confirmation"); else if (cell.marked_by_name) bits.push(`${cell.corrected ? "corrected" : "confirmed"} by ${cell.marked_by_name}`); if (cell.note) bits.push(cell.note); return `${SA_STATUS_LABEL[cell.status] || cell.status} · ${bits.join(" · ")}`; }; // The live figures for the gate strip. These are gate-derived subsets the // server does not total, so they are counted here off the cells; "not yet // tapped" is the server's own not_marked for the day where it exists. const saGateSummary = (report, today) => { let tapped = 0, onTime = 0, late = 0, unconfirmed = 0; for (const s of report.students) { const c = s.cells[today]; if (!c) continue; if (c.first_scan_at) { tapped++; if (c.status === "present") onTime++; else if (c.status === "late") late++; } if (c.source === "rfid") unconfirmed++; } const dayRow = (report.summary.by_day || []).find((d) => d.day === today); const notTapped = report.filters.status ? null // roster dropped → unknowable : dayRow ? dayRow.not_marked : report.students.length; return { tapped, onTime, late, unconfirmed, notTapped }; }; const saReaderTone = (r) => { if (!r.active) return "off"; if (r.minutes_since_seen == null) return "never"; if (r.minutes_since_seen <= 5) return "ok"; if (r.minutes_since_seen <= 60) return "stale"; return "down"; }; /* ---------- Screen-local styles (styles.css is not touched) ---------- */ const SA_CSS = ` .sa-filters { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: flex-end; margin-top: 14px; } .sa-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .sa-field > span { font-size: 10.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-3); } .sa-field .input, .sa-field .select { width: auto; padding: 7px 10px; font-size: 13px; } .sa-field.grow { flex: 1 1 200px; } .sa-field.grow .input { width: 100%; } .sa-filtertoggle { display: none; } .sa-stats { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 12px; margin-bottom: 20px; } .sa-stats .cx-card.tight { padding: 16px 18px; } .sa-stats .cx-stat-value.sm { font-size: 26px; } .sa-gate { display: flex; flex-wrap: wrap; gap: 14px 28px; align-items: flex-end; } .sa-gate-fig { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .sa-gate-fig b { font-size: 24px; font-weight: 600; letter-spacing: -0.8px; color: var(--ink-0); line-height: 1.05; } .sa-gate-fig span { font-size: 12px; color: var(--ink-3); white-space: nowrap; } .sa-readers { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line-soft); font-size: 12.5px; color: var(--ink-2); } .sa-rdot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 7px; vertical-align: middle; background: var(--ink-4); } .sa-rdot.ok { background: var(--green-solid); box-shadow: 0 0 0 3px var(--green-bg); } .sa-rdot.stale { background: var(--amber-solid); box-shadow: 0 0 0 3px var(--amber-bg); } .sa-rdot.down { background: var(--red-solid); box-shadow: 0 0 0 3px var(--red-bg); } .sa-rdot.never { background: var(--surface); border: 1.5px solid var(--ink-4); } .sa-rdot.off { background: var(--bg-4); } .sa-cell { display: inline-flex; width: 24px; height: 24px; align-items: center; justify-content: center; border-radius: 7px; font-weight: 700; font-size: 11.5px; font-family: var(--font-mono); box-sizing: border-box; } .sa-cell.present { background: var(--green-bg); color: var(--green); } .sa-cell.absent { background: var(--red-bg); color: var(--red); } .sa-cell.late { background: var(--amber-bg); color: var(--amber); } .sa-cell.half_day { background: var(--bg-3); color: var(--ink-2); } .sa-cell.excused { background: var(--violet-bg); color: var(--violet); } .sa-cell.blank { background: transparent; color: var(--ink-4); font-weight: 500; } /* Gate-only: same colour, no fill, dotted ring — a suggestion, not a mark. */ .sa-cell.gate { background: transparent; border: 1.5px dotted currentColor; } .sa-cell.today { box-shadow: 0 0 0 1.5px var(--ink-2); } .sa-grid { border-collapse: separate; border-spacing: 0; font-size: 12.5px; min-width: 100%; } .sa-grid th, .sa-grid td { padding: 6px 5px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; text-align: center; vertical-align: middle; } .sa-grid th { font-size: 10px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); background: var(--surface); position: sticky; top: 0; z-index: 1; line-height: 1.15; } .sa-grid th small { display: block; font-size: 9px; color: var(--ink-4); letter-spacing: 0; text-transform: none; margin-top: 1px; } .sa-grid th.today { color: var(--ink-0); } .sa-grid th:first-child, .sa-grid td:first-child { position: sticky; left: 0; background: var(--surface); z-index: 2; text-align: left; min-width: 168px; max-width: 220px; padding-left: 8px; box-shadow: 1px 0 0 var(--line-soft); } .sa-grid th:first-child { z-index: 3; } .sa-grid td:last-child, .sa-grid th:last-child { padding-right: 10px; font-variant-numeric: tabular-nums; } .sa-grid tbody tr { cursor: pointer; } .sa-grid tbody tr:hover td { background: var(--bg-2); } .sa-grid tbody tr:last-child td { border-bottom: none; } .sa-who { display: flex; align-items: baseline; gap: 8px; min-width: 0; } .sa-who .r { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); flex-shrink: 0; width: 28px; } .sa-who .n { font-weight: 600; color: var(--ink-0); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .sa-who .c { font-size: 10.5px; color: var(--ink-4); flex-shrink: 0; } .sa-class { padding: 12px 0 4px; } .sa-class-head { display: flex; align-items: baseline; gap: 10px; padding: 8px 0 6px; border-bottom: 1px solid var(--line); margin-bottom: 4px; } .sa-class-head b { font-size: 14px; color: var(--ink-0); } .sa-class-head span { font-size: 12px; color: var(--ink-3); } .sa-dayrow { display: grid; grid-template-columns: 34px minmax(0, 1fr) 96px minmax(0, 1.1fr) minmax(0, 1.2fr); gap: 10px; align-items: center; padding: 9px 4px; border-bottom: 1px solid var(--line-soft); cursor: pointer; border-radius: 10px; font-size: 13px; } .sa-dayrow:hover { background: var(--bg-2); } .sa-dayrow > * { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sa-dayrow .r { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); } .sa-dayrow .n { font-weight: 600; color: var(--ink-0); } .sa-dayrow .t { color: var(--ink-2); font-size: 12.5px; } .sa-dayrow .k { color: var(--ink-3); font-size: 12px; } .sa-dayrow .k.ok { color: var(--green); } .sa-dayrow .k.wait { color: var(--amber); } .sa-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; font-size: 12.5px; color: var(--ink-2); } .sa-legend .sa-cell { width: 20px; height: 20px; font-size: 10px; border-radius: 6px; } .sa-foot { font-size: 12.5px; color: var(--ink-3); line-height: 1.5; margin-top: 12px; } .sa-scrim { position: fixed; inset: 0; z-index: 245; background: color-mix(in srgb, var(--ink-0) 34%, transparent); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); animation: cx-fade-in 0.18s ease-out; } .sa-drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 250; width: min(460px, 100vw); background: var(--surface); box-shadow: -14px 0 44px color-mix(in srgb, var(--ink-0) 22%, transparent); overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; animation: sa-slide-in 0.22s cubic-bezier(.2,.85,.3,1); } .sa-drawer-head { display: flex; align-items: flex-start; gap: 12px; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--surface); z-index: 1; } .sa-drawer section { padding: 14px 20px; border-bottom: 1px solid var(--line-soft); } .sa-drawer .sa-sec { font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 10px; font-weight: 600; } .sa-taprow { display: grid; grid-template-columns: 110px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 7px 0; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; } .sa-taprow:last-child { border-bottom: none; } .sa-taprow .m { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); } .sa-drawer-grip { display: none; } @keyframes sa-slide-in { from { transform: translateX(100%); } to { transform: translateX(0); } } @keyframes sa-sheet-up { from { transform: translateY(100%); } to { transform: translateY(0); } } @media (max-width: 1100px) { .sa-stats { grid-template-columns: repeat(4, minmax(0, 1fr)); } } @media (max-width: 760px) { .sa-filtertoggle { display: inline-flex; } .sa-filters.collapsed { display: none; } .sa-field { flex: 1 1 45%; } .sa-field .input, .sa-field .select { width: 100%; } .sa-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } .sa-stats .cx-card.tight { padding: 12px 14px; } .sa-gate { gap: 10px 18px; } .sa-gate-fig b { font-size: 20px; } .sa-dayrow { grid-template-columns: 30px minmax(0, 1fr) auto; row-gap: 2px; } .sa-dayrow .t { grid-column: 2; } .sa-dayrow .k { grid-column: 2 / -1; } .sa-grid th:first-child, .sa-grid td:first-child { min-width: 132px; max-width: 150px; } .sa-drawer { top: auto; left: 0; right: 0; bottom: 0; width: 100%; max-height: 88vh; border-radius: 24px 24px 0 0; box-shadow: 0 -14px 44px color-mix(in srgb, var(--ink-0) 24%, transparent); padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); animation: sa-sheet-up 0.26s cubic-bezier(.2,.85,.3,1); } .sa-drawer-grip { display: block; flex-shrink: 0; width: 40px; height: 4px; border-radius: 999px; background: var(--bg-4); margin: 10px auto 0; } } @media (prefers-reduced-motion: reduce) { .sa-drawer, .sa-scrim { animation: none; } } `; /* ---------- Small pieces ---------- */ const SaField = ({ label, children, className = "" }) => ( ); // One register cell. `cell` may be undefined (blank — never absent). const SaCell = ({ cell, isToday, title }) => { const cls = cell ? `sa-cell ${cell.status}${cell.source === "rfid" ? " gate" : ""}${isToday ? " today" : ""}` : `sa-cell blank${isToday ? " today" : ""}`; return {cell ? SA_STATUS_CODE[cell.status] || "?" : "·"}; }; const SaLegend = () => (
{SA_STATUSES.map((s) => ( {SA_STATUS_CODE[s]}{SA_STATUS_LABEL[s]} ))} Pgate only, unconfirmed ·not tapped, not marked
); /* ---------- Today at the gate ---------- */ // Mounted only while today is inside the range, so the reader poll runs only // then. The tap figures come off the report (which the parent polls); the // reader health is its own 30 s poll. const SaGateStrip = ({ report, today, refreshing }) => { const CX = window.CX; const readers = CX.useCockpitData("/rfid/readers", { pollMs: 30000 }); const g = saGateSummary(report, today); const list = readers.data?.readers || []; return (
Today at the gate
{saPrettyDate(today, { weekday: "long", day: "numeric", month: "long" })} · refreshes every 30 s
{refreshing ? "Updating…" : "Live"}
{CX.num(g.tapped)}tapped in
{CX.num(g.onTime)}on time
{CX.num(g.late)}late
{g.notTapped == null ? "—" : CX.num(g.notTapped)}not yet tapped
{CX.num(g.unconfirmed)}awaiting teacher
Readers {readers.error && !list.length && ( Reader health unavailable )} {!readers.error && !readers.loading && list.length === 0 && ( No readers registered )} {list.map((r) => { const tone = saReaderTone(r); return ( {r.label} {" "} ({!r.active ? "disabled" : saAgo(r.minutes_since_seen)}{r.active && r.minutes_since_seen != null ? `, ${CX.num(r.scans_today)} today` : ""}) ); })}
); }; /* ---------- Totals row (the server's numbers, verbatim) ---------- */ const SaTotals = ({ t }) => { const CX = window.CX; const tile = (label, value, color, note) => (
{label}
{value} {note && {note}}
); return (
{tile("Present", CX.num(t.present), "var(--green)")} {tile("Absent", CX.num(t.absent), t.absent ? "var(--red)" : undefined)} {tile("Late", CX.num(t.late), t.late ? "var(--amber)" : undefined)} {tile("Half day", CX.num(t.half_day))} {tile("Excused", CX.num(t.excused), "var(--violet)")} {tile("Attendance", t.percent == null ? "—" : `${t.percent}%`, undefined, `of ${CX.num(t.marked)} marks`)} {tile("Unconfirmed", CX.num(t.unconfirmed), t.unconfirmed ? "var(--amber)" : undefined, "gate only")}
); }; /* ---------- One day: a list grouped by class ---------- */ const SaDayList = ({ report, day, scans, today, onPick }) => { const groups = []; const byClass = new Map(); for (const s of report.students) { let g = byClass.get(s.class_section_id); if (!g) { g = { id: s.class_section_id, label: s.class_label || "—", rows: [] }; byClass.set(s.class_section_id, g); groups.push(g); } g.rows.push(s); } const isToday = day === today; return (
{groups.map((g) => { const marked = g.rows.filter((s) => s.cells[day]).length; return (
{g.label} {marked} of {g.rows.length} {isToday ? "tapped or marked" : "marked"}
{g.rows.map((s) => { const cell = s.cells[day]; const scan = scans ? scans.get(s.student_id) : null; return (
onPick(s.student_id)} title="Open this student"> {s.roll_no || "—"} {s.name} {cell ? {SA_STATUS_LABEL[cell.status] || cell.status} : —} {cell?.first_scan_at ? <>{saTime(cell.first_scan_at)} · {scan?.reader_label || "gate"} : cell ? marked {saTime(cell.marked_at)} : {isToday ? "not tapped" : "not tapped, not marked"}} {cell ? cell.source === "rfid" ? "⏳ gate only" : `✓ ${cell.corrected ? "corrected" : "confirmed"}${cell.marked_by_name ? ` by ${cell.marked_by_name}` : ""}` : ""} {cell?.note && · {cell.note}}
); })}
); })}
); }; /* ---------- A range: the grid ---------- */ const SaRegisterGrid = ({ report, today, onPick }) => (
{report.days.map((d) => ( ))} {report.students.map((s) => ( onPick(s.student_id)}> {report.days.map((d) => ( ))} ))}
Student {d.slice(8, 10)}{saMonthAbbr(d)} · {saWeekday(d)} %
{s.roll_no || "—"} {s.name} {s.class_label}
{s.totals.percent == null ? "—" : `${s.totals.percent}%`}
); /* ---------- By class ---------- */ const SaByClass = ({ rows, onPickClass }) => { const CX = window.CX; return (
{rows.map((c) => ( onPickClass(c.class_section_id)}> ))}
Class Present Absent Late Half Excused Marked %
{c.class_label} {CX.num(c.present)} {CX.num(c.absent)} {CX.num(c.late)} {CX.num(c.half_day)} {CX.num(c.excused)} {CX.num(c.marked)} {c.percent == null ? "—" : `${c.percent}%`}
); }; /* ---------- The drawer: one student, every mark, every tap ---------- */ const SaStudentDrawer = ({ student, report, today, onClose }) => { const CX = window.CX; const { from, to } = report.filters; const [taps, setTaps] = useState({ loading: true, error: null, scans: [] }); useEffect(() => { let alive = true; setTaps({ loading: true, error: null, scans: [] }); window.AppApi.get(`/rfid/scans${saQs({ from, to, student_id: student.student_id, limit: 500 })}`) .then((r) => { if (alive) setTaps({ loading: false, error: null, scans: r?.scans || [] }); }) .catch((e) => { if (alive) setTaps({ loading: false, error: e?.message || "Tap log unavailable", scans: [] }); }); return () => { alive = false; }; }, [student.student_id, from, to]); useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); // Reader per day, from the taps, so a cell can say "via Main gate" here // even when the grid could not. const firstTapByDay = useMemo(() => { const m = new Map(); for (const sc of taps.scans) { // newest first → last write is the earliest const day = saIstDay(new Date(sc.scanned_at)); m.set(day, sc); } return m; }, [taps.scans]); const t = student.totals; return ( <>