Add 504 / IEP plans with tiered confidentiality

Plans, accommodations, related services, annual goals with progress
monitoring, meetings/team, and signed documents — plus a compliance
list and dashboard alerts for annual-review and triennial re-evaluation
dates (overdue in red, due-within-30-days in amber).

Access is tiered because special-education records are need-to-know
under FERPA:

  FULL  admin, the plan's case manager, the student's parents
  IMPL  the above, plus any teacher of the student — accommodations
        and services only, never eligibility or meeting notes

RLS is row-level and every app role is the same Postgres role
(`authenticated`), so column grants cannot separate the tiers. The
split is therefore physical: confidential fields live in plan_details,
plan_goals, plan_meetings and plan_documents rather than as columns on
student_plans.

The UI asks the database which tier applies via the same predicates the
policies use (can_view_plan_full / can_edit_plan) instead of re-deriving
the rules client-side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 09:14:40 -04:00
co-authored by Claude Opus 5
parent 872eacefc5
commit 9d7d18669d
9 changed files with 2905 additions and 3 deletions
+48 -2
View File
@@ -1,8 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useAuth, highestRole } from "@/hooks/use-auth";
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Users, ClipboardCheck, Receipt, CalendarDays } from "lucide-react";
import { Users, ClipboardCheck, Receipt, CalendarDays, ClipboardList, AlertTriangle } from "lucide-react";
import { COMPLIANCE_CLASS, complianceState, formatDate } from "@/lib/plans";
export const Route = createFileRoute("/_authenticated/dashboard")({
head: () => ({ meta: [{ title: "Dashboard — School Portal" }] }),
@@ -39,6 +40,26 @@ function Dashboard() {
},
});
// RLS scopes this to plans the viewer may see, so it is empty for most users.
const { data: planAlerts } = useQuery({
queryKey: ["plan-alerts"],
queryFn: async () => {
const { data } = await supabase
.from("student_plans")
.select("id, student_id, next_annual_review_date, next_reevaluation_date, students(first_name, last_name)")
.neq("status", "archived");
return (data ?? []).flatMap((p) => {
const rows: { key: string; student_id: string; name: string; kind: string; date: string }[] = [];
const name = `${p.students?.first_name ?? ""} ${p.students?.last_name ?? ""}`.trim();
if (p.next_annual_review_date) rows.push({ key: `${p.id}-ar`, student_id: p.student_id, name, kind: "Annual review", date: p.next_annual_review_date });
if (p.next_reevaluation_date) rows.push({ key: `${p.id}-re`, student_id: p.student_id, name, kind: "Re-evaluation", date: p.next_reevaluation_date });
return rows;
})
.filter((r) => complianceState(r.date) === "overdue" || complianceState(r.date) === "due_soon")
.sort((a, b) => (a.date < b.date ? -1 : 1));
},
});
return (
<div className="p-8 max-w-6xl">
<h1 className="text-2xl font-semibold">Welcome back</h1>
@@ -50,6 +71,31 @@ function Dashboard() {
<Stat icon={CalendarDays} label="Upcoming events" value={upcoming?.length ?? "—"} />
</div>
{planAlerts && planAlerts.length > 0 && (
<div className="mt-8 bg-card border rounded-lg p-6">
<h2 className="font-semibold mb-3 flex items-center gap-2">
<ClipboardList className="h-4 w-4" /> 504 / IEP compliance needing attention
</h2>
<ul className="space-y-2 text-sm">
{planAlerts.slice(0, 8).map((a) => (
<li key={a.key} className="flex justify-between gap-3 border-b last:border-0 py-2">
<span className="flex items-center gap-2">
{complianceState(a.date) === "overdue" && <AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />}
<Link to="/students/$id" params={{ id: a.student_id }} className="text-primary hover:underline">{a.name}</Link>
<span className="text-muted-foreground">· {a.kind}</span>
</span>
<span className={COMPLIANCE_CLASS[complianceState(a.date)]}>{formatDate(a.date)}</span>
</li>
))}
</ul>
{planAlerts.length > 8 && (
<Link to="/plans" className="text-xs text-primary hover:underline mt-3 inline-block">
View all {planAlerts.length} alerts
</Link>
)}
</div>
)}
<div className="mt-8 bg-card border rounded-lg p-6">
<h2 className="font-semibold mb-3 flex items-center gap-2"><CalendarDays className="h-4 w-4" /> Upcoming on the calendar</h2>
{upcoming && upcoming.length > 0 ? (