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.label}

+ +
+ {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 */} +
{ "use server"; }}> + +
+
+
+ + {/* Unpair */} + + +
+
+ +
+
+

Unpair Account

+

+ Disconnect and remove this account +

+
+
+ + + + + + + Unpair account? + + This will disconnect {account.label} from cm WhatsApp Bot. + Any scheduled reminders using this account will stop firing. This action + cannot be undone. + + + + {/* No-op placeholder — wired in Task 17 */} +
{ "use server"; }}> + +
+
+
+
+
+
+
+ + {/* 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.label} + +
+
+ + {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/**/*"] }