1"use client";
2
3
14import { useQuery } from "@tanstack/react-query";
15import { MailCheck, MailX, Inbox } from "lucide-react";
16import { orgAdminApi } from "@/lib/org-admin";
17import { Section } from "@/components/settings/settings-shell";
18
19function fmtTime(iso: string | null): string {
20 if (!iso) return "—";
21 return new Date(iso).toLocaleString();
22}
23
24function Stat({ icon: Icon, label, value, tone }: { icon: typeof Inbox; label: string; value: number | string; tone?: "bad" }) {
25 return (
26 <div className="flex items-center gap-3 rounded-lg border border-border p-3">
27 <Icon className={`size-4 ${tone === "bad" ? "text-destructive" : "text-muted-foreground"}`} aria-hidden />
28 <div>
29 <div className="text-xs text-muted-foreground">{label}</div>
30 <div className={`text-lg font-semibold ${tone === "bad" ? "text-destructive" : ""}`}>{value}</div>
31 </div>
32 </div>
33 );
34}
35
36export function AdminNotificationsTab({ orgId }: { orgId: string }) {
37 const health = useQuery({
38 queryKey: ["orgs", orgId, "admin", "notifications", "health"],
39 queryFn: () => orgAdminApi.notificationsHealth(orgId),
40 retry: false,
41 refetchInterval: 15_000,
42 });
43
44 if (health.isLoading) {
45 return <p className="text-sm text-muted-foreground" role="status">Loading notification health…</p>;
46 }
47 if (health.isError) {
48 return <p className="text-sm text-destructive" role="alert">Could not load the notification health.</p>;
49 }
50 const n = health.data!;
51 const failed = n.failedJobs;
52
53 return (
54 <div className="space-y-6">
55 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
56 <Stat icon={Inbox} label="In-app total" value={n.inAppTotal} />
57 <Stat icon={Inbox} label="In-app unread" value={n.inAppUnread} tone={n.inAppUnread > 0 ? "bad" : undefined} />
58 <Stat icon={MailCheck} label="Email queued (cluster)" value={n.emailQueued} />
59 <Stat icon={MailX} label="Email failed (org)" value={n.emailFailed} tone={n.emailFailed > 0 ? "bad" : undefined} />
60 </div>
61
62 <Section
63 title="Recent failed email jobs"
64 description="Org-scoped: only failed email jobs whose notification belongs to this org are listed (correlated by the job's payload, never by the BullMQ job id)."
65 >
66 {failed.length === 0 ? (
67 <p className="text-sm text-muted-foreground">No failed email jobs for this org.</p>
68 ) : (
69 <ul className="divide-y divide-border rounded-md border border-border">
70 {failed.map((f) => (
71 <li key={f.jobId} className="flex flex-wrap items-start gap-x-3 gap-y-1 p-3">
72 <div className="min-w-0 flex-1">
73 <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground">
74 <span className="font-medium text-foreground">notification {f.notificationId.slice(0, 12)}…</span>
75 <span>{f.attemptsMade} attempts</span>
76 <span>{fmtTime(f.failedAt)}</span>
77 <span className="text-[11px]">job {f.jobId.slice(0, 8)}…</span>
78 </div>
79 {f.failedReason && (
80 <p className="mt-1 break-words rounded bg-destructive/5 px-2 py-1 text-xs text-destructive">{f.failedReason}</p>
81 )}
82 </div>
83 </li>
84 ))}
85 </ul>
86 )}
87 </Section>
88 </div>
89 );
90}
91