// Case index — master register of all FOI cases with faceted advanced search. function CaseIndex({ go }) { const all = window.REQUESTS; // Deep-link: the Dashboard sets window.__openCaseId before navigating here, so a // clicked request opens straight to its detail. One-shot — cleared on read. const [sel, setSel] = React.useState(() => { const id = window.__openCaseId; window.__openCaseId = null; return id ? (window.REQUESTS || []).find((x) => x.id === id) || null : null; }); // Status lane (the triage tabs, carried over from the old Tracker) + advanced filters. const [lane, setLane] = React.useState("all"); const [q, setQ] = React.useState(""); const [types, setTypes] = React.useState([]); // recipientType const [statuses, setStatuses] = React.useState([]); // status const [cats, setCats] = React.useState([]); // category const [channels, setChannels] = React.useState([]); const [owner, setOwner] = React.useState("any"); const [deadlineState, setDeadlineState] = React.useState("any"); // any|overdue|due-soon|on-track|closed const [from, setFrom] = React.useState(""); const [to, setTo] = React.useState(""); const [sort, setSort] = React.useState("newest"); const toggle = (list, setList, v) => setList(list.includes(v) ? list.filter((x) => x !== v) : [...list, v]); const clearAll = () => { setQ(""); setTypes([]); setStatuses([]); setCats([]); setChannels([]); setOwner("any"); setDeadlineState("any"); setFrom(""); setTo(""); }; const deadlineOf = (r) => { if (["fulfilled", "refused", "partial"].includes(r.status)) return "closed"; if (!r.sentOn) return "draft"; const left = (r.deadlineDays || 20) - r.workingDaysElapsed; if (left < 0) return "overdue"; if (left <= 3) return "due-soon"; return "on-track"; }; // Status-lane membership (the triage tabs carried over from the Tracker). const inLane = (r) => { if (lane === "all") return true; if (lane === "active") return !["fulfilled", "refused", "draft"].includes(r.status); if (lane === "overdue") return r.status === "overdue"; if (lane === "closed") return ["fulfilled", "refused"].includes(r.status); return r.status === lane; // internal-review }; const results = React.useMemo(() => { let rows = all.filter((r) => { if (!inLane(r)) return false; if (q) { const hay = `${r.id} ${r.title} ${r.recipientName} ${(r.tags || []).join(" ")} ${r.category} ${r.owner}`.toLowerCase(); if (!hay.includes(q.toLowerCase())) return false; } if (types.length && !types.includes(r.recipientType)) return false; if (statuses.length && !statuses.includes(r.status)) return false; if (cats.length && !cats.includes(r.category)) return false; if (channels.length && !channels.includes(r.targetLevel)) return false; if (owner !== "any" && r.owner !== owner) return false; if (deadlineState !== "any" && deadlineOf(r) !== deadlineState) return false; if (from && r.sentOn && r.sentOn < from) return false; if (to && r.sentOn && r.sentOn > to) return false; if ((from || to) && !r.sentOn) return false; return true; }); rows = [...rows].sort((a, b) => { if (sort === "newest") return (b.sentOn || "0") < (a.sentOn || "0") ? -1 : 1; if (sort === "oldest") return (a.sentOn || "9") < (b.sentOn || "9") ? -1 : 1; if (sort === "deadline") return (a.workingDaysElapsed ? 20 - a.workingDaysElapsed : 99) - (b.workingDaysElapsed ? 20 - b.workingDaysElapsed : 99); if (sort === "replies") return b.replies - a.replies; if (sort === "title") return a.title.localeCompare(b.title); return 0; }); return rows; }, [all, lane, q, types, statuses, cats, channels, owner, deadlineState, from, to, sort]); const activeCount = types.length + statuses.length + cats.length + channels.length + (owner !== "any" ? 1 : 0) + (deadlineState !== "any" ? 1 : 0) + (from ? 1 : 0) + (to ? 1 : 0) + (q ? 1 : 0); if (sel) return setSel(null)} />; // Facet counts (against everything, classic GOV.UK style) const countBy = (key, val, valueGetter) => all.filter((r) => (valueGetter ? valueGetter(r) : r[key]) === val).length; return (
}>Cases {/* Status lanes — quick triage tabs (carried over from the Tracker), compose with the filters below */}
{[ { id: "all", label: "All", count: all.length }, { id: "active", label: "Active", count: all.filter((r) => !["fulfilled", "refused", "draft"].includes(r.status)).length }, { id: "overdue", label: "Overdue", count: all.filter((r) => r.status === "overdue").length }, { id: "internal-review", label: "Review", count: all.filter((r) => r.status === "internal-review").length }, { id: "closed", label: "Closed", count: all.filter((r) => ["fulfilled", "refused"].includes(r.status)).length }, ].map((t) => ( ))}
{/* ── Filter rail ── */} {/* ── Results ── */}
{results.length} of {all.length} cases
Sort
{/* Active filter chips */} {activeCount > 0 && (
{q && setQ("")} />} {types.map((t) => toggle(types, setTypes, t)} />)} {statuses.map((s) => toggle(statuses, setStatuses, s)} />)} {cats.map((c) => toggle(cats, setCats, c)} />)} {channels.map((c) => toggle(channels, setChannels, c)} />)} {owner !== "any" && setOwner("any")} />} {deadlineState !== "any" && setDeadlineState("any")} />} {from && setFrom("")} />} {to && setTo("")} />}
)} {results.map((r) => { const ds = deadlineOf(r); const budget = r.deadlineDays || 20; const left = budget - r.workingDaysElapsed; const pct = r.workingDaysElapsed ? (r.workingDaysElapsed / budget) * 100 : 0; const tone = ds === "overdue" ? "danger" : ds === "due-soon" ? "warn" : "info"; return ( setSel(r)} style={{ cursor: "pointer", borderBottom: "1px solid var(--rule)" }} onMouseEnter={(e) => e.currentTarget.style.background = "var(--surface-2)"} onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}> ); })}
# Case Type Progress Deadline Replies
{String(r.caseIndex).padStart(2, "0")}
{r.id} {r.category}
{r.title}
{r.recipientName} · {r.owner}
{r.recipientType} {r.sentOn ? ( ds === "closed" ? Closed : ( <>
{left >= 0 ? `${left}d left` : `${Math.abs(left)}d over`}
) ) : }
{r.replies}
{results.length === 0 && (
No cases match your filters
Try removing a filter or .
)}
); } function FacetGroup({ title, children, defaultOpen = true }) { const [open, setOpen] = React.useState(defaultOpen); return (
{open &&
{children}
}
); } function FacetCheck({ checked, onChange, label, count, tone }) { return ( ); } function FacetRadioRow({ checked, onChange, label, count }) { return ( ); } function Chip({ label, onClear }) { return ( {label} ); } // Case detail — deadline, real timeline, two-way conversation, and status actions. // (Moved here from the former Tracker screen during consolidation.) function RequestDetail({ r, back }) { // Full two-way conversation for this case ref — our sent request(s) + inbound replies. const [thread, setThread] = React.useState([]); React.useEffect(() => { fetch(`/api/thread?ref=${encodeURIComponent(r.id || "")}`) .then((res) => (res.ok ? res.json() : Promise.reject(res.status))) .then((d) => setThread(d.messages || [])) .catch(() => setThread([])); }, [r.id]); const replies = thread.filter((m) => !m.outbound); // genuine inbound replies (drive the count + timeline) // Manual status override — wires the detail action buttons to /api/cases/{id}/status. const [localStatus, setLocalStatus] = React.useState(null); const [busy, setBusy] = React.useState(false); const [openReply, setOpenReply] = React.useState(null); // id of the expanded reply row const curStatus = localStatus || r.status; const setStatus = async (s) => { setBusy(true); try { const resp = await fetch(`/api/cases/${encodeURIComponent(r.id)}/status`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: s }), }); if (resp.ok) { if (window.refreshCases) { try { await window.refreshCases(); } catch (_) {} } const updated = (window.REQUESTS || []).find((x) => x.id === r.id); setLocalStatus(updated ? updated.status : (s || r.status)); } } finally { setBusy(false); } }; const budget = r.deadlineDays || 20; const pct = r.workingDaysElapsed ? (r.workingDaysElapsed / budget) * 100 : 0; const tone = curStatus === "overdue" ? "danger" : pct > 70 ? "warn" : "info"; const daysLeft = budget - r.workingDaysElapsed; // Timeline driven by real signals: the dispatch, the first actual reply (from the // matched inbox messages), the statutory deadline, and the current (override-aware) status. const replyTimes = replies.map((m) => new Date(m.date)).filter((d) => !isNaN(d.getTime())); const firstReply = replyTimes.length ? new Date(Math.min(...replyTimes.map((d) => d.getTime()))) : null; const hasReply = replies.length > 0; const answered = ["fulfilled", "partial", "refused"].includes(curStatus); const timeline = [ { date: r.sentOn, label: "Dispatched", desc: `Sent via ${r.channel} to ${r.recipientName}`, ok: !!r.sentOn }, { date: firstReply ? firstReply.toLocaleDateString("en-CA") : null, label: "Reply received", desc: hasReply ? `${replies.length} message${replies.length === 1 ? "" : "s"} on this thread` : "Awaiting first reply", ok: hasReply }, { date: r.deadline, label: "Substantive response due", desc: "Statutory FOIA deadline", ok: answered }, { date: null, label: "Internal review", desc: "If refused or partial", ok: curStatus === "internal-review" } ]; return (
{r.id}

