From 9d7d18669d8176b456477f7425c62a1af013f3cf Mon Sep 17 00:00:00 2001 From: renee-png Date: Sun, 26 Jul 2026 09:14:40 -0400 Subject: [PATCH] Add 504 / IEP plans with tiered confidentiality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/plans.tsx | 1654 +++++++++++++++++ src/integrations/supabase/types.ts | 444 +++++ src/lib/plans.ts | 116 ++ src/routeTree.gen.ts | 21 + src/routes/_authenticated/dashboard.tsx | 50 +- src/routes/_authenticated/plans.tsx | 252 +++ src/routes/_authenticated/route.tsx | 3 +- src/routes/_authenticated/students.$id.tsx | 3 + .../20260725120000_student_plans_504_iep.sql | 365 ++++ 9 files changed, 2905 insertions(+), 3 deletions(-) create mode 100644 src/components/plans.tsx create mode 100644 src/lib/plans.ts create mode 100644 src/routes/_authenticated/plans.tsx create mode 100644 supabase/migrations/20260725120000_student_plans_504_iep.sql diff --git a/src/components/plans.tsx b/src/components/plans.tsx new file mode 100644 index 0000000..0483c6b --- /dev/null +++ b/src/components/plans.tsx @@ -0,0 +1,1654 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Plus, + Trash2, + Pencil, + Check, + Lock, + FileText, + Target, + CalendarClock, + ClipboardList, + Loader2, + Users, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + ACCOMMODATION_CATEGORIES, + GOAL_STATUS_LABEL, + MEETING_TYPE_LABEL, + PLAN_STATUS_LABEL, + PLAN_TYPE_LABEL, + COMPLIANCE_CLASS, + accommodationLabel, + complianceLabel, + complianceState, + daysUntil, + defaultReviewDates, + formatDate, + todayISO, + type GoalStatus, + type MeetingType, + type PlanStatus, + type PlanType, +} from "@/lib/plans"; + +type PlanRow = { + id: string; + student_id: string; + plan_type: PlanType; + status: PlanStatus; + case_manager_id: string | null; + effective_date: string | null; + end_date: string | null; + next_annual_review_date: string | null; + next_reevaluation_date: string | null; +}; + +const err = (e: unknown) => toast.error((e as Error).message); + +// ── Small shared bits ─────────────────────────────────────────────────────── +function SectionCard({ + title, + icon: Icon, + confidential, + action, + children, +}: { + title: string; + icon: typeof Target; + confidential?: boolean; + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+

+ {title} + {confidential && ( + + Confidential + + )} +

+ {action} +
+ {children} +
+ ); +} + +function Empty({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +export function ComplianceDate({ date, label }: { date: string | null; label: string }) { + const state = complianceState(date); + return ( +
+
{label}
+
{formatDate(date)}
+ {state !== "none" && ( +
+ {complianceLabel(state, daysUntil(date))} +
+ )} +
+ ); +} + +// Staff list for the case-manager picker. profiles/user_roles are admin-readable +// only, so this returns empty for everyone else — the picker is admin-only UI. +function useStaff() { + return 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 ?? []; + }, + }); +} + +// ── Entry point: the Plans tab on a student ───────────────────────────────── +export function StudentPlansTab({ studentId, isAdmin }: { studentId: string; isAdmin: boolean }) { + const qc = useQueryClient(); + const [adding, setAdding] = useState(false); + + const { data: plans, isLoading } = useQuery({ + queryKey: ["student-plans", studentId], + queryFn: async () => { + const { data, error } = await supabase + .from("student_plans") + .select("*") + .eq("student_id", studentId) + .order("created_at", { ascending: false }); + if (error) throw error; + return (data ?? []) as PlanRow[]; + }, + }); + + if (isLoading) { + return ( +
+ Loading plans… +
+ ); + } + + return ( +
+ {isAdmin && + (adding ? ( + { + setAdding(false); + qc.invalidateQueries({ queryKey: ["student-plans", studentId] }); + }} + /> + ) : ( + + ))} + {(plans ?? []).map((p) => ( + + ))} + {plans?.length === 0 && No 504 or IEP plan on file for this student.} +
+ ); +} + +function NewPlanForm({ studentId, onDone }: { studentId: string; onDone: () => void }) { + const { data: staff } = useStaff(); + const [saving, setSaving] = useState(false); + const [f, setF] = useState({ + plan_type: "iep" as PlanType, + status: "draft" as PlanStatus, + case_manager_id: "", + effective_date: todayISO(), + next_annual_review_date: defaultReviewDates(todayISO()).annual, + next_reevaluation_date: defaultReviewDates(todayISO()).reeval, + }); + + const save = async () => { + setSaving(true); + try { + const { data: u } = await supabase.auth.getUser(); + const { error } = await supabase.from("student_plans").insert({ + student_id: studentId, + plan_type: f.plan_type, + status: f.status, + case_manager_id: f.case_manager_id || null, + effective_date: f.effective_date || null, + next_annual_review_date: f.next_annual_review_date || null, + next_reevaluation_date: f.next_reevaluation_date || null, + created_by: u.user?.id, + }); + if (error) throw error; + toast.success("Plan created"); + onDone(); + } catch (e) { + err(e); + } finally { + setSaving(false); + } + }; + + return ( +
+
New plan
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/* Uncontrolled dates so Safari's native picker isn't reset by React. */} +
+ + { + const v = e.target.value; + setF((prev) => + v + ? { + ...prev, + effective_date: v, + ...(() => { + const d = defaultReviewDates(v); + return { + next_annual_review_date: d.annual, + next_reevaluation_date: d.reeval, + }; + })(), + } + : { ...prev, effective_date: v }, + ); + }} + /> +
+
+ + setF({ ...f, next_annual_review_date: e.target.value })} + /> +
+
+ + setF({ ...f, next_reevaluation_date: e.target.value })} + /> +
+
+

