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:
@@ -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 ? (
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AlertTriangle, ClipboardList, Loader2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
COMPLIANCE_CLASS,
|
||||
PLAN_STATUS_LABEL,
|
||||
PLAN_TYPE_LABEL,
|
||||
complianceState,
|
||||
formatDate,
|
||||
type PlanStatus,
|
||||
type PlanType,
|
||||
} from "@/lib/plans";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/plans")({
|
||||
head: () => ({ meta: [{ title: "504 / IEP Plans — School Portal" }] }),
|
||||
component: PlansList,
|
||||
});
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
student_id: string;
|
||||
plan_type: PlanType;
|
||||
status: PlanStatus;
|
||||
case_manager_id: string | null;
|
||||
next_annual_review_date: string | null;
|
||||
next_reevaluation_date: string | null;
|
||||
students: { first_name: string; last_name: string; grade_level: string | null } | null;
|
||||
};
|
||||
|
||||
type SortKey = "last_name" | "type" | "next_ar" | "next_eval";
|
||||
|
||||
function PlansList() {
|
||||
const [q, setQ] = useState("");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [sort, setSort] = useState<SortKey>("next_ar");
|
||||
|
||||
const { data: rows, isLoading } = useQuery({
|
||||
queryKey: ["plans-compliance"],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("student_plans")
|
||||
.select(
|
||||
"id, student_id, plan_type, status, case_manager_id, next_annual_review_date, next_reevaluation_date, students(first_name, last_name, grade_level)",
|
||||
);
|
||||
if (error) throw error;
|
||||
return (data ?? []) as unknown as Row[];
|
||||
},
|
||||
});
|
||||
|
||||
// profiles is admin-readable only, so non-admins simply get no names here.
|
||||
const { data: staff } = useQuery({
|
||||
queryKey: ["plan-staff"],
|
||||
queryFn: async () => {
|
||||
const { data: roles } = await supabase
|
||||
.from("user_roles")
|
||||
.select("user_id, role")
|
||||
.in("role", ["admin", "teacher"]);
|
||||
const ids = [...new Set((roles ?? []).map((r) => r.user_id))];
|
||||
if (!ids.length)
|
||||
return [] as { id: string; full_name: string | null; email: string | null }[];
|
||||
const { data } = await supabase.from("profiles").select("id, full_name, email").in("id", ids);
|
||||
return data ?? [];
|
||||
},
|
||||
});
|
||||
const managerName = (id: string | null) => {
|
||||
if (!id) return "Unassigned";
|
||||
const m = (staff ?? []).find((s) => s.id === id);
|
||||
return m ? m.full_name || m.email || "Assigned" : "Assigned";
|
||||
};
|
||||
|
||||
const worst = (r: Row) => {
|
||||
const a = complianceState(r.next_annual_review_date);
|
||||
const b = complianceState(r.next_reevaluation_date);
|
||||
if (a === "overdue" || b === "overdue") return "overdue";
|
||||
if (a === "due_soon" || b === "due_soon") return "due_soon";
|
||||
return "ok";
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = rows ?? [];
|
||||
const term = q.trim().toLowerCase();
|
||||
if (term) {
|
||||
list = list.filter((r) =>
|
||||
`${r.students?.first_name ?? ""} ${r.students?.last_name ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(term),
|
||||
);
|
||||
}
|
||||
if (filter === "overdue") list = list.filter((r) => worst(r) === "overdue");
|
||||
else if (filter === "due_soon") list = list.filter((r) => worst(r) === "due_soon");
|
||||
else if (filter === "iep") list = list.filter((r) => r.plan_type === "iep");
|
||||
else if (filter === "section_504") list = list.filter((r) => r.plan_type === "section_504");
|
||||
else if (filter === "active") list = list.filter((r) => r.status === "active");
|
||||
|
||||
// Nulls sort last so unset compliance dates never masquerade as urgent.
|
||||
const byDate = (x: string | null, y: string | null) =>
|
||||
x === y ? 0 : x === null ? 1 : y === null ? -1 : x < y ? -1 : 1;
|
||||
|
||||
return [...list].sort((a, b) => {
|
||||
switch (sort) {
|
||||
case "last_name":
|
||||
return (a.students?.last_name ?? "").localeCompare(b.students?.last_name ?? "");
|
||||
case "type":
|
||||
return a.plan_type.localeCompare(b.plan_type);
|
||||
case "next_eval":
|
||||
return byDate(a.next_reevaluation_date, b.next_reevaluation_date);
|
||||
default:
|
||||
return byDate(a.next_annual_review_date, b.next_annual_review_date);
|
||||
}
|
||||
});
|
||||
}, [rows, q, filter, sort]);
|
||||
|
||||
const overdueCount = (rows ?? []).filter((r) => worst(r) === "overdue").length;
|
||||
const dueSoonCount = (rows ?? []).filter((r) => worst(r) === "due_soon").length;
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-6xl">
|
||||
<h1 className="text-2xl font-semibold flex items-center gap-2">
|
||||
<ClipboardList className="h-6 w-6 text-primary" /> 504 / IEP Plans
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Compliance clocks for every plan you have access to. Overdue dates are shown in red.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
|
||||
<div className="bg-card border rounded-lg p-4">
|
||||
<div className="text-3xl font-semibold">{rows?.length ?? "—"}</div>
|
||||
<div className="text-sm text-muted-foreground">Plans on file</div>
|
||||
</div>
|
||||
<div className="bg-card border rounded-lg p-4">
|
||||
<div className={`text-3xl font-semibold ${overdueCount > 0 ? "text-destructive" : ""}`}>
|
||||
{overdueCount}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
{overdueCount > 0 && <AlertTriangle className="h-3.5 w-3.5 text-destructive" />} Overdue
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border rounded-lg p-4">
|
||||
<div className={`text-3xl font-semibold ${dueSoonCount > 0 ? "text-amber-600" : ""}`}>
|
||||
{dueSoonCount}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Due within 30 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center mt-6">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder="Filter by student name…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">No filter</SelectItem>
|
||||
<SelectItem value="overdue">Overdue only</SelectItem>
|
||||
<SelectItem value="due_soon">Due within 30 days</SelectItem>
|
||||
<SelectItem value="active">Active plans</SelectItem>
|
||||
<SelectItem value="iep">IEP only</SelectItem>
|
||||
<SelectItem value="section_504">504 only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as SortKey)}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="next_ar">Sort: next annual review</SelectItem>
|
||||
<SelectItem value="next_eval">Sort: next re-evaluation</SelectItem>
|
||||
<SelectItem value="last_name">Sort: last name</SelectItem>
|
||||
<SelectItem value="type">Sort: plan type</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 bg-card border rounded-lg overflow-x-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading plans…
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-6 text-sm text-muted-foreground">
|
||||
{rows?.length === 0
|
||||
? "No 504 or IEP plans on file yet."
|
||||
: "No plans match this filter."}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-left">
|
||||
<tr>
|
||||
<th className="p-3 font-medium">Student</th>
|
||||
<th className="p-3 font-medium">Grade</th>
|
||||
<th className="p-3 font-medium">Type</th>
|
||||
<th className="p-3 font-medium">Status</th>
|
||||
<th className="p-3 font-medium">Case manager</th>
|
||||
<th className="p-3 font-medium">Next annual review</th>
|
||||
<th className="p-3 font-medium">Next re-evaluation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filtered.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<Link
|
||||
to="/students/$id"
|
||||
params={{ id: r.student_id }}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{r.students?.last_name}, {r.students?.first_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{r.students?.grade_level || "—"}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={r.plan_type === "iep" ? "default" : "secondary"}>
|
||||
{PLAN_TYPE_LABEL[r.plan_type]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{PLAN_STATUS_LABEL[r.status]}</td>
|
||||
<td className="p-3 text-muted-foreground">{managerName(r.case_manager_id)}</td>
|
||||
<td
|
||||
className={`p-3 ${COMPLIANCE_CLASS[complianceState(r.next_annual_review_date)]}`}
|
||||
>
|
||||
{formatDate(r.next_annual_review_date)}
|
||||
</td>
|
||||
<td
|
||||
className={`p-3 ${COMPLIANCE_CLASS[complianceState(r.next_reevaluation_date)]}`}
|
||||
>
|
||||
{formatDate(r.next_reevaluation_date)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { supabase } from "@/integrations/supabase/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
GraduationCap, LayoutDashboard, Users, ClipboardCheck, Receipt,
|
||||
MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2, BookOpen
|
||||
MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2, BookOpen, ClipboardList
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -34,6 +34,7 @@ function ProtectedLayout() {
|
||||
{ to: "/students", label: "Students", icon: Users, show: true },
|
||||
{ to: "/classes", label: "Classes", icon: BookOpen, show: isAdmin || roles.includes("teacher") },
|
||||
{ to: "/attendance", label: "Attendance", icon: ClipboardCheck, show: isAdmin || roles.includes("teacher") },
|
||||
{ to: "/plans", label: "504 / IEP", icon: ClipboardList, show: isAdmin || roles.includes("teacher") },
|
||||
{ to: "/ledger", label: "Tuition", icon: Receipt, show: true },
|
||||
{ to: "/messages", label: "Messages", icon: MessageSquare, show: true },
|
||||
{ to: "/forms", label: "Forms", icon: FileText, show: true },
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createUserFn } from "@/lib/user-admin.functions";
|
||||
import { createIntakeToken } from "@/lib/intake.functions";
|
||||
import { genTempPassword } from "@/lib/temp-password";
|
||||
import { StudentGradeReport } from "@/components/gradebook";
|
||||
import { StudentPlansTab } from "@/components/plans";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/students/$id")({
|
||||
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
|
||||
@@ -90,6 +91,7 @@ function StudentDetail() {
|
||||
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||||
<TabsTrigger value="family">Family & pickup</TabsTrigger>
|
||||
<TabsTrigger value="academics">Academics</TabsTrigger>
|
||||
<TabsTrigger value="plans">504 / IEP</TabsTrigger>
|
||||
<TabsTrigger value="grades">Grades</TabsTrigger>
|
||||
<TabsTrigger value="attendance">Attendance</TabsTrigger>
|
||||
<TabsTrigger value="ledger">Tuition</TabsTrigger>
|
||||
@@ -98,6 +100,7 @@ function StudentDetail() {
|
||||
<TabsContent value="profile"><ProfileTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
|
||||
<TabsContent value="family"><FamilyTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
|
||||
<TabsContent value="academics"><AcademicsTab studentId={id} canEdit={canEdit} /></TabsContent>
|
||||
<TabsContent value="plans"><StudentPlansTab studentId={id} isAdmin={isAdmin} /></TabsContent>
|
||||
<TabsContent value="grades" className="mt-4"><StudentGradeReport studentId={id} classId={(student?.class_id as string | null) ?? null} /></TabsContent>
|
||||
<TabsContent value="attendance"><AttendanceTab studentId={id} /></TabsContent>
|
||||
<TabsContent value="ledger"><LedgerTab studentId={id} canEdit={isAdmin} /></TabsContent>
|
||||
|
||||
Reference in New Issue
Block a user