feat(web): dashboard + accounts list + account detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8771e65c8c
commit
7708dd671c
@ -16,6 +16,12 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: workspaceRoot,
|
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`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
217
apps/web/src/app/accounts/[id]/page.tsx
Normal file
217
apps/web/src/app/accounts/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
apps/web/src/app/accounts/page.tsx
Normal file
100
apps/web/src/app/accounts/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 (
|
return (
|
||||||
<main className="p-8">
|
<Badge variant="secondary" className={cfg.className}>
|
||||||
<h1 className="text-2xl font-semibold">cm WhatsApp Bot</h1>
|
<Icon className="size-3 mr-0.5" />
|
||||||
<p className="text-muted-foreground">
|
{cfg.label}
|
||||||
Web app skeleton — wired up. Real dashboard arrives in Task 13.
|
</Badge>
|
||||||
</p>
|
);
|
||||||
</main>
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
51
apps/web/src/components/account-status-badge.tsx
Normal file
51
apps/web/src/components/account-status-badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -21,7 +21,8 @@ function BottomNav() {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={key}
|
key={key}
|
||||||
href={href}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={href as any}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 flex-col items-center justify-center gap-0.5 min-h-[44px] text-xs font-medium transition-colors",
|
"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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={key}
|
key={key}
|
||||||
href={href}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={href as any}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors min-h-[44px]",
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors min-h-[44px]",
|
||||||
|
|||||||
45
apps/web/src/lib/queries.ts
Normal file
45
apps/web/src/lib/queries.ts
Normal 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)),
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import { Pool } from "pg";
|
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>;
|
export type DB = NodePgDatabase<typeof schema>;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
import { createClient } from "./index.js";
|
import { createClient } from "./index";
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { createClient, operators } from "./index.js";
|
import { createClient, operators } from "./index";
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
const operatorTelegramId = process.env.SEED_OPERATOR_TELEGRAM_ID;
|
const operatorTelegramId = process.env.SEED_OPERATOR_TELEGRAM_ID;
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src"
|
"rootDir": "./src",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"]
|
"include": ["src/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user