Modified by www.SourceFiles.app
This commit is contained in:
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user