/* ================================================================== Admin panel — Sidebar + Topbar + screen router Loads after data.js ================================================================== */ const { useState, useEffect, useRef, useMemo, useCallback } = React; const { TESTS, ACTIVE_TEST, QUESTIONS, RUBRIC_Q3, EVALUATIONS_Q3 } = window.APP; /* ---------- Icons (inline SVG) ---------- */ const Icon = ({ name, size = 14, className = "" }) => { const s = size; const paths = { library: <>, inbox: <>, cohort: <>, student: <>, upload: <>, plus: <>, search: <>, chevronRight: , chevronLeft: , chevronDown: , check: , x: <>, edit: <>, settings: <>, sparkle: <>, zap: , rubric: <>, filter: , report: <>, expand: <>, keyboard: <>, bell: <>, move: <>, sort: <>, flag: <>, warn: <>, copy: <>, list: <>, trash: <>, // Section marks for the mobile bottom bar. home: <>, building: <>, users: <>, cap: <>, grid: <>, menu: <>, }; return ( {paths[name]} ); }; /* ---------- Confidence dots ---------- */ const Confidence = ({ value }) => { const filled = Math.round(value * 5); const tier = value >= 0.8 ? "high" : value >= 0.5 ? "mid" : "low"; return (
{[0,1,2,3,4].map(i => ( ))}
); }; /* ---------- Status pill ---------- */ // A test with a future scheduled_date displays as "Upcoming" regardless of the // stored status (the row is still 'live' in the DB until the date passes, but // the dashboard/library should signal it as not-yet-open). const isFutureSchedule = (scheduledDate) => { if (!scheduledDate) return false; const d = new Date(scheduledDate); if (isNaN(d.getTime())) return false; const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0); return d.getTime() > startOfToday.getTime(); }; // Tests auto-expire ~48h after their scheduled date (server-derived via the // backend's `is_expired` flag on /tests). Until the test is closed/completed, // an expired live row shows the muted "Expired" pill in the library. const effectiveStatus = (status, scheduledDate, isExpired) => { const liveLike = ["live", "published", "ready_for_review", "optimised", "grading"]; if (isExpired && !["completed", "closed"].includes(status) && liveLike.includes(status)) { return "expired"; } if (liveLike.includes(status) && isFutureSchedule(scheduledDate)) return "upcoming"; return status; }; const StatusPill = ({ status, scheduledDate, isExpired }) => { const map = { draft: { cls: "pill", label: "Draft" }, live: { cls: "pill amber", label: "Live" }, completed: { cls: "pill green", label: "Completed" }, // Intermediate / legacy states ready_for_review: { cls: "pill amber", label: "Live" }, published: { cls: "pill amber", label: "Live" }, optimised: { cls: "pill amber", label: "Live" }, upcoming: { cls: "pill", label: "Upcoming" }, grading: { cls: "pill amber", label: "Grading" }, closed: { cls: "pill green", label: "Completed" }, parsing: { cls: "pill", label: "Parsing…" }, rubric_generating:{ cls: "pill", label: "Drafting rubrics…" }, expired: { cls: "pill", label: "Expired" }, }; const effective = effectiveStatus(status, scheduledDate, isExpired); const s = map[effective] || map.draft; return {s.label}; }; /* ---------- Navigation model ---------- Six top-level sections, each an overview screen plus the deep screens that belong under it. This is the single source of truth for BOTH rows of the nav: the pills in the top bar and the sub-nav beneath it. It also answers "which section owns this screen", which is how the correct pill stays lit when something navigates straight to a deep screen (a notification opening Doubts must light Assessment, not leave every pill dark). `roles` gates a section; `roles` on a child gates just that entry. Absent means everyone who can reach the cockpit at all. */ const NAV_SECTIONS = [ { id: "dashboard", label: "Dashboard", icon: "home", overview: "dashboard", children: [], }, { id: "academics", label: "Academics", icon: "cap", overview: "academics", children: [ { id: "academics", label: "Overview" }, { id: "students", label: "Students", roles: ["admin"] }, { id: "teachers", label: "Teachers", roles: ["admin"] }, { id: "staff-attendance", label: "Staff Attendance" }, { id: "student-attendance", label: "Student Attendance" }, { id: "holidays", label: "Holidays", roles: ["admin"] }, { id: "exams", label: "Examinations", roles: ["admin", "hod"] }, { id: "personnel", label: "Personnel", roles: ["admin"] }, { id: "taxonomy", label: "Classes & Subjects", roles: ["admin"] }, { id: "chapters", label: "Chapters", roles: ["admin"] }, { id: "topics", label: "Topics", roles: ["admin"] }, ], }, { id: "assessment", label: "Assessment", icon: "zap", overview: "assessment-overview", children: [ { id: "assessment-overview", label: "Overview" }, { id: "library", label: "Test Library" }, { id: "online-tests", label: "Online Tests" }, { id: "evaluate", label: "Paper Evaluation" }, { id: "report", label: "Class Reports" }, { id: "answer-key", label: "Answer Keys" }, { id: "doubts", label: "Doubts" }, { id: "ai-review", label: "AI Test Review" }, ], }, { id: "parents", label: "Parents", icon: "users", overview: "parents-overview", children: [ { id: "parents-overview", label: "Overview" }, { id: "parents", label: "Parent Accounts", roles: ["admin"] }, { id: "announcements", label: "Announcements", roles: ["admin"] }, // Admin AND HoD: the API admits both, and a HoD is exactly who deals // with a complaint about a member of their department. { id: "parent-complaints", label: "Complaints", roles: ["admin", "hod"] }, { id: "notifications", label: "Notifications" }, ], }, { id: "operations", label: "Operations", icon: "grid", overview: "finance", children: [ { id: "finance", label: "Finance" }, { id: "sheet-builder", label: "Sheet Builder", roles: ["admin"] }, ], }, ]; // Screens reachable from inside another screen rather than from the nav // (authoring flows, drill-downs). Mapped so the right pill stays lit. const SCREEN_SECTION_OVERRIDES = { manual: "assessment", "answer-key": "assessment", }; const visibleChildren = (section, role) => (section.children || []).filter((c) => !c.roles || c.roles.includes(role)); const visibleSections = (role) => NAV_SECTIONS.filter((s) => { if (s.roles && !s.roles.includes(role)) return false; // A section with no reachable children and no overview is dead weight. return s.overview || visibleChildren(s, role).length > 0; }); const sectionForScreen = (screen, role) => { if (SCREEN_SECTION_OVERRIDES[screen]) return SCREEN_SECTION_OVERRIDES[screen]; const hit = NAV_SECTIONS.find( (s) => s.overview === screen || (s.children || []).some((c) => c.id === screen), ); return hit ? hit.id : visibleSections(role)[0]?.id || "dashboard"; }; /* ---------- Shared scroll state ---------- Two pieces of chrome react to scrolling — the top bar condenses, the nav widget shrinks — so they read one hook rather than each installing their own listener with its own slightly different threshold. Three things this has to get right: 1. rAF-throttled. A scroll handler that calls setState on every event re-renders the whole shell dozens of times a second on a phone. 2. A DEAD ZONE on direction. Without it, the one or two pixels of jitter from a finger resting on the screen flip the direction repeatedly and the widget pulses. 6px of travel is required before direction flips. 3. Never "down" near the top of the page, so the widget is always at full size where a user starts. Bounce-scrolling past the top on iOS reports a negative scrollY, which would otherwise read as a downward move. */ const useScrollState = ({ condenseAt = 8, dirThreshold = 6 } = {}) => { const [state, setState] = useState({ scrolled: false, down: false }); const last = useRef(0); const ticking = useRef(false); useEffect(() => { const read = () => { ticking.current = false; const y = Math.max(0, window.scrollY || 0); const delta = y - last.current; setState((prev) => { const scrolled = y > condenseAt; let down = prev.down; if (Math.abs(delta) >= dirThreshold) down = delta > 0; // Anywhere near the top the widget is always full size. if (y <= condenseAt) down = false; if (scrolled === prev.scrolled && down === prev.down) return prev; return { scrolled, down }; }); if (Math.abs(delta) >= dirThreshold) last.current = y; }; const onScroll = () => { if (ticking.current) return; ticking.current = true; window.requestAnimationFrame(read); }; last.current = Math.max(0, window.scrollY || 0); read(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, [condenseAt, dirThreshold]); return state; }; /* ---------- Cockpit top bar ---------- */ const CockpitTopbar = ({ screen, setScreen }) => { const user = window.APP.CURRENT_USER; const school = window.APP.CURRENT_SCHOOL; const role = user?.role; const sections = visibleSections(role); const activeId = sectionForScreen(screen, role); const active = NAV_SECTIONS.find((s) => s.id === activeId); const children = active ? visibleChildren(active, role) : []; const initials = user?.initials || user?.name?.split(/\s+/).map((p) => p[0]).slice(0, 2).join("").toUpperCase() || "—"; const [menuOpen, setMenuOpen] = useState(false); const { scrolled } = useScrollState(); return ( <> {/* Only the BAR is sticky. The sub-nav below scrolls away with the page: pinning both would cost ~100px of an 844px screen permanently, and the bar is the half that has to stay reachable. */}
{/* Brand mark from brand.js: the configured logo, or a neutral initial-letter tile when no logo is set. */}
{school?.name || window.BRAND.orgName}
{window.BRAND.appName}
{menuOpen && ( <>
setMenuOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 190 }}/>
{user?.name || "Not signed in"}
{school?.name ? `${school.name} · ${user?.role || "—"}` : (user?.role || "—")}
)}
{children.length > 1 && (
{children.map((c) => ( ))}
)} ); }; /* ---------- Radial navigation widget (mobile / installed PWA) ---------- One floating circle that opens an arc of sections. Two gestures, one pointer pipeline: tap → arc opens and stays open, then tap a section press-and-drag→ arc opens, the section nearest the drag angle arms itself, releasing selects it Both fall out of the same handlers rather than being two code paths: the arc opens on pointerdown either way, and pointerup decides based on whether the finger travelled. That is why a slow tap and a fast flick both do the sensible thing without a hold timer to tune. Rendered at every width; CSS hides it above 760px, so there is no resize listener and no flash of the wrong navigation on first paint. */ // Geometry. The trigger sits at bottom-CENTRE, so the arc gets the whole // upper half to fan across rather than a single quadrant. That extra span is // what lets the radius stay small: six 56px circles need ~62px of arc between // centres, and across 156° that is only 62 / (156°/5 in radians) ≈ 114. // // A tighter radius is the point — every item stays inside a thumb's sweep, // and the open menu covers far less of the screen than a corner fan would. const RADIAL_R = 116; const RADIAL_FROM = 168; // degrees — leftmost item, just above level const RADIAL_TO = 12; // degrees — rightmost item const RADIAL_ARM_DIST = 44; // travel before a drag starts arming items const CockpitRadialNav = ({ screen, setScreen }) => { const role = window.APP.CURRENT_USER?.role; const sections = visibleSections(role); const activeId = sectionForScreen(screen, role); const [open, setOpen] = useState(false); const [armed, setArmed] = useState(-1); const fabRef = useRef(null); // Shrinks while the user reads downward, returns the moment they scroll // back up. Never while the arc is open — the trigger is the anchor the // whole gesture is measured from, so moving it mid-drag would be hostile. const { down } = useScrollState(); const mini = down && !open; // Refs, not state: these are read inside pointer handlers on every move and // must not schedule a render or go stale between events. const drag = useRef({ active: false, moved: false, wasOpen: false }); const go = useCallback((s) => { setScreen(s.overview || visibleChildren(s, role)[0]?.id); setOpen(false); setArmed(-1); }, [setScreen, role]); // Item positions: an even fan across the upper half, left to right. // Angles are measured from the +x axis with y flipped for screen space. const positions = useMemo(() => { const n = sections.length; return sections.map((_, i) => { // A lone section sits straight above the trigger, not at an arc end. const deg = n === 1 ? 90 : RADIAL_FROM + ((RADIAL_TO - RADIAL_FROM) * i) / (n - 1); const rad = (deg * Math.PI) / 180; return { deg, x: Math.cos(rad) * RADIAL_R, y: -Math.sin(rad) * RADIAL_R }; }); }, [sections]); const pickByPointer = useCallback((clientX, clientY) => { const fab = fabRef.current; if (!fab) return -1; const r = fab.getBoundingClientRect(); const cx = r.left + r.width / 2, cy = r.top + r.height / 2; const dx = clientX - cx, dy = cy - clientY; // dy up-positive if (Math.hypot(dx, dy) < RADIAL_ARM_DIST) return -1; // still on the trigger let deg = (Math.atan2(dy, dx) * 180) / Math.PI; if (deg < 0) deg += 360; // Nearest item by angle. No tolerance window: once the finger has left // the trigger, something is always armed, so a release never no-ops in a // way the user cannot see coming. let best = -1, bestD = Infinity; positions.forEach((p, i) => { const d = Math.abs(((p.deg - deg + 540) % 360) - 180); if (d < bestD) { bestD = d; best = i; } }); return best; }, [positions]); const onPointerDown = (e) => { drag.current = { active: true, moved: false, wasOpen: open }; setOpen(true); setArmed(-1); // Keep receiving move/up even when the finger leaves the button. try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* older WebKit */ } }; const onPointerMove = (e) => { if (!drag.current.active) return; const i = pickByPointer(e.clientX, e.clientY); if (i !== -1) drag.current.moved = true; setArmed(i); }; const onPointerUp = (e) => { if (!drag.current.active) return; const { moved, wasOpen } = drag.current; drag.current.active = false; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* no capture */ } if (moved) { const i = pickByPointer(e.clientX, e.clientY); if (i !== -1) { go(sections[i]); return; } } // A tap: toggle. Opening leaves the arc up for a second tap; tapping the // trigger again closes it. setArmed(-1); if (wasOpen) setOpen(false); }; const onKeyDown = (e) => { if (e.key === "Escape" && open) { setOpen(false); setArmed(-1); } }; const current = sections.find((s) => s.id === activeId); return (
{open && (
{ setOpen(false); setArmed(-1); }} aria-hidden="true"/> )} {sections.map((s, i) => ( ))}
); }; /* ---------- Brand mark ---------- The configured logo (window.BRAND.logoUrl), or — when none is set — a neutral rounded square carrying the organisation's initial. Shared by the top bar, the login screen and any printout header. */ const BrandMark = ({ size = 32 }) => { const B = window.BRAND || {}; if (B.logoUrl) { return {B.orgName; } const letter = String(B.orgShortName || B.orgName || "S").trim().charAt(0).toUpperCase() || "S"; return ( ); }; /* ---------- Notification rendering (shared) ---------- ONE renderer for both surfaces: the topbar bell and the full Notifications screen. It lives here because shell.jsx loads before the screens, so the screen can read it off window.AppUI while the bell uses it directly. Payloads vary widely by kind (some carry only { date }). Two rules for anything added here: 1. every field access is guarded — a payload is data from another subsystem and may legitimately lack a key; 2. the fallback prints a humanised kind, never a raw snake_case string and never the word "undefined". */ const NOTIFICATION_KIND_LABEL = { progress_extra_logged: "Outside-schedule class", progress_period_prompt: "Period log due", attempt_submitted: "Submission", question_reported: "Reported question", doubt_raised: "Doubt raised", doubt_message: "Doubt follow-up", retest_requested: "Retest request", chat_report_raised: "Chat report", parent_chat_message: "Parent message", attendance_checkin_reminder: "Check-in reminder", attendance_absent_report: "Absence report", attendance_checkout_override: "Off-site check-out", teacher_registration_pending: "New staff registration", parent_registration_pending: "New parent registration", password_reset_requested: "Password reset request", leave_application_raised: "Leave application", leave_application_approved: "Leave approved", leave_cover_assigned: "Cover assigned", }; // "attendance_checkin_reminder" → "Attendance checkin reminder". Only ever // used for a kind the server has added and this file does not know yet. const humaniseKind = (k) => String(k || "notification").replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase()); const notificationMeta = (n) => { const p = (n && n.payload) || {}; const label = NOTIFICATION_KIND_LABEL[n?.kind] || humaniseKind(n?.kind); const someone = (v, fallback) => v || fallback; const dateish = (d) => { if (!d) return ""; const dt = new Date(String(d).length === 10 ? d + "T00:00:00" : d); return Number.isNaN(dt.getTime()) ? String(d) : dt.toLocaleDateString("en-IN", { day: "numeric", month: "short" }); }; switch (n?.kind) { case "attempt_submitted": return { pill: "blue", label: "Submitted", title: `${someone(p.student_name, "A student")} submitted ${p.test_title || "a test"}`, sub: [ (p.auto_score != null && p.max_marks != null) ? `${p.auto_score} / ${p.max_marks} auto-graded` : null, p.student_roll, p.physical_handin ? "handed in on paper" : null, ].filter(Boolean).join(" · ") }; case "question_reported": return { pill: "amber", label: "Reported", title: `${someone(p.student_name, "A student")} reported Q${p.q_no ?? "?"} in ${p.test_title || "a test"}`, sub: (p.note || "").slice(0, 160) || "(no note)" }; case "doubt_raised": return { pill: "amber", label: "Doubt", title: `${someone(p.student_name, "A student")} asked about Q${p.q_no ?? "?"} in ${p.test_title || "a test"}`, sub: p.body_preview || "" }; case "doubt_message": return { pill: "amber", label: "Doubt · follow-up", title: `New message on Q${p.q_no ?? "?"}${p.test_title ? " · " + p.test_title : ""}`, sub: p.body_preview || "" }; case "retest_requested": return { pill: "amber", label: "Retest", title: `${someone(p.student_name, "A student")} requested a retest`, sub: [p.test_title || p.test_display_id, p.reason].filter(Boolean).join(" · ") }; /* ---- Attendance ---- */ case "attendance_checkin_reminder": return { pill: "", label: "Check-in", title: "Attendance check-in is open", sub: p.date ? `For ${dateish(p.date)} — staff who have not checked in yet` : "" }; case "attendance_absent_report": { const n2 = Number(p.count ?? (p.names || []).length) || 0; return { pill: "amber", label: "Absences", title: `${n2} staff member${n2 === 1 ? "" : "s"} unmarked${p.date ? " on " + dateish(p.date) : ""}`, sub: (p.names || []).slice(0, 4).join(", ") + ((p.names || []).length > 4 ? ` +${p.names.length - 4} more` : "") }; } case "attendance_checkout_override": return { pill: "amber", label: "Off-site", title: `${someone(p.teacher_name, "A teacher")} checked out off-site`, sub: p.distance != null ? `${Math.round(Number(p.distance))} m from the gate` : "" }; /* ---- Teaching log ---- */ case "progress_extra_logged": return { pill: "green", label: "Progress", title: `${someone(p.teacher_name, "A teacher")} logged an outside-schedule class`, sub: [ (p.classes || []).join(", ") || null, p.subject_name, p.started_at ? `${p.started_at}–${p.ended_at || ""}` : null, p.topic, ].filter(Boolean).join(" · ") }; case "progress_period_prompt": return { pill: "", label: "Period log", title: `Log period ${p.period_no ?? "?"}${p.class_label ? " · " + p.class_label : ""}`, sub: [p.subject_name, dateish(p.work_date)].filter(Boolean).join(" · ") }; /* ---- Leave ---- */ case "leave_application_raised": return { pill: "amber", label: "Leave", title: `${someone(p.teacher_name, "A teacher")} applied for leave`, sub: [ p.start_date ? `${dateish(p.start_date)}${p.end_date && p.end_date !== p.start_date ? " – " + dateish(p.end_date) : ""}` : null, p.reason_category, p.reason_note, ].filter(Boolean).join(" · ") }; case "leave_application_approved": return { pill: "green", label: "Leave approved", title: `Leave approved${p.decided_by_name ? " by " + p.decided_by_name : ""}`, sub: [ p.start_date ? `${dateish(p.start_date)}${p.end_date && p.end_date !== p.start_date ? " – " + dateish(p.end_date) : ""}` : null, p.reason_category, p.decision_note, ].filter(Boolean).join(" · ") }; case "leave_cover_assigned": { const per = p.periods || []; return { pill: "blue", label: "Cover", title: `You are covering for ${someone(p.absent_teacher_name, "a colleague")}`, sub: per.length ? per.slice(0, 3).map((x) => `P${x.period_no} ${x.class_label || ""}${x.subject_name ? " · " + x.subject_name : ""}`).join(" · ") + (per.length > 3 ? ` +${per.length - 3} more` : "") : "" }; } /* ---- People ---- */ case "parent_registration_pending": return { pill: "amber", label: "New parent", title: `${someone(p.parent_name, "Someone")} registered as a parent`, sub: [p.phone, p.child_count ? `${p.child_count} child(ren)` : null, "approve on the Parents screen"].filter(Boolean).join(" · ") }; case "teacher_registration_pending": return { pill: "amber", label: "New staff", title: `${someone(p.teacher_name, "Someone")} registered and is awaiting approval`, sub: [p.role, p.phone, "approve on the Teachers screen"].filter(Boolean).join(" · ") }; case "password_reset_requested": return { pill: "amber", label: "Password reset", title: `${someone(p.teacher_name, "A teacher")} requested a password reset`, sub: p.phone || "" }; case "parent_chat_message": return { pill: "blue", label: "Parent message", title: "A parent replied in a chat thread", sub: (p.body || "").slice(0, 140) }; case "chat_report_raised": return { pill: "amber", label: "Chat report", title: "A chat message was reported", sub: (p.reason || p.body || "").slice(0, 140) }; default: // An unrecognised kind still reads as a sentence, never as a raw key. return { pill: "", label, title: label, sub: p.date ? dateish(p.date) : "" }; } }; /* ---------- Notifications panel ---------- */ // Hangs off the topbar bell. Polls /api/notifications every 30s for new items // and on open. Click an item to navigate (attempt_submitted → submissions // screen for that test) or open the edit-question modal (question_reported). 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 NotificationsBell = () => { const [open, setOpen] = useState(false); const [items, setItems] = useState([]); const [unread, setUnread] = useState(0); const [loading, setLoading] = useState(false); // Route through window.AppApi so the Authorization: Bearer header from the // login flow rides every request. Raw fetch() never attached it, which is // why /api/notifications kept returning 401 even when the admin's session // was healthy and the other admin routes worked. const refresh = useCallback(async () => { try { setLoading(true); const data = await window.AppApi.get("/notifications"); setItems(data?.items || []); setUnread(data?.unread || 0); } catch (_e) { // Silent — the bell polls every 30 s and we don't want a transient // network blip to clobber the existing items. } finally { setLoading(false); } }, []); useEffect(() => { refresh(); const t = setInterval(refresh, 30000); return () => clearInterval(t); }, [refresh]); // Refresh again whenever the panel is opened, in case items came in between polls. useEffect(() => { if (open) refresh(); }, [open, refresh]); 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)); setUnread(n => Math.max(0, n - 1)); }; 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() })); setUnread(0); }; const handleClick = (n) => { if (!n.read_at) markRead(n.id); if (n.kind === "attempt_submitted") { // Jump to the evaluate screen for this test. Mirrors the dashboard // "Submissions →" button — sets APP.EVAL_CONTEXT and asks app.jsx to // switch screens via the navigate hook. if (typeof window.APP?.navigate === "function") { window.APP.navigate("evaluate", { testDisplayId: n.payload?.test_display_id }); } setOpen(false); } else if (n.kind === "question_reported") { // Open inline edit modal — keeps the teacher on the current screen. window.dispatchEvent(new CustomEvent("app.openEditQuestion", { detail: n.payload })); setOpen(false); } else if (n.kind === "doubt_raised" || n.kind === "doubt_message") { // Open the doubts screen with the right thread preselected. if (typeof window.APP?.navigate === "function") { window.APP.navigate("doubts", { doubtId: n.payload?.doubt_id, tab: "open" }); } setOpen(false); } else if (n.kind === "progress_extra_logged") { // Jump to the full notifications panel. if (typeof window.APP?.navigate === "function") window.APP.navigate("notifications"); setOpen(false); } }; return (
{/* Portalled to , deliberately. An overlay must position against the VIEWPORT, and inside the topbar it could not: the topbar carries a backdrop-filter for the glass material, and a filtered element becomes the containing block for its position:fixed descendants. The panel was therefore resolving its inset against the topbar's box and only fitting the screen by coincidence. A portal removes the coupling entirely. */} {open && ReactDOM.createPortal( <>
setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 190 }}/>
Notifications {unread > 0 && ( )}
{items.length === 0 && (
{loading ? "Loading…" : "No notifications yet."}
)} {items.map(n => { // One shared renderer — see notificationMeta above. const meta = notificationMeta(n); return (
handleClick(n)} style={{ display: "block", padding: "11px 16px", borderBottom: "1px solid var(--line-soft)", background: n.read_at ? "transparent" : "var(--accent-bg)", cursor: "pointer", }}>
{!n.read_at && } {meta.label} {relTime(n.created_at)}
{meta.title}
{meta.sub}
); })}
{ if (window.APP?.navigate) window.APP.navigate("notifications"); setOpen(false); }} style={{ padding: "12px 16px", textAlign: "center", cursor: "pointer", fontWeight: 500, color: "var(--accent)", fontSize: 13, borderTop: "1px solid var(--line-soft)" }}> View all notifications →
, document.body, )}
); }; /* ---------- Edit-question modal (triggered by question_reported notifications) ---------- */ const EditQuestionModal = () => { const [payload, setPayload] = useState(null); const [text, setText] = useState(""); const [marks, setMarks] = useState(""); const [saving, setSaving] = useState(false); const [err, setErr] = useState(null); useEffect(() => { const onOpen = (e) => { setPayload(e.detail); setText(e.detail?.question_text || ""); setMarks(e.detail?.question_marks != null ? String(e.detail.question_marks) : ""); setErr(null); }; window.addEventListener("app.openEditQuestion", onOpen); return () => window.removeEventListener("app.openEditQuestion", onOpen); }, []); if (!payload) return null; const close = () => setPayload(null); const save = async () => { setSaving(true); setErr(null); try { const body = { text }; if (marks && !Number.isNaN(Number(marks))) body.marks = Number(marks); await window.AppApi.patch(`/questions/${payload.question_id}`, body); close(); } catch (e) { setErr(e.message); } finally { setSaving(false); } }; return (
e.stopPropagation()} style={{ background: "var(--surface)", border: "1px solid var(--line-strong)", borderRadius: 20, boxShadow: "var(--shadow-pop)", width: 560, maxWidth: "92vw", padding: 22, }}>

Edit Q{payload.q_no} · {payload.test_title}

Reported by {payload.student_name} ({payload.student_roll})
{payload.note || "(no note)"}