yiekheng 7708dd671c feat(web): dashboard + accounts list + account detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:27:24 +08:00

237 lines
7.7 KiB
TypeScript

import Link from "next/link";
import {
WifiIcon,
BellIcon,
ActivityIcon,
CheckCircle2Icon,
AlertTriangleIcon,
XCircleIcon,
MinusCircleIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
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
// ---------------------------------------------------------------------------
function StatCard({
title,
value,
icon: Icon,
description,
}: {
title: string;
value: string | number;
icon: React.ElementType;
description?: string;
}) {
return (
<Card>
<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>
);
}
// ---------------------------------------------------------------------------
// 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 */}
<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"
/>
<StatCard
title="Active reminders"
value={stats.activeReminders}
icon={BellIcon}
description="Scheduled & running"
/>
<StatCard
title="Recent runs"
value={stats.recentRuns.length}
icon={ActivityIcon}
description="Last 10 reminder runs"
/>
</div>
{/* Recent activity */}
<section className="space-y-4">
<h2 className="text-lg font-medium tracking-tight">Recent activity</h2>
{hasRuns ? (
<>
{/* Mobile: card list */}
<div className="flex flex-col gap-3 sm:hidden">
{stats.recentRuns.map((run) => (
<Card key={run.id} size="sm">
<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}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{relativeTime(run.fired_at)}
</p>
</div>
<RunStatusBadge status={run.status} />
</CardContent>
</Card>
))}
</div>
{/* Desktop: table */}
<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) => (
<TableRow key={run.id}>
<TableCell className="font-medium">{run.name}</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>
);
}