This commit is contained in:
2026-08-22 23:08:09 +00:00
parent f09d86ea12
commit ee192c0b0f
-411
View File
@@ -1,411 +0,0 @@
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 (
<div className="space-y-2 mb-4">
{alerts.map((a) => (
<div key={a.id} className={`border-l-4 rounded-r-md p-3 ${severityCls(a.severity)}`}>
<div className="flex items-start gap-2">
<ShieldAlert className="h-5 w-5 shrink-0 mt-0.5 text-rose-600" />
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm">{a.title}</div>
<p className="text-sm mt-0.5 whitespace-pre-wrap">{a.description}</p>
<p className="text-xs text-muted-foreground mt-1">
{ALERT_TYPES.find((t) => t.value === a.alert_type)?.label ?? a.alert_type}
{a.expiration_date && ` · until ${a.expiration_date}`}
</p>
</div>
{a.requires_acknowledgment &&
(ackedIds.has(a.id) ? (
<span className="text-xs text-muted-foreground flex items-center gap-1 shrink-0">
<Check className="h-3.5 w-3.5" /> Acknowledged
</span>
) : (
<Button
size="sm"
className="shrink-0"
onClick={() => acknowledge.mutate(a.id)}
disabled={acknowledge.isPending}
>
Acknowledge
</Button>
))}
</div>
</div>
))}
</div>
);
}
/** 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 (
<div className="space-y-4 mt-4">
{isAdmin && (
<div className="bg-card border rounded-lg p-4">
{!adding ? (
<Button onClick={() => setAdding(true)}>
<Plus className="h-4 w-4 mr-1" /> New alert
</Button>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-xs">Type</Label>
<Select
value={form.alert_type}
onValueChange={(v) => setForm({ ...form, alert_type: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALERT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Severity</Label>
<Select
value={form.severity}
onValueChange={(v) => setForm({ ...form, severity: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="info">Information</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs">Title</Label>
<Input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="e.g. Court-ordered pickup restriction"
/>
</div>
<div>
<Label className="text-xs">Description</Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="What staff need to know and do."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label className="text-xs">Effective from</Label>
<Input
type="date"
value={form.effective_date}
onChange={(e) => setForm({ ...form, effective_date: e.target.value })}
/>
</div>
<div>
<Label className="text-xs">Expires (optional)</Label>
<Input
type="date"
value={form.expiration_date}
onChange={(e) => setForm({ ...form, expiration_date: e.target.value })}
/>
</div>
</div>
<div>
<Label className="text-xs">Visible to</Label>
<div className="flex flex-wrap gap-x-4 gap-y-1.5 mt-1">
{STAFF_ROLES.map((r) => (
<label key={r} className="flex items-center gap-1.5 text-sm">
<Checkbox
checked={form.roles.includes(r)}
onCheckedChange={(v) => toggleRole(r, !!v)}
/>
{r.replace("_", " ")}
</label>
))}
</div>
<p className="text-xs text-muted-foreground mt-1">
Narrow this for custody or court-order alerts that should not reach every teacher.
</p>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.requires_acknowledgment}
onCheckedChange={(v) => setForm({ ...form, requires_acknowledgment: !!v })}
/>
Require staff to acknowledge
</label>
<div className="flex gap-2">
<Button onClick={() => create.mutate()} disabled={create.isPending}>
Create alert
</Button>
<Button variant="ghost" onClick={() => setAdding(false)}>
Cancel
</Button>
</div>
</div>
)}
</div>
)}
<div className="space-y-2">
{(all ?? []).map((a) => {
const mine = (acks ?? []).filter((k) => k.alert_id === a.id);
return (
<div key={a.id} className={`border rounded-lg p-3 ${a.is_active ? "" : "opacity-60"}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium text-sm flex items-center gap-2">
{a.severity === "critical" && (
<AlertTriangle className="h-4 w-4 text-rose-600" />
)}
{a.title}
{!a.is_active && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted">inactive</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 whitespace-pre-wrap">
{a.description}
</p>
<p className="text-xs text-muted-foreground mt-1">
{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"}
</p>
</div>
{isAdmin && a.is_active && (
<Button size="sm" variant="outline" onClick={() => deactivate.mutate(a.id)}>
Deactivate
</Button>
)}
</div>
</div>
);
})}
{(all ?? []).length === 0 && (
<p className="text-sm text-muted-foreground">No alerts recorded for this student.</p>
)}
</div>
</div>
);
}