{r.title}

{r.summary}
{curStatus !== "fulfilled" && } {curStatus !== "refused" && } {curStatus !== "internal-review" && }
{r.sentOn && (
Response deadline
FOIA · {budget} working days{(r.recipientType === "MAT" || r.recipientType === "School") ? " (school-day rule)" : ""}
{daysLeft >= 0 ? `${daysLeft}` : `+${Math.abs(daysLeft)}`}
{daysLeft >= 0 ? "days left" : "days overdue"}
Sent {r.sentOn} Due {r.deadline}
)}
Timeline
{timeline.map((step, i) => (
{step.ok && }
{step.label}
{step.desc}
{step.date || "—"}
))}
{thread.length > 0 && (
Conversation
{thread.length} message{thread.length === 1 ? "" : "s"} · {replies.length} repl{replies.length === 1 ? "y" : "ies"}
{thread.map((m) => { const fm = (m.from || "").match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>/); const fromName = (fm ? (fm[1] || fm[2]) : (m.from || "")).trim() || "Unknown sender"; const open = openReply === m.id; const tone = m.outbound ? "var(--accent)" : "var(--ok-fg)"; return (
setOpenReply(open ? null : m.id)} onMouseEnter={(e) => e.currentTarget.style.background = "var(--surface-2)"} onMouseLeave={(e) => e.currentTarget.style.background = "transparent"} style={{ padding: "12px 18px", cursor: "pointer" }}>
{m.outbound ? "SENT" : "REPLY"}
{fromName}
{(m.date || "").slice(0, 16)}
{m.subject || "(no subject)"}
{open ? "Hide ▲" : "View ▼"}
{open && (
{(m.body || m.snippet || "(no message body)").trim()}
)}
); })}
)}
Details
Status Recipient{r.recipientName} Channel{r.channel} Sent{r.sentOn || "—"} Owner{r.owner} Replies{replies.length}
Compliance checks
  • Within s.12 cost limit
  • No vexatious-history flags
  • {r.status === "overdue" ? "!" : "✓"} {r.status === "overdue" ? "Past 20-working-day deadline" : "Within 20-working-day window"}
  • Redaction pass complete
); } Object.assign(window, { CaseIndex, RequestDetail });