feat(web): dashboard + accounts list + account detail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-09 23:27:24 +08:00
parent 8771e65c8c
commit 7708dd671c
11 changed files with 663 additions and 14 deletions

View File

@ -16,6 +16,12 @@ const nextConfig: NextConfig = {
},
turbopack: {
root: workspaceRoot,
resolveExtensions: [".tsx", ".ts", ".jsx", ".js", ".mjs", ".json"],
resolveAlias: {
// Turbopack doesn't strip the `.js` extension alias that NodeNext requires.
// Map the compiled-style paths back to the real TS source files.
"@cmbot/db/schema.js": `${workspaceRoot}/packages/db/src/schema.ts`,
},
},
};

View File

@ -0,0 +1,217 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
UsersIcon,
RefreshCwIcon,
Trash2Icon,
ArrowLeftIcon,
SmartphoneIcon,
CalendarIcon,
TagIcon,
DatabaseIcon,
} 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 { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries";
interface AccountDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function AccountDetailPage({ params }: AccountDetailPageProps) {
const { id } = await params;
const op = await getSeededOperator();
const account = await getAccount(op.id, id);
if (!account) {
notFound();
}
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-2xl mx-auto space-y-6">
{/* Back link */}
<Button asChild variant="ghost" size="sm" className="-ml-2">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts" as any}>
<ArrowLeftIcon />
Accounts
</Link>
</Button>
{/* Header */}
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{account.label}</h1>
<AccountStatusBadge status={account.status} />
</div>
{account.phoneNumber && account.status === "connected" && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<SmartphoneIcon className="size-3.5 shrink-0" />
{account.phoneNumber}
</p>
)}
</div>
{/* Actions */}
<div className="flex flex-col gap-3">
{/* Groups */}
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<UsersIcon className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Groups</p>
<p className="text-xs text-muted-foreground">View synced WhatsApp groups</p>
</div>
</div>
<Button asChild variant="outline" size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${account.id}/groups` as any}>View</Link>
</Button>
</CardContent>
</Card>
{/* Sync */}
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-muted">
<RefreshCwIcon className="size-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Sync Groups Now</p>
<p className="text-xs text-muted-foreground">
Fetch latest groups from WhatsApp
</p>
</div>
</div>
{/* No-op placeholder — wired in Task 17 */}
<form action={async () => { "use server"; }}>
<Button type="submit" variant="outline" size="sm">
<RefreshCwIcon />
Sync
</Button>
</form>
</CardContent>
</Card>
{/* Unpair */}
<Card>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-medium text-destructive">Unpair Account</p>
<p className="text-xs text-muted-foreground">
Disconnect and remove this account
</p>
</div>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
Unpair
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair account?</DialogTitle>
<DialogDescription>
This will disconnect <strong>{account.label}</strong> from cm WhatsApp Bot.
Any scheduled reminders using this account will stop firing. This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
{/* No-op placeholder — wired in Task 17 */}
<form action={async () => { "use server"; }}>
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, unpair
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</div>
{/* Detail grid */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
Account details
</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex items-start gap-2">
<TagIcon className="size-3.5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<dt className="text-xs text-muted-foreground">Label</dt>
<dd className="text-sm font-medium">{account.label}</dd>
</div>
</div>
<div className="flex items-start gap-2">
<DatabaseIcon className="size-3.5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<dt className="text-xs text-muted-foreground">Status</dt>
<dd className="text-sm font-medium capitalize">
{account.status.replace(/_/g, " ")}
</dd>
</div>
</div>
<div className="flex items-start gap-2">
<CalendarIcon className="size-3.5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<dt className="text-xs text-muted-foreground">Paired at</dt>
<dd className="text-sm font-medium">
{account.createdAt.toLocaleDateString("en-MY", {
timeZone: "Asia/Kuala_Lumpur",
year: "numeric",
month: "short",
day: "numeric",
})}
</dd>
</div>
</div>
{account.phoneNumber && (
<div className="flex items-start gap-2">
<SmartphoneIcon className="size-3.5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<dt className="text-xs text-muted-foreground">Phone number</dt>
<dd className="text-sm font-medium">{account.phoneNumber}</dd>
</div>
</div>
)}
</dl>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,100 @@
import Link from "next/link";
import { PlusIcon, SmartphoneIcon, CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator";
import { listAccounts } from "@/lib/queries";
export default async function AccountsPage() {
const op = await getSeededOperator();
const accounts = await listAccounts(op.id);
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Accounts</h1>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts/new" as any}>
<PlusIcon />
Pair New Account
</Link>
</Button>
</div>
{/* Account cards */}
{accounts.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{accounts.map((account) => (
<Link
key={account.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/accounts/${account.id}` 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-shadow hover:shadow-md hover:ring-foreground/20">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base leading-snug">{account.label}</CardTitle>
<AccountStatusBadge status={account.status} />
</div>
</CardHeader>
<CardContent className="space-y-2">
{account.phoneNumber ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<SmartphoneIcon className="size-3.5 shrink-0" />
<span>{account.phoneNumber}</span>
</div>
) : (
<p className="text-sm text-muted-foreground/60 italic">No phone number</p>
)}
{account.lastConnectedAt ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" />
<span>
Last connected{" "}
{account.lastConnectedAt.toLocaleDateString("en-MY", {
timeZone: "Asia/Kuala_Lumpur",
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
</div>
) : null}
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<SmartphoneIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">No accounts paired yet.</p>
<p className="text-xs text-muted-foreground">
Pair a WhatsApp account to start scheduling reminders.
</p>
</div>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts/new" as any}>
<PlusIcon />
Pair New Account
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -1,10 +1,236 @@
export default function Page() {
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 (
<main className="p-8">
<h1 className="text-2xl font-semibold">cm WhatsApp Bot</h1>
<p className="text-muted-foreground">
Web app skeleton wired up. Real dashboard arrives in Task 13.
</p>
</main>
<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>
);
}

View File

@ -0,0 +1,51 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
type AccountStatus =
| "connected"
| "pending"
| "connecting"
| "disconnected"
| "logged_out"
| "banned";
const STATUS_LABEL: Record<AccountStatus, string> = {
connected: "Connected",
pending: "Pending",
connecting: "Connecting",
disconnected: "Disconnected",
logged_out: "Logged Out",
banned: "Banned",
};
const STATUS_CLASS: Record<AccountStatus, string> = {
connected:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
pending:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
connecting:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
disconnected:
"bg-amber-200/40 text-amber-600 dark:bg-amber-900/30 dark:text-amber-500 border-transparent",
logged_out:
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
banned:
"bg-red-700/20 text-red-800 dark:bg-red-900/40 dark:text-red-300 border-transparent",
};
interface AccountStatusBadgeProps {
status: string;
className?: string;
}
export function AccountStatusBadge({ status, className }: AccountStatusBadgeProps) {
const key = status as AccountStatus;
const label = STATUS_LABEL[key] ?? status;
const cls = STATUS_CLASS[key] ?? "bg-secondary text-secondary-foreground border-transparent";
return (
<Badge variant="secondary" className={cn(cls, className)}>
{label}
</Badge>
);
}

View File

@ -21,7 +21,8 @@ function BottomNav() {
return (
<Link
key={key}
href={href}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
aria-current={active ? "page" : undefined}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-0.5 min-h-[44px] text-xs font-medium transition-colors",
@ -68,7 +69,8 @@ function Sidebar() {
return (
<Link
key={key}
href={href}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors min-h-[44px]",

View File

@ -0,0 +1,45 @@
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "./db";
export async function getDashboardStats(operatorId: string) {
const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId),
});
const reminders = await db.query.reminders.findMany({
where: (_, { sql: s }) => s`status = 'active'`,
});
const recentRuns = await db.execute(sql`
SELECT rr.id, rr.status, rr.fired_at, r.name
FROM reminder_runs rr
JOIN reminders r ON r.id = rr.reminder_id
JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId}
ORDER BY rr.fired_at DESC
LIMIT 10
`);
return {
connectedAccounts: accounts.filter((a) => a.status === "connected").length,
totalAccounts: accounts.length,
activeReminders: reminders.length,
recentRuns: recentRuns.rows as Array<{
id: string;
status: string;
fired_at: Date;
name: string;
}>,
};
}
export async function listAccounts(operatorId: string) {
return db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId),
orderBy: (a, { asc }) => [asc(a.label)],
});
}
export async function getAccount(operatorId: string, accountId: string) {
return db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
}

View File

@ -1,8 +1,8 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema.js";
import * as schema from "./schema";
export * from "./schema.js";
export * from "./schema";
export type DB = NodePgDatabase<typeof schema>;

View File

@ -1,5 +1,5 @@
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { createClient } from "./index.js";
import { createClient } from "./index";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {

View File

@ -1,4 +1,4 @@
import { createClient, operators } from "./index.js";
import { createClient, operators } from "./index";
const databaseUrl = process.env.DATABASE_URL;
const operatorTelegramId = process.env.SEED_OPERATOR_TELEGRAM_ID;

View File

@ -2,7 +2,9 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
"rootDir": "./src",
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*"]
}