+ Review dates default to +1 year and +3 years from the effective date. Adjust to match the + district's actual timeline. +

+
+ + +
+
+ ); +} + +// ── One plan ──────────────────────────────────────────────────────────────── +function PlanCard({ plan, isAdmin }: { plan: PlanRow; isAdmin: boolean }) { + const qc = useQueryClient(); + const [editing, setEditing] = useState(false); + + // Ask the database who we are rather than re-deriving the tier rules here — + // these are the same predicates the RLS policies use. + const { data: access } = useQuery({ + queryKey: ["plan-access", plan.id], + queryFn: async () => { + const [full, edit] = await Promise.all([ + supabase.rpc("can_view_plan_full", { _plan: plan.id }), + supabase.rpc("can_edit_plan", { _plan: plan.id }), + ]); + return { full: full.data === true, edit: edit.data === true }; + }, + }); + const canViewFull = access?.full ?? false; + const canEdit = access?.edit ?? false; + + const { data: staff } = useStaff(); + const manager = (staff ?? []).find((s) => s.id === plan.case_manager_id); + + const invalidate = () => qc.invalidateQueries({ queryKey: ["student-plans", plan.student_id] }); + + const remove = async () => { + if (!confirm("Delete this plan and everything attached to it? This cannot be undone.")) return; + const { error } = await supabase.from("student_plans").delete().eq("id", plan.id); + if (error) return err(error); + toast.success("Plan deleted"); + invalidate(); + }; + + return ( +
+
+
+
+ + {PLAN_TYPE_LABEL[plan.plan_type]} + + + {PLAN_STATUS_LABEL[plan.status]} + + + {formatDate(plan.effective_date)} + {plan.end_date ? ` – ${formatDate(plan.end_date)}` : ""} + +
+
+ {canEdit && + (editing ? ( + + ) : ( + + ))} + {isAdmin && ( + + )} +
+
+ +
+ + +
+
Case manager
+
+ {manager + ? manager.full_name || manager.email + : plan.case_manager_id + ? "Assigned" + : "Unassigned"} +
+
+
+ + {editing && } +
+ +
+ + + {canViewFull ? ( + <> + + + + + + ) : ( +
+ + + Eligibility, goals, meeting notes and documents are restricted to the case manager, + administrators, and the student's parents. You can see the accommodations and services + you are responsible for implementing. + +
+ )} +
+
+ ); +} + +function PlanHeaderEditor({ + plan, + staff, + onSaved, +}: { + plan: PlanRow; + staff: { id: string; full_name: string | null; email: string | null }[]; + onSaved: () => void; +}) { + const [f, setF] = useState({ + status: plan.status, + case_manager_id: plan.case_manager_id ?? "", + effective_date: plan.effective_date ?? "", + end_date: plan.end_date ?? "", + next_annual_review_date: plan.next_annual_review_date ?? "", + next_reevaluation_date: plan.next_reevaluation_date ?? "", + }); + const [saving, setSaving] = useState(false); + + const save = async () => { + setSaving(true); + try { + const { error } = await supabase + .from("student_plans") + .update({ + status: f.status, + case_manager_id: f.case_manager_id || null, + effective_date: f.effective_date || null, + end_date: f.end_date || null, + next_annual_review_date: f.next_annual_review_date || null, + next_reevaluation_date: f.next_reevaluation_date || null, + }) + .eq("id", plan.id); + if (error) throw error; + toast.success("Plan updated"); + onSaved(); + } catch (e) { + err(e); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+ + +
+
+ + +
+
+ + setF({ ...f, effective_date: e.target.value })} + /> +
+
+ + setF({ ...f, end_date: e.target.value })} + /> +
+
+ + setF({ ...f, next_annual_review_date: e.target.value })} + /> +
+
+ + setF({ ...f, next_reevaluation_date: e.target.value })} + /> +
+
+ +
+ ); +} + +// ── Accommodations (visible to implementing teachers) ─────────────────────── +function AccommodationsSection({ planId, canEdit }: { planId: string; canEdit: boolean }) { + const qc = useQueryClient(); + const key = ["plan-accommodations", planId]; + const { data } = useQuery({ + queryKey: key, + queryFn: async () => + ( + await supabase + .from("plan_accommodations") + .select("*") + .eq("plan_id", planId) + .order("sort_order") + ).data ?? [], + }); + const [draft, setDraft] = useState({ category: "instructional", description: "", setting: "" }); + const [adding, setAdding] = useState(false); + + const add = async () => { + if (!draft.description.trim()) return toast.error("Describe the accommodation"); + const { error } = await supabase.from("plan_accommodations").insert({ + plan_id: planId, + category: draft.category, + description: draft.description.trim(), + setting: draft.setting || null, + sort_order: data?.length ?? 0, + }); + if (error) return err(error); + setDraft({ category: "instructional", description: "", setting: "" }); + setAdding(false); + qc.invalidateQueries({ queryKey: key }); + }; + const remove = async (id: string) => { + const { error } = await supabase.from("plan_accommodations").delete().eq("id", id); + if (error) return err(error); + qc.invalidateQueries({ queryKey: key }); + }; + + return ( + setAdding(true)}> + Add + + ) : undefined + } + > + {adding && ( +
+
+ + setDraft({ ...draft, setting: e.target.value })} + /> +
+