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: {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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]",
|
||||
|
||||
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 { 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>;
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
"rootDir": "./src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user