diff --git a/src/components/alerts.tsx b/src/components/alerts.tsx new file mode 100644 index 0000000..b2ca1fb --- /dev/null +++ b/src/components/alerts.tsx @@ -0,0 +1,411 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +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 { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AlertTriangle, Check, Plus, ShieldAlert } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { Database } from "@/integrations/supabase/types"; + +type AlertRow = Database["public"]["Tables"]["student_alerts"]["Row"]; +type AppRole = Database["public"]["Enums"]["app_role"]; + +const ALERT_TYPES = [ + { value: "parenting_plan", label: "Parenting plan" }, + { value: "custody", label: "Custody limitation" }, + { value: "unauthorized_pickup", label: "Unauthorized pickup" }, + { value: "medical", label: "Medical emergency" }, + { value: "allergy", label: "Severe allergy" }, + { value: "court_order", label: "Court-ordered restriction" }, + { value: "safety", label: "Safety concern" }, + { value: "dismissal", label: "Special dismissal" }, + { value: "other", label: "Other" }, +]; + +const STAFF_ROLES: AppRole[] = [ + "admin", + "org_admin", + "super_admin", + "campus_admin", + "management", + "teacher", + "staff", +]; + +const severityCls = (s: string) => + s === "critical" + ? "border-rose-600 bg-rose-50 dark:bg-rose-950/40" + : s === "high" + ? "border-amber-500 bg-amber-50 dark:bg-amber-950/40" + : "border-slate-300 bg-muted/40"; + +function useActiveAlerts(studentId: string) { + return useQuery({ + queryKey: ["student-alerts-active", studentId], + queryFn: async () => { + const today = new Date().toISOString().slice(0, 10); + const { data, error } = await supabase + .from("student_alerts") + .select("*") + .eq("student_id", studentId) + .eq("is_active", true) + .lte("effective_date", today) + .or(`expiration_date.is.null,expiration_date.gte.${today}`) + .order("severity"); + if (error) throw error; + return (data ?? []) as AlertRow[]; + }, + }); +} + +/** + * The red banner. Sits above everything on the student profile because the spec + * requires these to be visible to every authorized staff member who interacts + * with the student — not filed behind a tab. + */ +export function StudentAlertsBanner({ studentId }: { studentId: string }) { + const { user } = useAuth(); + const qc = useQueryClient(); + const { data: alerts } = useActiveAlerts(studentId); + const logged = useRef(false); + + const { data: acks } = useQuery({ + queryKey: ["my-alert-acks", studentId, user?.id], + enabled: !!user, + queryFn: async () => + ( + await supabase + .from("student_alert_acknowledgments") + .select("alert_id") + .eq("user_id", user!.id) + ).data ?? [], + }); + const ackedIds = new Set((acks ?? []).map((a) => a.alert_id)); + + // Every display is auditable, per the spec. Logged once per mount rather + // than per render so a re-render does not inflate the record. + useEffect(() => { + if (!user || !alerts?.length || logged.current) return; + logged.current = true; + void supabase + .from("student_alert_views") + .insert(alerts.map((a) => ({ alert_id: a.id, user_id: user.id }))); + }, [alerts, user]); + + const acknowledge = useMutation({ + mutationFn: async (alertId: string) => { + const { error } = await supabase + .from("student_alert_acknowledgments") + .insert({ alert_id: alertId, user_id: user!.id }); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["my-alert-acks", studentId, user?.id] }); + toast.success("Acknowledged"); + }, + onError: (e: Error) => toast.error(e.message), + }); + + if (!alerts?.length) return null; + + return ( +
{a.description}
++ {ALERT_TYPES.find((t) => t.value === a.alert_type)?.label ?? a.alert_type} + {a.expiration_date && ` · until ${a.expiration_date}`} +
++ Narrow this for custody or court-order alerts that should not reach every teacher. +
++ {a.description} +
++ {a.effective_date} + {a.expiration_date && ` → ${a.expiration_date}`} · visible to{" "} + {(a.visible_to_roles ?? []).length} role + {(a.visible_to_roles ?? []).length === 1 ? "" : "s"} · {mine.length}{" "} + acknowledgment{mine.length === 1 ? "" : "s"} +
+No alerts recorded for this student.
+ )} +