diff --git a/src/routes/_authenticated/plans.tsx b/src/routes/_authenticated/plans.tsx new file mode 100644 index 0000000..b40b41d --- /dev/null +++ b/src/routes/_authenticated/plans.tsx @@ -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("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 ( +
+

+ 504 / IEP Plans +

+

+ Compliance clocks for every plan you have access to. Overdue dates are shown in red. +

+ +
+
+
{rows?.length ?? "—"}
+
Plans on file
+
+
+
0 ? "text-destructive" : ""}`}> + {overdueCount} +
+
+ {overdueCount > 0 && } Overdue +
+
+
+
0 ? "text-amber-600" : ""}`}> + {dueSoonCount} +
+
Due within 30 days
+
+
+ +
+ setQ(e.target.value)} + /> + + +
+ +
+ {isLoading ? ( +
+ Loading plans… +
+ ) : filtered.length === 0 ? ( +
+ {rows?.length === 0 + ? "No 504 or IEP plans on file yet." + : "No plans match this filter."} +
+ ) : ( + + + + + + + + + + + + + + {filtered.map((r) => ( + + + + + + + + + + ))} + +
StudentGradeTypeStatusCase managerNext annual reviewNext re-evaluation
+ + {r.students?.last_name}, {r.students?.first_name} + + {r.students?.grade_level || "—"} + + {PLAN_TYPE_LABEL[r.plan_type]} + + {PLAN_STATUS_LABEL[r.status]}{managerName(r.case_manager_id)} + {formatDate(r.next_annual_review_date)} + + {formatDate(r.next_reevaluation_date)} +
+ )} +
+
+ ); +}