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 ( +
+ {alerts.map((a) => ( +
+
+ +
+
{a.title}
+

{a.description}

+

+ {ALERT_TYPES.find((t) => t.value === a.alert_type)?.label ?? a.alert_type} + {a.expiration_date && ` · until ${a.expiration_date}`} +

+
+ {a.requires_acknowledgment && + (ackedIds.has(a.id) ? ( + + Acknowledged + + ) : ( + + ))} +
+
+ ))} +
+ ); +} + +/** Full management surface: history, who acknowledged, and creating new alerts. */ +export function StudentAlertsTab({ studentId, isAdmin }: { studentId: string; isAdmin: boolean }) { + const qc = useQueryClient(); + const { user } = useAuth(); + const [adding, setAdding] = useState(false); + + const { data: all } = useQuery({ + queryKey: ["student-alerts-all", studentId], + queryFn: async () => + (( + await supabase + .from("student_alerts") + .select("*") + .eq("student_id", studentId) + .order("effective_date", { ascending: false }) + ).data ?? []) as AlertRow[], + }); + + const { data: acks } = useQuery({ + queryKey: ["alert-acks", studentId], + queryFn: async () => + ( + await supabase + .from("student_alert_acknowledgments") + .select("alert_id, acknowledged_at, profiles:user_id(full_name, email)") + ).data ?? [], + }); + + const [form, setForm] = useState({ + alert_type: "safety", + severity: "critical", + title: "", + description: "", + effective_date: new Date().toISOString().slice(0, 10), + expiration_date: "", + requires_acknowledgment: true, + roles: STAFF_ROLES as AppRole[], + }); + + const create = useMutation({ + mutationFn: async () => { + if (!form.title.trim() || !form.description.trim()) + throw new Error("Title and description are required"); + const { error } = await supabase.from("student_alerts").insert({ + student_id: studentId, + alert_type: form.alert_type, + severity: form.severity, + title: form.title.trim(), + description: form.description.trim(), + effective_date: form.effective_date, + expiration_date: form.expiration_date || null, + requires_acknowledgment: form.requires_acknowledgment, + visible_to_roles: form.roles, + created_by: user?.id ?? null, + }); + if (error) throw error; + }, + onSuccess: () => { + setAdding(false); + setForm({ ...form, title: "", description: "" }); + qc.invalidateQueries({ queryKey: ["student-alerts-all", studentId] }); + qc.invalidateQueries({ queryKey: ["student-alerts-active", studentId] }); + toast.success("Alert created"); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const deactivate = useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabase + .from("student_alerts") + .update({ is_active: false }) + .eq("id", id); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["student-alerts-all", studentId] }); + qc.invalidateQueries({ queryKey: ["student-alerts-active", studentId] }); + toast.success("Alert deactivated"); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const toggleRole = (r: AppRole, on: boolean) => + setForm((f) => ({ + ...f, + roles: on ? [...f.roles, r] : f.roles.filter((x) => x !== r), + })); + + return ( +
+ {isAdmin && ( +
+ {!adding ? ( + + ) : ( +
+
+
+ + +
+
+ + +
+
+ +
+ + setForm({ ...form, title: e.target.value })} + placeholder="e.g. Court-ordered pickup restriction" + /> +
+
+ +