diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index e273fe3..bb1116c 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -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`,
+ },
},
};
diff --git a/apps/web/src/app/accounts/[id]/page.tsx b/apps/web/src/app/accounts/[id]/page.tsx
new file mode 100644
index 0000000..f2d29dc
--- /dev/null
+++ b/apps/web/src/app/accounts/[id]/page.tsx
@@ -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 (
+
+ {/* Back link */}
+
+
+ {/* Header */}
+
+
+ {account.phoneNumber && account.status === "connected" && (
+
+
+ {account.phoneNumber}
+
+ )}
+
+
+ {/* Actions */}
+
+ {/* Groups */}
+
+
+
+
+
+
+
+
Groups
+
View synced WhatsApp groups
+
+
+
+
+
+
+ {/* Sync */}
+
+
+
+
+
+
+
+
Sync Groups Now
+
+ Fetch latest groups from WhatsApp
+
+
+
+ {/* No-op placeholder — wired in Task 17 */}
+
+
+
+
+ {/* Unpair */}
+
+
+
+
+
+
+
+
Unpair Account
+
+ Disconnect and remove this account
+
+
+
+
+
+
+
+
+ {/* Detail grid */}
+
+
+
+ Account details
+
+
+
+
+
+
+
+
- Label
+ - {account.label}
+
+
+
+
+
+
+
- Status
+ -
+ {account.status.replace(/_/g, " ")}
+
+
+
+
+
+
+
+
- Paired at
+ -
+ {account.createdAt.toLocaleDateString("en-MY", {
+ timeZone: "Asia/Kuala_Lumpur",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}
+
+
+
+
+ {account.phoneNumber && (
+
+
+
+
- Phone number
+ - {account.phoneNumber}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/accounts/page.tsx b/apps/web/src/app/accounts/page.tsx
new file mode 100644
index 0000000..cfd50da
--- /dev/null
+++ b/apps/web/src/app/accounts/page.tsx
@@ -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 (
+
+ {/* Header */}
+
+
Accounts
+
+
+
+ {/* Account cards */}
+ {accounts.length > 0 ? (
+
+ {accounts.map((account) => (
+
+
+
+
+
+
+ {account.phoneNumber ? (
+
+
+ {account.phoneNumber}
+
+ ) : (
+ No phone number
+ )}
+ {account.lastConnectedAt ? (
+
+
+
+ Last connected{" "}
+ {account.lastConnectedAt.toLocaleDateString("en-MY", {
+ timeZone: "Asia/Kuala_Lumpur",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}
+
+
+ ) : null}
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
No accounts paired yet.
+
+ Pair a WhatsApp account to start scheduling reminders.
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 2d435ad..43b4799 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -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 (
-
- cm WhatsApp Bot
-
- Web app skeleton — wired up. Real dashboard arrives in Task 13.
-
-
+
+
+ {cfg.label}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Stat card
+// ---------------------------------------------------------------------------
+function StatCard({
+ title,
+ value,
+ icon: Icon,
+ description,
+}: {
+ title: string;
+ value: string | number;
+ icon: React.ElementType;
+ description?: string;
+}) {
+ return (
+
+
+
+
+ {title}
+
+
+
+
+
+ {value}
+ {description && (
+ {description}
+ )}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Page
+// ---------------------------------------------------------------------------
+export default async function DashboardPage() {
+ const op = await getSeededOperator();
+ const stats = await getDashboardStats(op.id);
+
+ const hasRuns = stats.recentRuns.length > 0;
+
+ return (
+
+
Dashboard
+
+ {/* Stat cards */}
+
+
+
+
+
+
+ {/* Recent activity */}
+
+ Recent activity
+
+ {hasRuns ? (
+ <>
+ {/* Mobile: card list */}
+
+ {stats.recentRuns.map((run) => (
+
+
+
+
{run.name}
+
+ {relativeTime(run.fired_at)}
+
+
+
+
+
+ ))}
+
+
+ {/* Desktop: table */}
+
+
+
+
+
+
+ Reminder
+ Status
+ Fired
+
+
+
+ {stats.recentRuns.map((run) => (
+
+ {run.name}
+
+
+
+
+ {relativeTime(run.fired_at)}
+
+
+ ))}
+
+
+
+
+
+ >
+ ) : (
+
+
+
+
+
No reminders have fired yet.
+
+ Schedule one to start sending WhatsApp messages.
+
+
+
+
+
+ )}
+
+
);
}
diff --git a/apps/web/src/components/account-status-badge.tsx b/apps/web/src/components/account-status-badge.tsx
new file mode 100644
index 0000000..47e8ed6
--- /dev/null
+++ b/apps/web/src/components/account-status-badge.tsx
@@ -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 = {
+ connected: "Connected",
+ pending: "Pending",
+ connecting: "Connecting",
+ disconnected: "Disconnected",
+ logged_out: "Logged Out",
+ banned: "Banned",
+};
+
+const STATUS_CLASS: Record = {
+ 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 (
+
+ {label}
+
+ );
+}
diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx
index 0a0cf8b..f9bda9d 100644
--- a/apps/web/src/components/app-shell.tsx
+++ b/apps/web/src/components/app-shell.tsx
@@ -21,7 +21,8 @@ function BottomNav() {
return (
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)),
+ });
+}
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index f36c389..de3477a 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -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;
diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts
index dc14764..962a365 100644
--- a/packages/db/src/migrate.ts
+++ b/packages/db/src/migrate.ts
@@ -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) {
diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts
index 6c75e3a..3fa93f6 100644
--- a/packages/db/src/seed.ts
+++ b/packages/db/src/seed.ts
@@ -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;
diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json
index 8f24167..20c4917 100644
--- a/packages/db/tsconfig.json
+++ b/packages/db/tsconfig.json
@@ -2,7 +2,9 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
- "rootDir": "./src"
+ "rootDir": "./src",
+ "module": "ESNext",
+ "moduleResolution": "Bundler"
},
"include": ["src/**/*"]
}