/* ========================================================================= 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 (