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:
File diff suppressed because it is too large
Load Diff
@@ -620,6 +620,358 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
plan_accommodations: {
|
||||||
|
Row: {
|
||||||
|
category: string
|
||||||
|
created_at: string
|
||||||
|
description: string
|
||||||
|
id: string
|
||||||
|
plan_id: string
|
||||||
|
setting: string | null
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
category?: string
|
||||||
|
created_at?: string
|
||||||
|
description: string
|
||||||
|
id?: string
|
||||||
|
plan_id: string
|
||||||
|
setting?: string | null
|
||||||
|
sort_order?: number
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
category?: string
|
||||||
|
created_at?: string
|
||||||
|
description?: string
|
||||||
|
id?: string
|
||||||
|
plan_id?: string
|
||||||
|
setting?: string | null
|
||||||
|
sort_order?: number
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "plan_accommodations_plan_id_fkey"
|
||||||
|
columns: ["plan_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "student_plans"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
plan_details: {
|
||||||
|
Row: {
|
||||||
|
confidential_notes: string | null
|
||||||
|
consent_date: string | null
|
||||||
|
eligibility_category: string | null
|
||||||
|
eligibility_notes: string | null
|
||||||
|
evaluation_summary: string | null
|
||||||
|
last_evaluation_date: string | null
|
||||||
|
parent_concerns: string | null
|
||||||
|
plan_id: string
|
||||||
|
student_strengths: string | null
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
confidential_notes?: string | null
|
||||||
|
consent_date?: string | null
|
||||||
|
eligibility_category?: string | null
|
||||||
|
eligibility_notes?: string | null
|
||||||
|
evaluation_summary?: string | null
|
||||||
|
last_evaluation_date?: string | null
|
||||||
|
parent_concerns?: string | null
|
||||||
|
plan_id: string
|
||||||
|
student_strengths?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
confidential_notes?: string | null
|
||||||
|
consent_date?: string | null
|
||||||
|
eligibility_category?: string | null
|
||||||
|
eligibility_notes?: string | null
|
||||||
|
evaluation_summary?: string | null
|
||||||
|
last_evaluation_date?: string | null
|
||||||
|
parent_concerns?: string | null
|
||||||
|
plan_id?: string
|
||||||
|
student_strengths?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "plan_details_plan_id_fkey"
|
||||||
|
columns: ["plan_id"]
|
||||||
|
isOneToOne: true
|
||||||
|
referencedRelation: "student_plans"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
plan_documents: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
file_path: string
|
||||||
|
id: string
|
||||||
|
plan_id: string
|
||||||
|
title: string | null
|
||||||
|
uploaded_by: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
file_path: string
|
||||||
|
id?: string
|
||||||
|
plan_id: string
|
||||||
|
title?: string | null
|
||||||
|
uploaded_by?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
file_path?: string
|
||||||
|
id?: string
|
||||||
|
plan_id?: string
|
||||||
|
title?: string | null
|
||||||
|
uploaded_by?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "plan_documents_plan_id_fkey"
|
||||||
|
columns: ["plan_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "student_plans"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
plan_goal_progress: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
date: string
|
||||||
|
goal_id: string
|
||||||
|
id: string
|
||||||
|
narrative: string | null
|
||||||
|
recorded_by: string | null
|
||||||
|
value: number | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
date?: string
|
||||||
|
goal_id: string
|
||||||
|
id?: string
|
||||||
|
narrative?: string | null
|
||||||
|
recorded_by?: string | null
|
||||||
|
value?: number | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
date?: string
|
||||||
|
goal_id?: string
|
||||||
|
id?: string
|
||||||
|
narrative?: string | null
|
||||||
|
recorded_by?: string | null
|
||||||
|
value?: number | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "plan_goal_progress_goal_id_fkey"
|
||||||
|
columns: ["goal_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "plan_goals"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
plan_goals: {
|
||||||
|
Row: {
|
||||||
|
area: string | null
|
||||||
|
baseline: string | null
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
plan_id: string
|
||||||
|
sort_order: number
|
||||||
|
statement: string
|
||||||
|
status: Database["public"]["Enums"]["plan_goal_status"]
|
||||||
|
target_criteria: string | null
|
||||||
|
target_date: string | null
|
||||||
|
target_value: number | null
|
||||||
|
unit: string | null
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
area?: string | null
|
||||||
|
baseline?: string | null
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
plan_id: string
|
||||||
|
sort_order?: number
|
||||||
|
statement: string
|
||||||
|
status?: Database["public"]["Enums"]["plan_goal_status"]
|
||||||
|
target_criteria?: string | null
|
||||||
|
target_date?: string | null
|
||||||
|
target_value?: number | null
|
||||||
|
unit?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
area?: string | null
|
||||||
|
baseline?: string | null
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
plan_id?: string
|
||||||
|
sort_order?: number
|
||||||
|
statement?: string
|
||||||
|
status?: Database["public"]["Enums"]["plan_goal_status"]
|
||||||
|
target_criteria?: string | null
|
||||||
|
target_date?: string | null
|
||||||
|
target_value?: number | null
|
||||||
|
unit?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "plan_goals_plan_id_fkey"
|
||||||
|
columns: ["plan_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "student_plans"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
plan_meeting_attendees: {
|
||||||
|
Row: {
|
||||||
|
attended: boolean
|
||||||
|
created_at: string
|
||||||
|
id: string
|
||||||
|
meeting_id: string
|
||||||
|
name: string
|
||||||
|
team_role: string | null
|
||||||
|
user_id: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
attended?: boolean
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
meeting_id: string
|
||||||
|
name: string
|
||||||
|
team_role?: string | null
|
||||||
|
user_id?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
attended?: boolean
|
||||||
|
created_at?: string
|
||||||
|
id?: string
|
||||||
|
meeting_id?: string
|
||||||
|
name?: string
|
||||||
|
team_role?: string | null
|
||||||
|
user_id?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "plan_meeting_attendees_meeting_id_fkey"
|
||||||
|
columns: ["meeting_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "plan_meetings"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
plan_meetings: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
created_by: string | null
|
||||||
|
id: string
|
||||||
|
location: string | null
|
||||||
|
meeting_date: string
|
||||||
|
meeting_type: Database["public"]["Enums"]["plan_meeting_type"]
|
||||||
|
notes: string | null
|
||||||
|
outcome: string | null
|
||||||
|
plan_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
id?: string
|
||||||
|
location?: string | null
|
||||||
|
meeting_date: string
|
||||||
|
meeting_type?: Database["public"]["Enums"]["plan_meeting_type"]
|
||||||
|
notes?: string | null
|
||||||
|
outcome?: string | null
|
||||||
|
plan_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
id?: string
|
||||||
|
location?: string | null
|
||||||
|
meeting_date?: string
|
||||||
|
meeting_type?: Database["public"]["Enums"]["plan_meeting_type"]
|
||||||
|
notes?: string | null
|
||||||
|
outcome?: string | null
|
||||||
|
plan_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "plan_meetings_plan_id_fkey"
|
||||||
|
columns: ["plan_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "student_plans"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
plan_services: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
delivery: string | null
|
||||||
|
end_date: string | null
|
||||||
|
id: string
|
||||||
|
location: string | null
|
||||||
|
minutes_per_session: number | null
|
||||||
|
period: string
|
||||||
|
plan_id: string
|
||||||
|
provider_id: string | null
|
||||||
|
provider_name: string | null
|
||||||
|
service_type: string
|
||||||
|
sessions_per_period: number | null
|
||||||
|
start_date: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
delivery?: string | null
|
||||||
|
end_date?: string | null
|
||||||
|
id?: string
|
||||||
|
location?: string | null
|
||||||
|
minutes_per_session?: number | null
|
||||||
|
period?: string
|
||||||
|
plan_id: string
|
||||||
|
provider_id?: string | null
|
||||||
|
provider_name?: string | null
|
||||||
|
service_type: string
|
||||||
|
sessions_per_period?: number | null
|
||||||
|
start_date?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
delivery?: string | null
|
||||||
|
end_date?: string | null
|
||||||
|
id?: string
|
||||||
|
location?: string | null
|
||||||
|
minutes_per_session?: number | null
|
||||||
|
period?: string
|
||||||
|
plan_id?: string
|
||||||
|
provider_id?: string | null
|
||||||
|
provider_name?: string | null
|
||||||
|
service_type?: string
|
||||||
|
sessions_per_period?: number | null
|
||||||
|
start_date?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "plan_services_plan_id_fkey"
|
||||||
|
columns: ["plan_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "student_plans"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
profiles: {
|
profiles: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -744,6 +1096,59 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
student_plans: {
|
||||||
|
Row: {
|
||||||
|
case_manager_id: string | null
|
||||||
|
created_at: string
|
||||||
|
created_by: string | null
|
||||||
|
effective_date: string | null
|
||||||
|
end_date: string | null
|
||||||
|
id: string
|
||||||
|
next_annual_review_date: string | null
|
||||||
|
next_reevaluation_date: string | null
|
||||||
|
plan_type: Database["public"]["Enums"]["plan_type"]
|
||||||
|
status: Database["public"]["Enums"]["plan_status"]
|
||||||
|
student_id: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
case_manager_id?: string | null
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
effective_date?: string | null
|
||||||
|
end_date?: string | null
|
||||||
|
id?: string
|
||||||
|
next_annual_review_date?: string | null
|
||||||
|
next_reevaluation_date?: string | null
|
||||||
|
plan_type: Database["public"]["Enums"]["plan_type"]
|
||||||
|
status?: Database["public"]["Enums"]["plan_status"]
|
||||||
|
student_id: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
case_manager_id?: string | null
|
||||||
|
created_at?: string
|
||||||
|
created_by?: string | null
|
||||||
|
effective_date?: string | null
|
||||||
|
end_date?: string | null
|
||||||
|
id?: string
|
||||||
|
next_annual_review_date?: string | null
|
||||||
|
next_reevaluation_date?: string | null
|
||||||
|
plan_type?: Database["public"]["Enums"]["plan_type"]
|
||||||
|
status?: Database["public"]["Enums"]["plan_status"]
|
||||||
|
student_id?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "student_plans_student_id_fkey"
|
||||||
|
columns: ["student_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "students"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
students: {
|
students: {
|
||||||
Row: {
|
Row: {
|
||||||
agreement_signed_by: string | null
|
agreement_signed_by: string | null
|
||||||
@@ -906,6 +1311,13 @@ export type Database = {
|
|||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
}
|
}
|
||||||
Functions: {
|
Functions: {
|
||||||
|
can_edit_goal: { Args: { _goal: string }; Returns: boolean }
|
||||||
|
can_edit_meeting: { Args: { _meeting: string }; Returns: boolean }
|
||||||
|
can_edit_plan: { Args: { _plan: string }; Returns: boolean }
|
||||||
|
can_view_goal: { Args: { _goal: string }; Returns: boolean }
|
||||||
|
can_view_meeting: { Args: { _meeting: string }; Returns: boolean }
|
||||||
|
can_view_plan_full: { Args: { _plan: string }; Returns: boolean }
|
||||||
|
can_view_plan_impl: { Args: { _plan: string }; Returns: boolean }
|
||||||
current_user_has_role: {
|
current_user_has_role: {
|
||||||
Args: { _role: Database["public"]["Enums"]["app_role"] }
|
Args: { _role: Database["public"]["Enums"]["app_role"] }
|
||||||
Returns: boolean
|
Returns: boolean
|
||||||
@@ -928,6 +1340,21 @@ export type Database = {
|
|||||||
attendance_status: "present" | "absent" | "late" | "excused"
|
attendance_status: "present" | "absent" | "late" | "excused"
|
||||||
ledger_category: "tuition" | "late_pickup" | "activity" | "other"
|
ledger_category: "tuition" | "late_pickup" | "activity" | "other"
|
||||||
ledger_kind: "charge" | "payment"
|
ledger_kind: "charge" | "payment"
|
||||||
|
plan_goal_status:
|
||||||
|
| "not_started"
|
||||||
|
| "in_progress"
|
||||||
|
| "met"
|
||||||
|
| "not_met"
|
||||||
|
| "discontinued"
|
||||||
|
plan_meeting_type:
|
||||||
|
| "initial"
|
||||||
|
| "annual_review"
|
||||||
|
| "reevaluation"
|
||||||
|
| "eligibility"
|
||||||
|
| "amendment"
|
||||||
|
| "other"
|
||||||
|
plan_status: "draft" | "active" | "archived"
|
||||||
|
plan_type: "iep" | "section_504"
|
||||||
}
|
}
|
||||||
CompositeTypes: {
|
CompositeTypes: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
@@ -1062,6 +1489,23 @@ export const Constants = {
|
|||||||
attendance_status: ["present", "absent", "late", "excused"],
|
attendance_status: ["present", "absent", "late", "excused"],
|
||||||
ledger_category: ["tuition", "late_pickup", "activity", "other"],
|
ledger_category: ["tuition", "late_pickup", "activity", "other"],
|
||||||
ledger_kind: ["charge", "payment"],
|
ledger_kind: ["charge", "payment"],
|
||||||
|
plan_goal_status: [
|
||||||
|
"not_started",
|
||||||
|
"in_progress",
|
||||||
|
"met",
|
||||||
|
"not_met",
|
||||||
|
"discontinued",
|
||||||
|
],
|
||||||
|
plan_meeting_type: [
|
||||||
|
"initial",
|
||||||
|
"annual_review",
|
||||||
|
"reevaluation",
|
||||||
|
"eligibility",
|
||||||
|
"amendment",
|
||||||
|
"other",
|
||||||
|
],
|
||||||
|
plan_status: ["draft", "active", "archived"],
|
||||||
|
plan_type: ["iep", "section_504"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -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")}`;
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/
|
|||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as IntakeTokenRouteImport } from './routes/intake.$token'
|
import { Route as IntakeTokenRouteImport } from './routes/intake.$token'
|
||||||
import { Route as AuthenticatedStudentsRouteImport } from './routes/_authenticated/students'
|
import { Route as AuthenticatedStudentsRouteImport } from './routes/_authenticated/students'
|
||||||
|
import { Route as AuthenticatedPlansRouteImport } from './routes/_authenticated/plans'
|
||||||
import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticated/messages'
|
import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticated/messages'
|
||||||
import { Route as AuthenticatedLedgerRouteImport } from './routes/_authenticated/ledger'
|
import { Route as AuthenticatedLedgerRouteImport } from './routes/_authenticated/ledger'
|
||||||
import { Route as AuthenticatedFormsRouteImport } from './routes/_authenticated/forms'
|
import { Route as AuthenticatedFormsRouteImport } from './routes/_authenticated/forms'
|
||||||
@@ -52,6 +53,11 @@ const AuthenticatedStudentsRoute = AuthenticatedStudentsRouteImport.update({
|
|||||||
path: '/students',
|
path: '/students',
|
||||||
getParentRoute: () => AuthenticatedRouteRoute,
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedPlansRoute = AuthenticatedPlansRouteImport.update({
|
||||||
|
id: '/plans',
|
||||||
|
path: '/plans',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedMessagesRoute = AuthenticatedMessagesRouteImport.update({
|
const AuthenticatedMessagesRoute = AuthenticatedMessagesRouteImport.update({
|
||||||
id: '/messages',
|
id: '/messages',
|
||||||
path: '/messages',
|
path: '/messages',
|
||||||
@@ -132,6 +138,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/forms': typeof AuthenticatedFormsRoute
|
'/forms': typeof AuthenticatedFormsRoute
|
||||||
'/ledger': typeof AuthenticatedLedgerRoute
|
'/ledger': typeof AuthenticatedLedgerRoute
|
||||||
'/messages': typeof AuthenticatedMessagesRoute
|
'/messages': typeof AuthenticatedMessagesRoute
|
||||||
|
'/plans': typeof AuthenticatedPlansRoute
|
||||||
'/students': typeof AuthenticatedStudentsRouteWithChildren
|
'/students': typeof AuthenticatedStudentsRouteWithChildren
|
||||||
'/intake/$token': typeof IntakeTokenRoute
|
'/intake/$token': typeof IntakeTokenRoute
|
||||||
'/classes/$id': typeof AuthenticatedClassesIdRoute
|
'/classes/$id': typeof AuthenticatedClassesIdRoute
|
||||||
@@ -150,6 +157,7 @@ export interface FileRoutesByTo {
|
|||||||
'/forms': typeof AuthenticatedFormsRoute
|
'/forms': typeof AuthenticatedFormsRoute
|
||||||
'/ledger': typeof AuthenticatedLedgerRoute
|
'/ledger': typeof AuthenticatedLedgerRoute
|
||||||
'/messages': typeof AuthenticatedMessagesRoute
|
'/messages': typeof AuthenticatedMessagesRoute
|
||||||
|
'/plans': typeof AuthenticatedPlansRoute
|
||||||
'/intake/$token': typeof IntakeTokenRoute
|
'/intake/$token': typeof IntakeTokenRoute
|
||||||
'/classes/$id': typeof AuthenticatedClassesIdRoute
|
'/classes/$id': typeof AuthenticatedClassesIdRoute
|
||||||
'/students/$id': typeof AuthenticatedStudentsIdRoute
|
'/students/$id': typeof AuthenticatedStudentsIdRoute
|
||||||
@@ -170,6 +178,7 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated/forms': typeof AuthenticatedFormsRoute
|
'/_authenticated/forms': typeof AuthenticatedFormsRoute
|
||||||
'/_authenticated/ledger': typeof AuthenticatedLedgerRoute
|
'/_authenticated/ledger': typeof AuthenticatedLedgerRoute
|
||||||
'/_authenticated/messages': typeof AuthenticatedMessagesRoute
|
'/_authenticated/messages': typeof AuthenticatedMessagesRoute
|
||||||
|
'/_authenticated/plans': typeof AuthenticatedPlansRoute
|
||||||
'/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren
|
'/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren
|
||||||
'/intake/$token': typeof IntakeTokenRoute
|
'/intake/$token': typeof IntakeTokenRoute
|
||||||
'/_authenticated/classes/$id': typeof AuthenticatedClassesIdRoute
|
'/_authenticated/classes/$id': typeof AuthenticatedClassesIdRoute
|
||||||
@@ -191,6 +200,7 @@ export interface FileRouteTypes {
|
|||||||
| '/forms'
|
| '/forms'
|
||||||
| '/ledger'
|
| '/ledger'
|
||||||
| '/messages'
|
| '/messages'
|
||||||
|
| '/plans'
|
||||||
| '/students'
|
| '/students'
|
||||||
| '/intake/$token'
|
| '/intake/$token'
|
||||||
| '/classes/$id'
|
| '/classes/$id'
|
||||||
@@ -209,6 +219,7 @@ export interface FileRouteTypes {
|
|||||||
| '/forms'
|
| '/forms'
|
||||||
| '/ledger'
|
| '/ledger'
|
||||||
| '/messages'
|
| '/messages'
|
||||||
|
| '/plans'
|
||||||
| '/intake/$token'
|
| '/intake/$token'
|
||||||
| '/classes/$id'
|
| '/classes/$id'
|
||||||
| '/students/$id'
|
| '/students/$id'
|
||||||
@@ -228,6 +239,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated/forms'
|
| '/_authenticated/forms'
|
||||||
| '/_authenticated/ledger'
|
| '/_authenticated/ledger'
|
||||||
| '/_authenticated/messages'
|
| '/_authenticated/messages'
|
||||||
|
| '/_authenticated/plans'
|
||||||
| '/_authenticated/students'
|
| '/_authenticated/students'
|
||||||
| '/intake/$token'
|
| '/intake/$token'
|
||||||
| '/_authenticated/classes/$id'
|
| '/_authenticated/classes/$id'
|
||||||
@@ -281,6 +293,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedStudentsRouteImport
|
preLoaderRoute: typeof AuthenticatedStudentsRouteImport
|
||||||
parentRoute: typeof AuthenticatedRouteRoute
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/plans': {
|
||||||
|
id: '/_authenticated/plans'
|
||||||
|
path: '/plans'
|
||||||
|
fullPath: '/plans'
|
||||||
|
preLoaderRoute: typeof AuthenticatedPlansRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
'/_authenticated/messages': {
|
'/_authenticated/messages': {
|
||||||
id: '/_authenticated/messages'
|
id: '/_authenticated/messages'
|
||||||
path: '/messages'
|
path: '/messages'
|
||||||
@@ -414,6 +433,7 @@ interface AuthenticatedRouteRouteChildren {
|
|||||||
AuthenticatedFormsRoute: typeof AuthenticatedFormsRoute
|
AuthenticatedFormsRoute: typeof AuthenticatedFormsRoute
|
||||||
AuthenticatedLedgerRoute: typeof AuthenticatedLedgerRoute
|
AuthenticatedLedgerRoute: typeof AuthenticatedLedgerRoute
|
||||||
AuthenticatedMessagesRoute: typeof AuthenticatedMessagesRoute
|
AuthenticatedMessagesRoute: typeof AuthenticatedMessagesRoute
|
||||||
|
AuthenticatedPlansRoute: typeof AuthenticatedPlansRoute
|
||||||
AuthenticatedStudentsRoute: typeof AuthenticatedStudentsRouteWithChildren
|
AuthenticatedStudentsRoute: typeof AuthenticatedStudentsRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +446,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
|||||||
AuthenticatedFormsRoute: AuthenticatedFormsRoute,
|
AuthenticatedFormsRoute: AuthenticatedFormsRoute,
|
||||||
AuthenticatedLedgerRoute: AuthenticatedLedgerRoute,
|
AuthenticatedLedgerRoute: AuthenticatedLedgerRoute,
|
||||||
AuthenticatedMessagesRoute: AuthenticatedMessagesRoute,
|
AuthenticatedMessagesRoute: AuthenticatedMessagesRoute,
|
||||||
|
AuthenticatedPlansRoute: AuthenticatedPlansRoute,
|
||||||
AuthenticatedStudentsRoute: AuthenticatedStudentsRouteWithChildren,
|
AuthenticatedStudentsRoute: AuthenticatedStudentsRouteWithChildren,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useAuth, highestRole } from "@/hooks/use-auth";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
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")({
|
export const Route = createFileRoute("/_authenticated/dashboard")({
|
||||||
head: () => ({ meta: [{ title: "Dashboard — School Portal" }] }),
|
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 (
|
return (
|
||||||
<div className="p-8 max-w-6xl">
|
<div className="p-8 max-w-6xl">
|
||||||
<h1 className="text-2xl font-semibold">Welcome back</h1>
|
<h1 className="text-2xl font-semibold">Welcome back</h1>
|
||||||
@@ -50,6 +71,31 @@ function Dashboard() {
|
|||||||
<Stat icon={CalendarDays} label="Upcoming events" value={upcoming?.length ?? "—"} />
|
<Stat icon={CalendarDays} label="Upcoming events" value={upcoming?.length ?? "—"} />
|
||||||
</div>
|
</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">
|
<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>
|
<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 ? (
|
{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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
GraduationCap, LayoutDashboard, Users, ClipboardCheck, Receipt,
|
GraduationCap, LayoutDashboard, Users, ClipboardCheck, Receipt,
|
||||||
MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2, BookOpen
|
MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2, BookOpen, ClipboardList
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ function ProtectedLayout() {
|
|||||||
{ to: "/students", label: "Students", icon: Users, show: true },
|
{ to: "/students", label: "Students", icon: Users, show: true },
|
||||||
{ to: "/classes", label: "Classes", icon: BookOpen, show: isAdmin || roles.includes("teacher") },
|
{ to: "/classes", label: "Classes", icon: BookOpen, show: isAdmin || roles.includes("teacher") },
|
||||||
{ to: "/attendance", label: "Attendance", icon: ClipboardCheck, 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: "/ledger", label: "Tuition", icon: Receipt, show: true },
|
||||||
{ to: "/messages", label: "Messages", icon: MessageSquare, show: true },
|
{ to: "/messages", label: "Messages", icon: MessageSquare, show: true },
|
||||||
{ to: "/forms", label: "Forms", icon: FileText, 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 { createIntakeToken } from "@/lib/intake.functions";
|
||||||
import { genTempPassword } from "@/lib/temp-password";
|
import { genTempPassword } from "@/lib/temp-password";
|
||||||
import { StudentGradeReport } from "@/components/gradebook";
|
import { StudentGradeReport } from "@/components/gradebook";
|
||||||
|
import { StudentPlansTab } from "@/components/plans";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/students/$id")({
|
export const Route = createFileRoute("/_authenticated/students/$id")({
|
||||||
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
|
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
|
||||||
@@ -90,6 +91,7 @@ function StudentDetail() {
|
|||||||
<TabsTrigger value="profile">Profile</TabsTrigger>
|
<TabsTrigger value="profile">Profile</TabsTrigger>
|
||||||
<TabsTrigger value="family">Family & pickup</TabsTrigger>
|
<TabsTrigger value="family">Family & pickup</TabsTrigger>
|
||||||
<TabsTrigger value="academics">Academics</TabsTrigger>
|
<TabsTrigger value="academics">Academics</TabsTrigger>
|
||||||
|
<TabsTrigger value="plans">504 / IEP</TabsTrigger>
|
||||||
<TabsTrigger value="grades">Grades</TabsTrigger>
|
<TabsTrigger value="grades">Grades</TabsTrigger>
|
||||||
<TabsTrigger value="attendance">Attendance</TabsTrigger>
|
<TabsTrigger value="attendance">Attendance</TabsTrigger>
|
||||||
<TabsTrigger value="ledger">Tuition</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="profile"><ProfileTab studentId={id} canEdit={canEdit} isAdmin={isAdmin} /></TabsContent>
|
||||||
<TabsContent value="family"><FamilyTab 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="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="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="attendance"><AttendanceTab studentId={id} /></TabsContent>
|
||||||
<TabsContent value="ledger"><LedgerTab studentId={id} canEdit={isAdmin} /></TabsContent>
|
<TabsContent value="ledger"><LedgerTab studentId={id} canEdit={isAdmin} /></TabsContent>
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
-- 504 / IEP plans.
|
||||||
|
--
|
||||||
|
-- Confidentiality is tiered, because special-education records are the most
|
||||||
|
-- sensitive data in the portal and FERPA access is need-to-know:
|
||||||
|
--
|
||||||
|
-- FULL tier — admin, the plan's case manager, and the student's parents.
|
||||||
|
-- Sees everything: eligibility/disability category, evaluations,
|
||||||
|
-- goals and progress data, meeting notes, signed documents.
|
||||||
|
--
|
||||||
|
-- IMPL tier — the above, PLUS any teacher of the student's class.
|
||||||
|
-- Sees only what they must implement: that a plan exists, its
|
||||||
|
-- type/status/dates, the accommodations, and the service
|
||||||
|
-- schedule. Never the disability category or meeting notes.
|
||||||
|
--
|
||||||
|
-- The split is physical, not cosmetic: RLS is row-level, and every role here is
|
||||||
|
-- the same Postgres role (`authenticated`), so column grants cannot separate
|
||||||
|
-- these tiers. Confidential fields therefore live in their own tables
|
||||||
|
-- (plan_details, plan_goals, plan_meetings, plan_documents) rather than as
|
||||||
|
-- columns on student_plans.
|
||||||
|
|
||||||
|
-- ── Enums ───────────────────────────────────────────────────────────────────
|
||||||
|
DO $$ BEGIN CREATE TYPE public.plan_type AS ENUM ('iep', 'section_504');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN CREATE TYPE public.plan_status AS ENUM ('draft', 'active', 'archived');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN CREATE TYPE public.plan_goal_status AS ENUM ('not_started', 'in_progress', 'met', 'not_met', 'discontinued');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN CREATE TYPE public.plan_meeting_type AS ENUM ('initial', 'annual_review', 'reevaluation', 'eligibility', 'amendment', 'other');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
-- ── Core plan (IMPL tier) ───────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.student_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
student_id UUID NOT NULL REFERENCES public.students(id) ON DELETE CASCADE,
|
||||||
|
plan_type plan_type NOT NULL,
|
||||||
|
status plan_status NOT NULL DEFAULT 'draft',
|
||||||
|
case_manager_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
effective_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
-- Compliance clocks. Annual review = yearly; reevaluation = triennial under
|
||||||
|
-- IDEA. Stored explicitly rather than derived, because districts amend them.
|
||||||
|
next_annual_review_date DATE,
|
||||||
|
next_reevaluation_date DATE,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_plans_student ON public.student_plans(student_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_plans_case_manager ON public.student_plans(case_manager_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_plans_review ON public.student_plans(next_annual_review_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_plans_reeval ON public.student_plans(next_reevaluation_date);
|
||||||
|
-- A student may hold an IEP and a 504 historically, but only one active of each.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_student_plans_one_active
|
||||||
|
ON public.student_plans(student_id, plan_type) WHERE status = 'active';
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.student_plans TO authenticated;
|
||||||
|
GRANT ALL ON public.student_plans TO service_role;
|
||||||
|
ALTER TABLE public.student_plans ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── Access predicates ───────────────────────────────────────────────────────
|
||||||
|
-- SECURITY DEFINER so they can read student_plans without re-entering its RLS.
|
||||||
|
CREATE OR REPLACE FUNCTION public.can_view_plan_full(_plan UUID)
|
||||||
|
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM public.student_plans p
|
||||||
|
WHERE p.id = _plan AND (
|
||||||
|
public.current_user_has_role('admin')
|
||||||
|
OR p.case_manager_id = auth.uid()
|
||||||
|
OR public.is_parent_of(p.student_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.can_view_plan_impl(_plan UUID)
|
||||||
|
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM public.student_plans p
|
||||||
|
WHERE p.id = _plan AND (
|
||||||
|
public.current_user_has_role('admin')
|
||||||
|
OR p.case_manager_id = auth.uid()
|
||||||
|
OR public.is_parent_of(p.student_id)
|
||||||
|
OR public.teaches_student(p.student_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Authoring a plan is admin or case manager only. Parents read, never write.
|
||||||
|
CREATE OR REPLACE FUNCTION public.can_edit_plan(_plan UUID)
|
||||||
|
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM public.student_plans p
|
||||||
|
WHERE p.id = _plan AND (
|
||||||
|
public.current_user_has_role('admin') OR p.case_manager_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Predicates for goals and meetings are defined after those tables exist —
|
||||||
|
-- LANGUAGE SQL bodies are parsed at CREATE time, so they cannot forward-reference.
|
||||||
|
|
||||||
|
-- ── Confidential detail (FULL tier), 1:1 with the plan ──────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.plan_details (
|
||||||
|
plan_id UUID PRIMARY KEY REFERENCES public.student_plans(id) ON DELETE CASCADE,
|
||||||
|
eligibility_category TEXT, -- IDEA disability category, or the 504 impairment
|
||||||
|
eligibility_notes TEXT,
|
||||||
|
last_evaluation_date DATE,
|
||||||
|
consent_date DATE,
|
||||||
|
evaluation_summary TEXT,
|
||||||
|
student_strengths TEXT,
|
||||||
|
parent_concerns TEXT,
|
||||||
|
confidential_notes TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_details TO authenticated;
|
||||||
|
GRANT ALL ON public.plan_details TO service_role;
|
||||||
|
ALTER TABLE public.plan_details ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── Accommodations (IMPL tier) ──────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.plan_accommodations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES public.student_plans(id) ON DELETE CASCADE,
|
||||||
|
category TEXT NOT NULL DEFAULT 'instructional', -- instructional | environmental | assessment | behavioral | other
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
setting TEXT,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_accommodations_plan ON public.plan_accommodations(plan_id);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_accommodations TO authenticated;
|
||||||
|
GRANT ALL ON public.plan_accommodations TO service_role;
|
||||||
|
ALTER TABLE public.plan_accommodations ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── Related services (IMPL tier — teachers need the pull-out schedule) ──────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.plan_services (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES public.student_plans(id) ON DELETE CASCADE,
|
||||||
|
service_type TEXT NOT NULL, -- speech, OT, PT, counseling, …
|
||||||
|
provider_name TEXT,
|
||||||
|
provider_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
minutes_per_session INT,
|
||||||
|
sessions_per_period INT,
|
||||||
|
period TEXT NOT NULL DEFAULT 'week', -- week | month
|
||||||
|
location TEXT,
|
||||||
|
delivery TEXT, -- push_in | pull_out
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_services_plan ON public.plan_services(plan_id);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_services TO authenticated;
|
||||||
|
GRANT ALL ON public.plan_services TO service_role;
|
||||||
|
ALTER TABLE public.plan_services ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── Goals + progress monitoring (FULL tier) ─────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.plan_goals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES public.student_plans(id) ON DELETE CASCADE,
|
||||||
|
area TEXT, -- reading, math, behavior, speech, …
|
||||||
|
statement TEXT NOT NULL,
|
||||||
|
baseline TEXT,
|
||||||
|
target_criteria TEXT,
|
||||||
|
target_value NUMERIC, -- optional numeric target for charting
|
||||||
|
unit TEXT, -- %, wpm, trials, …
|
||||||
|
target_date DATE,
|
||||||
|
status plan_goal_status NOT NULL DEFAULT 'not_started',
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_goals_plan ON public.plan_goals(plan_id);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_goals TO authenticated;
|
||||||
|
GRANT ALL ON public.plan_goals TO service_role;
|
||||||
|
ALTER TABLE public.plan_goals ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.plan_goal_progress (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
goal_id UUID NOT NULL REFERENCES public.plan_goals(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
value NUMERIC,
|
||||||
|
narrative TEXT,
|
||||||
|
recorded_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_goal_progress_goal ON public.plan_goal_progress(goal_id, date);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_goal_progress TO authenticated;
|
||||||
|
GRANT ALL ON public.plan_goal_progress TO service_role;
|
||||||
|
ALTER TABLE public.plan_goal_progress ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── Meetings + team (FULL tier) ─────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.plan_meetings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES public.student_plans(id) ON DELETE CASCADE,
|
||||||
|
meeting_type plan_meeting_type NOT NULL DEFAULT 'annual_review',
|
||||||
|
meeting_date DATE NOT NULL,
|
||||||
|
location TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
outcome TEXT,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_meetings_plan ON public.plan_meetings(plan_id, meeting_date);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_meetings TO authenticated;
|
||||||
|
GRANT ALL ON public.plan_meetings TO service_role;
|
||||||
|
ALTER TABLE public.plan_meetings ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.plan_meeting_attendees (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
meeting_id UUID NOT NULL REFERENCES public.plan_meetings(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
team_role TEXT, -- parent, gen-ed teacher, sped teacher, LEA rep, …
|
||||||
|
attended BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_meeting_attendees_meeting ON public.plan_meeting_attendees(meeting_id);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_meeting_attendees TO authenticated;
|
||||||
|
GRANT ALL ON public.plan_meeting_attendees TO service_role;
|
||||||
|
ALTER TABLE public.plan_meeting_attendees ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── Signed documents (FULL tier) ────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS public.plan_documents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES public.student_plans(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
uploaded_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_documents_plan ON public.plan_documents(plan_id);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_documents TO authenticated;
|
||||||
|
GRANT ALL ON public.plan_documents TO service_role;
|
||||||
|
ALTER TABLE public.plan_documents ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ── Goal / meeting predicates (tables now exist) ────────────────────────────
|
||||||
|
CREATE OR REPLACE FUNCTION public.can_edit_goal(_goal UUID)
|
||||||
|
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
SELECT EXISTS (SELECT 1 FROM public.plan_goals g WHERE g.id = _goal AND public.can_edit_plan(g.plan_id))
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.can_view_goal(_goal UUID)
|
||||||
|
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
SELECT EXISTS (SELECT 1 FROM public.plan_goals g WHERE g.id = _goal AND public.can_view_plan_full(g.plan_id))
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.can_edit_meeting(_meeting UUID)
|
||||||
|
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
SELECT EXISTS (SELECT 1 FROM public.plan_meetings m WHERE m.id = _meeting AND public.can_edit_plan(m.plan_id))
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.can_view_meeting(_meeting UUID)
|
||||||
|
RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$
|
||||||
|
SELECT EXISTS (SELECT 1 FROM public.plan_meetings m WHERE m.id = _meeting AND public.can_view_plan_full(m.plan_id))
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ── Policies ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- student_plans: IMPL tier reads. Admin manages; case manager updates own.
|
||||||
|
CREATE POLICY "plans read" ON public.student_plans FOR SELECT TO authenticated
|
||||||
|
USING (
|
||||||
|
public.current_user_has_role('admin')
|
||||||
|
OR case_manager_id = auth.uid()
|
||||||
|
OR public.is_parent_of(student_id)
|
||||||
|
OR public.teaches_student(student_id)
|
||||||
|
);
|
||||||
|
CREATE POLICY "plans admin manage" ON public.student_plans FOR ALL TO authenticated
|
||||||
|
USING (public.current_user_has_role('admin')) WITH CHECK (public.current_user_has_role('admin'));
|
||||||
|
CREATE POLICY "plans case manager update" ON public.student_plans FOR UPDATE TO authenticated
|
||||||
|
USING (case_manager_id = auth.uid()) WITH CHECK (case_manager_id = auth.uid());
|
||||||
|
|
||||||
|
-- plan_details: FULL tier only.
|
||||||
|
CREATE POLICY "plan_details read" ON public.plan_details FOR SELECT TO authenticated
|
||||||
|
USING (public.can_view_plan_full(plan_id));
|
||||||
|
CREATE POLICY "plan_details write" ON public.plan_details FOR ALL TO authenticated
|
||||||
|
USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id));
|
||||||
|
|
||||||
|
-- plan_accommodations: IMPL tier read, author write.
|
||||||
|
CREATE POLICY "plan_accommodations read" ON public.plan_accommodations FOR SELECT TO authenticated
|
||||||
|
USING (public.can_view_plan_impl(plan_id));
|
||||||
|
CREATE POLICY "plan_accommodations write" ON public.plan_accommodations FOR ALL TO authenticated
|
||||||
|
USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id));
|
||||||
|
|
||||||
|
-- plan_services: IMPL tier read, author write.
|
||||||
|
CREATE POLICY "plan_services read" ON public.plan_services FOR SELECT TO authenticated
|
||||||
|
USING (public.can_view_plan_impl(plan_id));
|
||||||
|
CREATE POLICY "plan_services write" ON public.plan_services FOR ALL TO authenticated
|
||||||
|
USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id));
|
||||||
|
|
||||||
|
-- plan_goals + progress: FULL tier only.
|
||||||
|
CREATE POLICY "plan_goals read" ON public.plan_goals FOR SELECT TO authenticated
|
||||||
|
USING (public.can_view_plan_full(plan_id));
|
||||||
|
CREATE POLICY "plan_goals write" ON public.plan_goals FOR ALL TO authenticated
|
||||||
|
USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id));
|
||||||
|
|
||||||
|
CREATE POLICY "plan_goal_progress read" ON public.plan_goal_progress FOR SELECT TO authenticated
|
||||||
|
USING (public.can_view_goal(goal_id));
|
||||||
|
CREATE POLICY "plan_goal_progress write" ON public.plan_goal_progress FOR ALL TO authenticated
|
||||||
|
USING (public.can_edit_goal(goal_id)) WITH CHECK (public.can_edit_goal(goal_id));
|
||||||
|
|
||||||
|
-- plan_meetings + attendees: FULL tier only.
|
||||||
|
CREATE POLICY "plan_meetings read" ON public.plan_meetings FOR SELECT TO authenticated
|
||||||
|
USING (public.can_view_plan_full(plan_id));
|
||||||
|
CREATE POLICY "plan_meetings write" ON public.plan_meetings FOR ALL TO authenticated
|
||||||
|
USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id));
|
||||||
|
|
||||||
|
CREATE POLICY "plan_meeting_attendees read" ON public.plan_meeting_attendees FOR SELECT TO authenticated
|
||||||
|
USING (public.can_view_meeting(meeting_id));
|
||||||
|
CREATE POLICY "plan_meeting_attendees write" ON public.plan_meeting_attendees FOR ALL TO authenticated
|
||||||
|
USING (public.can_edit_meeting(meeting_id)) WITH CHECK (public.can_edit_meeting(meeting_id));
|
||||||
|
|
||||||
|
-- plan_documents: FULL tier only.
|
||||||
|
CREATE POLICY "plan_documents read" ON public.plan_documents FOR SELECT TO authenticated
|
||||||
|
USING (public.can_view_plan_full(plan_id));
|
||||||
|
CREATE POLICY "plan_documents write" ON public.plan_documents FOR ALL TO authenticated
|
||||||
|
USING (public.can_edit_plan(plan_id)) WITH CHECK (public.can_edit_plan(plan_id));
|
||||||
|
|
||||||
|
-- ── Storage: signed plan documents, FULL tier ───────────────────────────────
|
||||||
|
-- Path convention "<plan_id>/<filename>", so folder[1] is the plan id.
|
||||||
|
INSERT INTO storage.buckets (id, name, public)
|
||||||
|
VALUES ('plan-documents', 'plan-documents', false)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE POLICY "plan docs read" ON storage.objects FOR SELECT TO authenticated
|
||||||
|
USING (bucket_id = 'plan-documents' AND public.can_view_plan_full(((storage.foldername(name))[1])::uuid));
|
||||||
|
CREATE POLICY "plan docs write" ON storage.objects FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (bucket_id = 'plan-documents' AND public.can_edit_plan(((storage.foldername(name))[1])::uuid));
|
||||||
|
CREATE POLICY "plan docs update" ON storage.objects FOR UPDATE TO authenticated
|
||||||
|
USING (bucket_id = 'plan-documents' AND public.can_edit_plan(((storage.foldername(name))[1])::uuid));
|
||||||
|
CREATE POLICY "plan docs delete" ON storage.objects FOR DELETE TO authenticated
|
||||||
|
USING (bucket_id = 'plan-documents' AND public.can_edit_plan(((storage.foldername(name))[1])::uuid));
|
||||||
|
|
||||||
|
-- ── updated_at triggers ─────────────────────────────────────────────────────
|
||||||
|
DROP TRIGGER IF EXISTS trg_student_plans_upd ON public.student_plans;
|
||||||
|
CREATE TRIGGER trg_student_plans_upd BEFORE UPDATE ON public.student_plans
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_plan_details_upd ON public.plan_details;
|
||||||
|
CREATE TRIGGER trg_plan_details_upd BEFORE UPDATE ON public.plan_details
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_plan_goals_upd ON public.plan_goals;
|
||||||
|
CREATE TRIGGER trg_plan_goals_upd BEFORE UPDATE ON public.plan_goals
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||||
|
|
||||||
|
-- ── Lock the predicates to signed-in users ──────────────────────────────────
|
||||||
|
-- Postgres grants EXECUTE to PUBLIC by default, which would expose these
|
||||||
|
-- SECURITY DEFINER helpers as callable REST RPC endpoints to the anon role.
|
||||||
|
-- They exist only to back RLS policies.
|
||||||
|
DO $$
|
||||||
|
DECLARE fn TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOREACH fn IN ARRAY ARRAY[
|
||||||
|
'public.can_view_plan_full(uuid)',
|
||||||
|
'public.can_view_plan_impl(uuid)',
|
||||||
|
'public.can_edit_plan(uuid)',
|
||||||
|
'public.can_view_goal(uuid)',
|
||||||
|
'public.can_edit_goal(uuid)',
|
||||||
|
'public.can_view_meeting(uuid)',
|
||||||
|
'public.can_edit_meeting(uuid)'
|
||||||
|
] LOOP
|
||||||
|
EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC, anon', fn);
|
||||||
|
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO authenticated, service_role', fn);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
Reference in New Issue
Block a user