/* ================================================================== 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 = () => (
| Student | {report.days.map((d) => ({d.slice(8, 10)}{saMonthAbbr(d)} · {saWeekday(d)} | ))}% |
|---|---|---|
|
{s.roll_no || "—"}
{s.name}
{s.class_label}
|
{report.days.map((d) => (
|
))}
{s.totals.percent == null ? "—" : `${s.totals.percent}%`} |
| 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}%`} |