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] });
+ }}
+ />
+ ) : (
+ setAdding(true)}>
+ Add 504 / IEP plan
+
+ ))}
+ {(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
+
+
+ Type
+ setF({ ...f, plan_type: v as PlanType })}
+ >
+
+
+
+
+ {PLAN_TYPE_LABEL.iep}
+ {PLAN_TYPE_LABEL.section_504}
+
+
+
+
+ Status
+ setF({ ...f, status: v as PlanStatus })}>
+
+
+
+
+ {(Object.keys(PLAN_STATUS_LABEL) as PlanStatus[]).map((s) => (
+
+ {PLAN_STATUS_LABEL[s]}
+
+ ))}
+
+
+
+
+ Case manager
+ setF({ ...f, case_manager_id: v })}
+ >
+
+
+
+
+ {(staff ?? []).map((s) => (
+
+ {s.full_name || s.email}
+
+ ))}
+
+
+
+
+
+
+ Review dates default to +1 year and +3 years from the effective date. Adjust to match the
+ district's actual timeline.
+
+
+
+ {saving ? "Saving…" : "Create plan"}
+
+
+ Cancel
+
+
+
+ );
+}
+
+// ── 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 ? (
+
{
+ setEditing(false);
+ invalidate();
+ }}
+ >
+ Done
+
+ ) : (
+
setEditing(true)}>
+ Edit
+
+ ))}
+ {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 (
+
+
+
+ Status
+ setF({ ...f, status: v as PlanStatus })}>
+
+
+
+
+ {(Object.keys(PLAN_STATUS_LABEL) as PlanStatus[]).map((s) => (
+
+ {PLAN_STATUS_LABEL[s]}
+
+ ))}
+
+
+
+
+ Case manager
+ setF({ ...f, case_manager_id: v })}
+ >
+
+
+
+
+ {staff.map((s) => (
+
+ {s.full_name || s.email}
+
+ ))}
+
+
+
+
+ Effective date
+ setF({ ...f, effective_date: e.target.value })}
+ />
+
+
+ End date
+ setF({ ...f, end_date: e.target.value })}
+ />
+
+
+ Next annual review
+ setF({ ...f, next_annual_review_date: e.target.value })}
+ />
+
+
+ Next re-evaluation
+ setF({ ...f, next_reevaluation_date: e.target.value })}
+ />
+
+
+
+ {saving ? "Saving…" : "Save plan details"}
+
+
+ );
+}
+
+// ── 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, category: v })}
+ >
+
+
+
+
+ {ACCOMMODATION_CATEGORIES.map((c) => (
+
+ {c.label}
+
+ ))}
+
+
+ setDraft({ ...draft, setting: e.target.value })}
+ />
+
+
+ )}
+ {(data ?? []).length === 0 && !adding && No accommodations recorded. }
+
+ {(data ?? []).map((a) => (
+
+
+
{a.description}
+
+ {accommodationLabel(a.category)}
+ {a.setting ? ` · ${a.setting}` : ""}
+
+
+ {canEdit && (
+
remove(a.id)}>
+
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+// ── Related services (visible to implementing teachers) ─────────────────────
+function ServicesSection({ planId, canEdit }: { planId: string; canEdit: boolean }) {
+ const qc = useQueryClient();
+ const key = ["plan-services", planId];
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: async () =>
+ (await supabase.from("plan_services").select("*").eq("plan_id", planId).order("created_at"))
+ .data ?? [],
+ });
+ const [adding, setAdding] = useState(false);
+ const [d, setD] = useState({
+ service_type: "",
+ provider_name: "",
+ minutes_per_session: "",
+ sessions_per_period: "",
+ period: "week",
+ location: "",
+ delivery: "pull_out",
+ });
+
+ const add = async () => {
+ if (!d.service_type.trim()) return toast.error("Name the service");
+ const { error } = await supabase.from("plan_services").insert({
+ plan_id: planId,
+ service_type: d.service_type.trim(),
+ provider_name: d.provider_name || null,
+ minutes_per_session: d.minutes_per_session ? Number(d.minutes_per_session) : null,
+ sessions_per_period: d.sessions_per_period ? Number(d.sessions_per_period) : null,
+ period: d.period,
+ location: d.location || null,
+ delivery: d.delivery,
+ });
+ if (error) return err(error);
+ setD({
+ service_type: "",
+ provider_name: "",
+ minutes_per_session: "",
+ sessions_per_period: "",
+ period: "week",
+ location: "",
+ delivery: "pull_out",
+ });
+ setAdding(false);
+ qc.invalidateQueries({ queryKey: key });
+ };
+ const remove = async (id: string) => {
+ const { error } = await supabase.from("plan_services").delete().eq("id", id);
+ if (error) return err(error);
+ qc.invalidateQueries({ queryKey: key });
+ };
+
+ const summary = (s: {
+ minutes_per_session: number | null;
+ sessions_per_period: number | null;
+ period: string;
+ }) => {
+ if (!s.minutes_per_session && !s.sessions_per_period) return null;
+ const mins = s.minutes_per_session ? `${s.minutes_per_session} min` : "";
+ const freq = s.sessions_per_period ? `${s.sessions_per_period}× / ${s.period}` : "";
+ return [mins, freq].filter(Boolean).join(" · ");
+ };
+
+ return (
+ setAdding(true)}>
+ Add
+
+ ) : undefined
+ }
+ >
+ {adding && (
+
+
+
+
+ Add
+
+ setAdding(false)}>
+ Cancel
+
+
+
+ )}
+ {(data ?? []).length === 0 && !adding && No related services recorded. }
+
+ {(data ?? []).map((s) => (
+
+
+
+ {s.service_type}
+ {s.provider_name ? ` — ${s.provider_name}` : ""}
+
+
+ {[summary(s), s.location, s.delivery === "push_in" ? "Push-in" : "Pull-out"]
+ .filter(Boolean)
+ .join(" · ")}
+
+
+ {canEdit && (
+
remove(s.id)}>
+
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+// ── Eligibility / confidential detail ───────────────────────────────────────
+function DetailsSection({ planId, canEdit }: { planId: string; canEdit: boolean }) {
+ const qc = useQueryClient();
+ const key = ["plan-details", planId];
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: async () =>
+ (await supabase.from("plan_details").select("*").eq("plan_id", planId).maybeSingle()).data,
+ });
+ const [editing, setEditing] = useState(false);
+ const [f, setF] = useState>({});
+ const [saving, setSaving] = useState(false);
+
+ const start = () => {
+ setF({
+ eligibility_category: data?.eligibility_category ?? "",
+ eligibility_notes: data?.eligibility_notes ?? "",
+ last_evaluation_date: data?.last_evaluation_date ?? "",
+ consent_date: data?.consent_date ?? "",
+ evaluation_summary: data?.evaluation_summary ?? "",
+ student_strengths: data?.student_strengths ?? "",
+ parent_concerns: data?.parent_concerns ?? "",
+ confidential_notes: data?.confidential_notes ?? "",
+ });
+ setEditing(true);
+ };
+
+ const save = async () => {
+ setSaving(true);
+ try {
+ const { error } = await supabase.from("plan_details").upsert(
+ {
+ plan_id: planId,
+ eligibility_category: f.eligibility_category || null,
+ eligibility_notes: f.eligibility_notes || null,
+ last_evaluation_date: f.last_evaluation_date || null,
+ consent_date: f.consent_date || null,
+ evaluation_summary: f.evaluation_summary || null,
+ student_strengths: f.student_strengths || null,
+ parent_concerns: f.parent_concerns || null,
+ confidential_notes: f.confidential_notes || null,
+ },
+ { onConflict: "plan_id" },
+ );
+ if (error) throw error;
+ toast.success("Details saved");
+ setEditing(false);
+ qc.invalidateQueries({ queryKey: key });
+ } catch (e) {
+ err(e);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const Row = ({ label, value }: { label: string; value?: string | null }) => (
+
+ {label}
+ {value || "—"}
+
+ );
+
+ return (
+ setEditing(false)}>
+ Cancel
+
+ ) : (
+
+ Edit
+
+ )
+ ) : undefined
+ }
+ >
+ {editing ? (
+
+
+
+ Eligibility notes
+
+
+ Evaluation summary
+
+
+ Student strengths
+
+
+ Parent concerns
+
+
+ Confidential notes (staff only)
+
+
+ {saving ? "Saving…" : "Save"}
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+// ── Goals + progress monitoring ─────────────────────────────────────────────
+function GoalsSection({ planId, canEdit }: { planId: string; canEdit: boolean }) {
+ const qc = useQueryClient();
+ const key = ["plan-goals", planId];
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: async () =>
+ (await supabase.from("plan_goals").select("*").eq("plan_id", planId).order("sort_order"))
+ .data ?? [],
+ });
+ const [adding, setAdding] = useState(false);
+ const [g, setG] = useState({
+ area: "",
+ statement: "",
+ baseline: "",
+ target_criteria: "",
+ target_value: "",
+ unit: "",
+ target_date: "",
+ });
+
+ const add = async () => {
+ if (!g.statement.trim()) return toast.error("Write the goal statement");
+ const { error } = await supabase.from("plan_goals").insert({
+ plan_id: planId,
+ area: g.area || null,
+ statement: g.statement.trim(),
+ baseline: g.baseline || null,
+ target_criteria: g.target_criteria || null,
+ target_value: g.target_value ? Number(g.target_value) : null,
+ unit: g.unit || null,
+ target_date: g.target_date || null,
+ sort_order: data?.length ?? 0,
+ });
+ if (error) return err(error);
+ setG({
+ area: "",
+ statement: "",
+ baseline: "",
+ target_criteria: "",
+ target_value: "",
+ unit: "",
+ target_date: "",
+ });
+ setAdding(false);
+ qc.invalidateQueries({ queryKey: key });
+ };
+
+ return (
+ setAdding(true)}>
+ Add goal
+
+ ) : undefined
+ }
+ >
+ {adding && (
+
+ )}
+ {(data ?? []).length === 0 && !adding && No goals recorded. }
+
+ {(data ?? []).map((goal) => (
+ qc.invalidateQueries({ queryKey: key })}
+ />
+ ))}
+
+
+ );
+}
+
+type GoalRecord = {
+ id: string;
+ area: string | null;
+ statement: string;
+ baseline: string | null;
+ target_criteria: string | null;
+ target_value: number | null;
+ unit: string | null;
+ target_date: string | null;
+ status: GoalStatus;
+};
+
+function GoalRow({
+ goal,
+ canEdit,
+ onChanged,
+}: {
+ goal: GoalRecord;
+ canEdit: boolean;
+ onChanged: () => void;
+}) {
+ const qc = useQueryClient();
+ const key = ["plan-goal-progress", goal.id];
+ const [open, setOpen] = useState(false);
+ const { data: progress } = useQuery({
+ queryKey: key,
+ enabled: open,
+ queryFn: async () =>
+ (
+ await supabase
+ .from("plan_goal_progress")
+ .select("*")
+ .eq("goal_id", goal.id)
+ .order("date", { ascending: false })
+ ).data ?? [],
+ });
+ const [p, setP] = useState({ date: todayISO(), value: "", narrative: "" });
+
+ const latest = progress?.[0];
+ const pct =
+ goal.target_value && latest?.value != null
+ ? Math.max(0, Math.min(100, (Number(latest.value) / Number(goal.target_value)) * 100))
+ : null;
+
+ const addProgress = async () => {
+ const { data: u } = await supabase.auth.getUser();
+ const { error } = await supabase.from("plan_goal_progress").insert({
+ goal_id: goal.id,
+ date: p.date || todayISO(),
+ value: p.value ? Number(p.value) : null,
+ narrative: p.narrative || null,
+ recorded_by: u.user?.id,
+ });
+ if (error) return err(error);
+ setP({ date: todayISO(), value: "", narrative: "" });
+ qc.invalidateQueries({ queryKey: key });
+ };
+
+ const setStatus = async (status: GoalStatus) => {
+ const { error } = await supabase.from("plan_goals").update({ status }).eq("id", goal.id);
+ if (error) return err(error);
+ onChanged();
+ };
+
+ const remove = async () => {
+ if (!confirm("Delete this goal and its progress data?")) return;
+ const { error } = await supabase.from("plan_goals").delete().eq("id", goal.id);
+ if (error) return err(error);
+ onChanged();
+ };
+
+ return (
+
+
+
+
{goal.statement}
+
+ {[
+ goal.area,
+ goal.baseline ? `Baseline: ${goal.baseline}` : null,
+ goal.target_value != null ? `Target: ${goal.target_value}${goal.unit ?? ""}` : null,
+ goal.target_date ? `By ${formatDate(goal.target_date)}` : null,
+ ]
+ .filter(Boolean)
+ .join(" · ")}
+
+ {goal.target_criteria && (
+
Mastery: {goal.target_criteria}
+ )}
+
+
+ {canEdit ? (
+ setStatus(v as GoalStatus)}>
+
+
+
+
+ {(Object.keys(GOAL_STATUS_LABEL) as GoalStatus[]).map((s) => (
+
+ {GOAL_STATUS_LABEL[s]}
+
+ ))}
+
+
+ ) : (
+ {GOAL_STATUS_LABEL[goal.status]}
+ )}
+ {canEdit && (
+
+
+
+ )}
+
+
+
+ {pct !== null && (
+
+
+
+ Latest {latest?.value}
+ {goal.unit ?? ""} of {goal.target_value}
+ {goal.unit ?? ""} target ({Math.round(pct)}%)
+
+
+ )}
+
+
setOpen((o) => !o)}
+ >
+ {open ? "Hide" : "Show"} progress {progress ? `(${progress.length})` : ""}
+
+
+ {open && (
+
+ )}
+
+ );
+}
+
+// ── Meetings + team ─────────────────────────────────────────────────────────
+function MeetingsSection({ planId, canEdit }: { planId: string; canEdit: boolean }) {
+ const qc = useQueryClient();
+ const key = ["plan-meetings", planId];
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: async () =>
+ (
+ await supabase
+ .from("plan_meetings")
+ .select("*")
+ .eq("plan_id", planId)
+ .order("meeting_date", { ascending: false })
+ ).data ?? [],
+ });
+ const [adding, setAdding] = useState(false);
+ const [m, setM] = useState({
+ meeting_type: "annual_review" as MeetingType,
+ meeting_date: todayISO(),
+ location: "",
+ notes: "",
+ outcome: "",
+ });
+
+ const add = async () => {
+ const { data: u } = await supabase.auth.getUser();
+ const { error } = await supabase.from("plan_meetings").insert({
+ plan_id: planId,
+ meeting_type: m.meeting_type,
+ meeting_date: m.meeting_date || todayISO(),
+ location: m.location || null,
+ notes: m.notes || null,
+ outcome: m.outcome || null,
+ created_by: u.user?.id,
+ });
+ if (error) return err(error);
+ setM({
+ meeting_type: "annual_review",
+ meeting_date: todayISO(),
+ location: "",
+ notes: "",
+ outcome: "",
+ });
+ setAdding(false);
+ qc.invalidateQueries({ queryKey: key });
+ };
+ const remove = async (id: string) => {
+ const { error } = await supabase.from("plan_meetings").delete().eq("id", id);
+ if (error) return err(error);
+ qc.invalidateQueries({ queryKey: key });
+ };
+
+ return (
+ setAdding(true)}>
+ Add meeting
+
+ ) : undefined
+ }
+ >
+ {adding && (
+
+ )}
+ {(data ?? []).length === 0 && !adding && No meetings recorded. }
+
+ {(data ?? []).map((mt) => (
+
+
+
+
+ {MEETING_TYPE_LABEL[mt.meeting_type]} · {formatDate(mt.meeting_date)}
+
+ {mt.location &&
{mt.location}
}
+ {mt.notes &&
{mt.notes}
}
+ {mt.outcome && (
+
Outcome: {mt.outcome}
+ )}
+
+ {canEdit && (
+
remove(mt.id)}>
+
+
+ )}
+
+
+
+ ))}
+
+
+ );
+}
+
+function Attendees({ meetingId, canEdit }: { meetingId: string; canEdit: boolean }) {
+ const qc = useQueryClient();
+ const key = ["plan-meeting-attendees", meetingId];
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: async () =>
+ (
+ await supabase
+ .from("plan_meeting_attendees")
+ .select("*")
+ .eq("meeting_id", meetingId)
+ .order("created_at")
+ ).data ?? [],
+ });
+ const [a, setA] = useState({ name: "", team_role: "" });
+
+ const add = async () => {
+ if (!a.name.trim()) return;
+ const { error } = await supabase.from("plan_meeting_attendees").insert({
+ meeting_id: meetingId,
+ name: a.name.trim(),
+ team_role: a.team_role || null,
+ });
+ if (error) return err(error);
+ setA({ name: "", team_role: "" });
+ qc.invalidateQueries({ queryKey: key });
+ };
+ const remove = async (id: string) => {
+ const { error } = await supabase.from("plan_meeting_attendees").delete().eq("id", id);
+ if (error) return err(error);
+ qc.invalidateQueries({ queryKey: key });
+ };
+
+ return (
+
+
Team members present
+
+ {(data ?? []).map((at) => (
+
+ {at.name}
+ {at.team_role ? ` · ${at.team_role}` : ""}
+ {canEdit && (
+ remove(at.id)} className="ml-1 opacity-60 hover:opacity-100">
+ ×
+
+ )}
+
+ ))}
+ {(data ?? []).length === 0 && (
+ None recorded.
+ )}
+
+ {canEdit && (
+
+ setA({ ...a, name: e.target.value })}
+ />
+ setA({ ...a, team_role: e.target.value })}
+ />
+
+ Add
+
+
+ )}
+
+ );
+}
+
+// ── Signed documents ────────────────────────────────────────────────────────
+function DocumentsSection({ planId, canEdit }: { planId: string; canEdit: boolean }) {
+ const qc = useQueryClient();
+ const key = ["plan-documents", planId];
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: async () =>
+ (
+ await supabase
+ .from("plan_documents")
+ .select("*")
+ .eq("plan_id", planId)
+ .order("created_at", { ascending: false })
+ ).data ?? [],
+ });
+ const [title, setTitle] = useState("");
+ const [uploading, setUploading] = useState(false);
+
+ const onUpload = async (file: File) => {
+ if (!file) return;
+ setUploading(true);
+ try {
+ // Bucket policies key off the first path segment, so it must be the plan id.
+ const path = `${planId}/${Date.now()}-${file.name}`;
+ const { error: upErr } = await supabase.storage.from("plan-documents").upload(path, file);
+ if (upErr) throw upErr;
+ const { data: u } = await supabase.auth.getUser();
+ const { error } = await supabase.from("plan_documents").insert({
+ plan_id: planId,
+ file_path: path,
+ title: title || file.name,
+ uploaded_by: u.user?.id,
+ });
+ if (error) throw error;
+ setTitle("");
+ qc.invalidateQueries({ queryKey: key });
+ toast.success("Document uploaded");
+ } catch (e) {
+ err(e);
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const download = async (path: string) => {
+ const { data, error } = await supabase.storage.from("plan-documents").createSignedUrl(path, 60);
+ if (error) return err(error);
+ window.open(data.signedUrl, "_blank");
+ };
+
+ const remove = async (id: string, path: string) => {
+ if (!confirm("Delete this document?")) return;
+ await supabase.storage.from("plan-documents").remove([path]);
+ const { error } = await supabase.from("plan_documents").delete().eq("id", id);
+ if (error) return err(error);
+ qc.invalidateQueries({ queryKey: key });
+ };
+
+ return (
+
+ {canEdit && (
+
+ setTitle(e.target.value)}
+ />
+ e.target.files && onUpload(e.target.files[0])}
+ />
+
+ )}
+ {(data ?? []).length === 0 ? (
+ No documents uploaded.
+ ) : (
+
+ {(data ?? []).map((doc) => (
+
+
download(doc.file_path)}
+ className="flex items-center gap-2 text-sm hover:underline text-left"
+ >
+ {doc.title}
+
+
+
+ {new Date(doc.created_at).toLocaleDateString()}
+
+ {canEdit && (
+ remove(doc.id, doc.file_path)}>
+
+
+ )}
+
+
+ ))}
+
+ )}
+
+ );
+}