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
+116
View File
@@ -0,0 +1,116 @@
import type { Database } from "@/integrations/supabase/types";
export type PlanType = Database["public"]["Enums"]["plan_type"];
export type PlanStatus = Database["public"]["Enums"]["plan_status"];
export type GoalStatus = Database["public"]["Enums"]["plan_goal_status"];
export type MeetingType = Database["public"]["Enums"]["plan_meeting_type"];
export const PLAN_TYPE_LABEL: Record<PlanType, string> = {
iep: "IEP",
section_504: "504 Plan",
};
export const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
draft: "Draft",
active: "Active",
archived: "Archived",
};
export const GOAL_STATUS_LABEL: Record<GoalStatus, string> = {
not_started: "Not started",
in_progress: "In progress",
met: "Met",
not_met: "Not met",
discontinued: "Discontinued",
};
export const MEETING_TYPE_LABEL: Record<MeetingType, string> = {
initial: "Initial",
annual_review: "Annual review",
reevaluation: "Re-evaluation",
eligibility: "Eligibility",
amendment: "Amendment",
other: "Other",
};
export const ACCOMMODATION_CATEGORIES = [
{ value: "instructional", label: "Instructional" },
{ value: "environmental", label: "Environmental" },
{ value: "assessment", label: "Assessment / testing" },
{ value: "behavioral", label: "Behavioral" },
{ value: "other", label: "Other" },
];
export const accommodationLabel = (v: string) =>
ACCOMMODATION_CATEGORIES.find((c) => c.value === v)?.label ?? v;
// ── Compliance clocks ───────────────────────────────────────────────────────
// A date within this many days counts as "due soon" rather than merely upcoming.
export const DUE_SOON_DAYS = 30;
export type ComplianceState = "none" | "ok" | "due_soon" | "overdue";
// "YYYY-MM-DD" through `new Date()` parses as UTC midnight, which lands on the
// previous day for anyone west of Greenwich. Build a local date instead.
function parseISODate(d: string): Date {
const [y, m, day] = d.split("-").map(Number);
return new Date(y, m - 1, day);
}
export function daysUntil(date: string | null | undefined, today = new Date()): number | null {
if (!date) return null;
const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
return Math.round((parseISODate(date).getTime() - midnight.getTime()) / 86_400_000);
}
export function complianceState(
date: string | null | undefined,
today = new Date(),
): ComplianceState {
const d = daysUntil(date, today);
if (d === null) return "none";
if (d < 0) return "overdue";
if (d <= DUE_SOON_DAYS) return "due_soon";
return "ok";
}
// EmbraceIEP shows overdue dates in red italics; due-soon gets an amber warning.
export const COMPLIANCE_CLASS: Record<ComplianceState, string> = {
none: "text-muted-foreground",
ok: "text-foreground",
due_soon: "text-amber-600 font-medium",
overdue: "text-destructive font-semibold italic",
};
export function complianceLabel(state: ComplianceState, days: number | null): string {
switch (state) {
case "overdue":
return `${Math.abs(days ?? 0)} day${Math.abs(days ?? 0) === 1 ? "" : "s"} overdue`;
case "due_soon":
return days === 0 ? "Due today" : `Due in ${days} day${days === 1 ? "" : "s"}`;
case "ok":
return "On track";
default:
return "Not set";
}
}
export const formatDate = (d: string | null | undefined) =>
d ? parseISODate(d).toLocaleDateString() : "—";
// Default compliance clocks when a plan takes effect: annual review at +1 year,
// re-evaluation at +3 years (the IDEA triennial). Both are editable afterwards.
export function defaultReviewDates(effective: string): { annual: string; reeval: string } {
const iso = (dt: Date) =>
`${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`;
const base = parseISODate(effective);
const annual = new Date(base);
annual.setFullYear(annual.getFullYear() + 1);
const reeval = new Date(base);
reeval.setFullYear(reeval.getFullYear() + 3);
return { annual: iso(annual), reeval: iso(reeval) };
}
export const todayISO = () => {
const t = new Date();
return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, "0")}-${String(t.getDate()).padStart(2, "0")}`;
};