Modified by www.SourceFiles.app

This commit is contained in:
2026-08-07 04:13:13 +00:00
parent 604bf18d4a
commit 33ddaeb90c
-252
View File
@@ -1,252 +0,0 @@
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>
);
}