/* ========================================================================= Unified Notifications panel. --------------------------------------------------------------------------- A full, filterable history of the signed-in user's notifications — the same feed the topbar bell summarizes, but paged and filterable by Type and Date. Reads GET /api/notifications/feed?from&to&kind&limit&offset (per-user visibility, school timezone date filtering). Admins additionally see the admin-targeted kinds (e.g. an outside-schedule class logged by a teacher). ========================================================================= */ (function () { // Type filter options. Keep in sync with NotificationKind on the server; // unknown kinds still render via the fallback in `meta()`. const KIND_OPTIONS = [ { value: "", label: "All types" }, { value: "progress_extra_logged", label: "Outside-schedule class" }, { value: "attempt_submitted", label: "Submission" }, { value: "question_reported", label: "Reported question" }, { value: "doubt_raised", label: "Doubt raised" }, { value: "doubt_message", label: "Doubt follow-up" }, { value: "retest_requested", label: "Retest request" }, { value: "chat_report_raised", label: "Chat report" }, { value: "attendance_checkout_override", label: "Off-site check-out" }, { value: "teacher_registration_pending", label: "New staff registration" }, { value: "parent_registration_pending", label: "New parent registration" }, { value: "password_reset_requested", label: "Password reset request" }, ]; const KIND_LABEL = Object.fromEntries(KIND_OPTIONS.map(o => [o.value, o.label])); const relTime = (iso) => { const ms = Date.now() - new Date(iso).getTime(); const s = Math.floor(ms / 1000); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; }; const fmtAbs = (iso) => { try { return new Date(iso).toLocaleString(undefined, { day: "2-digit", month: "short", hour: "numeric", minute: "2-digit", }); } catch { return iso; } }; // Per-kind pill colour + title/sub strings. Delegates to the single // renderer in shell.jsx so this screen and the topbar bell can never drift // — they did, and the bell was the one showing "undefined" for the most // common notification kind in the database. function meta(n) { return window.AppUI.notificationMeta(n); } function NotificationsScreen() { const { useState, useEffect } = React; const PAGE = 50; const [items, setItems] = useState([]); const [loading, setLoad] = useState(true); const [err, setErr] = useState(null); const [hasMore, setMore] = useState(false); const [offset, setOffset] = useState(0); // Filters const [kind, setKind] = useState(""); const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const buildQuery = (off) => { const q = new URLSearchParams(); if (kind) q.set("kind", kind); if (from) q.set("from", from); if (to) q.set("to", to); q.set("limit", String(PAGE)); q.set("offset", String(off)); return q.toString(); }; const fetchPage = async (off, replace) => { setLoad(true); setErr(null); try { const data = await window.AppApi.get(`/notifications/feed?${buildQuery(off)}`); const rows = data?.items || []; setItems(prev => replace ? rows : [...prev, ...rows]); setMore(!!data?.has_more); setOffset(off + rows.length); } catch (e) { setErr(e?.message || "Failed to load notifications"); } finally { setLoad(false); } }; // Refetch from the top whenever a filter changes. useEffect(() => { fetchPage(0, true); /* eslint-disable-next-line */ }, [kind, from, to]); const markRead = async (id) => { try { await window.AppApi.post(`/notifications/${id}/read`, {}); } catch (_e) { /* best-effort */ } setItems(xs => xs.map(x => x.id === id ? { ...x, read_at: new Date().toISOString() } : x)); }; const markAll = async () => { try { await window.AppApi.post(`/notifications/read-all`, {}); } catch (_e) { /* best-effort */ } setItems(xs => xs.map(x => x.read_at ? x : { ...x, read_at: new Date().toISOString() })); }; const clearFilters = () => { setKind(""); setFrom(""); setTo(""); }; const hasFilters = kind || from || to; return (
{/* Filters */}
Notifications
{hasFilters && ( )}
{/* List */}
{err && (
{err}
)} {!err && items.length === 0 && (
{loading ? "Loading…" : hasFilters ? "No notifications match these filters." : "No notifications yet."}
)} {items.map(n => { const m = meta(n); const p = n.payload || {}; const photo = n.kind === "progress_extra_logged" && p.photo_path ? `${window.AppApi.base}/progress-photos/${p.photo_path}` : null; return (
{ if (!n.read_at) markRead(n.id); }} style={{ display: "flex", gap: 12, padding: "12px 16px", borderBottom: "1px solid var(--line-soft)", background: n.read_at ? "transparent" : "color-mix(in srgb, var(--amber-solid) 4%, transparent)", cursor: n.read_at ? "default" : "pointer", }}>
{!n.read_at && } {m.label} {fmtAbs(n.created_at)} · {relTime(n.created_at)}
{m.title}
{m.sub &&
{m.sub}
}
{photo && ( e.stopPropagation()} style={{ flexShrink: 0 }}> Classroom evidence )}
); })} {hasMore && (
)}
); } window.NotificationsScreen = NotificationsScreen; })();