Dashboard - Stat cards are now clickable: Accounts → /accounts, Active reminders → /reminders?filter=active, Recent runs → /reminders. - Recent activity rows link to the underlying reminder when it still exists. Runs whose reminder has been deleted render with a "(deleted)" marker and stay non-clickable. - New "Clear history" action wipes all run rows the operator owns plus any orphan rows (reminderId=NULL). Run history persists after reminder delete - reminder_runs.reminder_id is now nullable with ON DELETE SET NULL, so deleting a reminder no longer cascade-erases its history. - New reminder_runs.reminder_name column snapshots the name at fire time so history rows stay readable even after the reminder is gone. - Fire-reminder records the snapshot. - Dashboard query LEFT JOINs and COALESCEs name from the live reminder, the snapshot, or "(deleted reminder)" as last resort. QR - Drop the 25 s server-side throttle. With listener accumulation already fixed (previous commit), the payload-equality dedupe is enough. Symptom: after the first QR expired the throttle blocked the next emit, and the QR never refreshed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import Link from "next/link";
|
|
import {
|
|
WifiIcon,
|
|
BellIcon,
|
|
ActivityIcon,
|
|
CheckCircle2Icon,
|
|
AlertTriangleIcon,
|
|
XCircleIcon,
|
|
MinusCircleIcon,
|
|
Trash2Icon,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
} from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import { clearHistoryAction } from "@/actions/history";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { getSeededOperator } from "@/lib/operator";
|
|
import { getDashboardStats } from "@/lib/queries";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Relative time helper (no external dep, server-safe)
|
|
// ---------------------------------------------------------------------------
|
|
function relativeTime(date: Date | string): string {
|
|
const d = typeof date === "string" ? new Date(date) : date;
|
|
const diffMs = Date.now() - d.getTime();
|
|
const diffSec = Math.floor(diffMs / 1000);
|
|
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
|
|
if (diffSec < 60) return rtf.format(-diffSec, "second");
|
|
if (diffSec < 3600) return rtf.format(-Math.floor(diffSec / 60), "minute");
|
|
if (diffSec < 86400) return rtf.format(-Math.floor(diffSec / 3600), "hour");
|
|
return rtf.format(-Math.floor(diffSec / 86400), "day");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Run-status pill
|
|
// ---------------------------------------------------------------------------
|
|
const RUN_STATUS_CONFIG: Record<
|
|
string,
|
|
{ label: string; className: string; icon: React.ElementType }
|
|
> = {
|
|
success: {
|
|
label: "Success",
|
|
className:
|
|
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
|
|
icon: CheckCircle2Icon,
|
|
},
|
|
partial: {
|
|
label: "Partial",
|
|
className:
|
|
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
|
|
icon: AlertTriangleIcon,
|
|
},
|
|
failed: {
|
|
label: "Failed",
|
|
className:
|
|
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
|
|
icon: XCircleIcon,
|
|
},
|
|
skipped: {
|
|
label: "Skipped",
|
|
className:
|
|
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
|
|
icon: MinusCircleIcon,
|
|
},
|
|
};
|
|
|
|
function RunStatusBadge({ status }: { status: string }) {
|
|
const cfg = RUN_STATUS_CONFIG[status] ?? {
|
|
label: status,
|
|
className: "bg-secondary text-secondary-foreground border-transparent",
|
|
icon: ActivityIcon,
|
|
};
|
|
const Icon = cfg.icon;
|
|
return (
|
|
<Badge variant="secondary" className={cfg.className}>
|
|
<Icon className="size-3 mr-0.5" />
|
|
{cfg.label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stat card — entire card is the link to its tab
|
|
// ---------------------------------------------------------------------------
|
|
function StatCard({
|
|
title,
|
|
value,
|
|
icon: Icon,
|
|
description,
|
|
href,
|
|
}: {
|
|
title: string;
|
|
value: string | number;
|
|
icon: React.ElementType;
|
|
description?: string;
|
|
href: string;
|
|
}) {
|
|
return (
|
|
<Link
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
href={href as any}
|
|
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
>
|
|
<Card className="h-full transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
|
{title}
|
|
</CardTitle>
|
|
<Icon className="size-4 text-muted-foreground shrink-0" />
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
|
{description && (
|
|
<CardDescription className="mt-1 text-xs">{description}</CardDescription>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page
|
|
// ---------------------------------------------------------------------------
|
|
export default async function DashboardPage() {
|
|
const op = await getSeededOperator();
|
|
const stats = await getDashboardStats(op.id);
|
|
|
|
const hasRuns = stats.recentRuns.length > 0;
|
|
|
|
return (
|
|
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-8">
|
|
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
|
|
|
{/* Stat cards — click to drill into the corresponding tab */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
<StatCard
|
|
title="Accounts connected"
|
|
value={`${stats.connectedAccounts} / ${stats.totalAccounts}`}
|
|
icon={WifiIcon}
|
|
description="WhatsApp accounts"
|
|
href="/accounts"
|
|
/>
|
|
<StatCard
|
|
title="Active reminders"
|
|
value={stats.activeReminders}
|
|
icon={BellIcon}
|
|
description="Scheduled & running"
|
|
href="/reminders?filter=active"
|
|
/>
|
|
<StatCard
|
|
title="Recent runs"
|
|
value={stats.recentRuns.length}
|
|
icon={ActivityIcon}
|
|
description="Last 10 reminder runs"
|
|
href="/reminders"
|
|
/>
|
|
</div>
|
|
|
|
{/* Recent activity */}
|
|
<section className="space-y-4">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h2 className="text-lg font-medium tracking-tight">Recent activity</h2>
|
|
{hasRuns && (
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
|
|
<Trash2Icon />
|
|
Clear history
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Clear all run history?</DialogTitle>
|
|
<DialogDescription>
|
|
This permanently removes every reminder run record, including
|
|
runs from reminders that have already been deleted. Reminders
|
|
themselves are not affected.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter showCloseButton>
|
|
<form action={clearHistoryAction}>
|
|
<Button type="submit" variant="destructive" size="sm">
|
|
<Trash2Icon />
|
|
Yes, clear history
|
|
</Button>
|
|
</form>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
|
|
{hasRuns ? (
|
|
<>
|
|
{/* Mobile: card list — clickable when the reminder still exists */}
|
|
<div className="flex flex-col gap-3 sm:hidden">
|
|
{stats.recentRuns.map((run) => {
|
|
const body = (
|
|
<Card size="sm" className={run.is_deleted ? undefined : "transition-all hover:shadow-md hover:ring-primary/30 cursor-pointer"}>
|
|
<CardContent className="flex items-center justify-between gap-3 py-3">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{run.name}
|
|
{run.is_deleted && (
|
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground italic">
|
|
(deleted)
|
|
</span>
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{relativeTime(run.fired_at)}
|
|
</p>
|
|
</div>
|
|
<RunStatusBadge status={run.status} />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
return run.reminder_id && !run.is_deleted ? (
|
|
<Link
|
|
key={run.id}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
href={`/reminders/${run.reminder_id}` as any}
|
|
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
>
|
|
{body}
|
|
</Link>
|
|
) : (
|
|
<div key={run.id}>{body}</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Desktop: table — rows are clickable when reminder still exists */}
|
|
<div className="hidden sm:block">
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Reminder</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Fired</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{stats.recentRuns.map((run) => {
|
|
const clickable = run.reminder_id && !run.is_deleted;
|
|
return (
|
|
<TableRow
|
|
key={run.id}
|
|
className={clickable ? "cursor-pointer hover:bg-muted/50" : undefined}
|
|
>
|
|
<TableCell className="font-medium">
|
|
{clickable ? (
|
|
<Link
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
href={`/reminders/${run.reminder_id}` as any}
|
|
className="block focus-visible:outline-none focus-visible:underline"
|
|
>
|
|
{run.name}
|
|
</Link>
|
|
) : (
|
|
<span className="text-muted-foreground italic">
|
|
{run.name}
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<RunStatusBadge status={run.status} />
|
|
</TableCell>
|
|
<TableCell className="text-right text-muted-foreground text-xs">
|
|
{relativeTime(run.fired_at)}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
|
<ActivityIcon className="size-10 text-muted-foreground/40" />
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">No reminders have fired yet.</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Schedule one to start sending WhatsApp messages.
|
|
</p>
|
|
</div>
|
|
<Button asChild size="sm">
|
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
<Link href={"/reminders/new" as any}>Schedule a reminder</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|