feat(web): reminders list + detail pages with run history

This commit is contained in:
yiekheng 2026-05-09 23:36:18 +08:00
parent 6b1a9191ab
commit 8fd5468e3a
4 changed files with 566 additions and 0 deletions

View File

@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import { Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
interface DeleteDialogProps {
deleteAction: () => Promise<void>;
}
export function DeleteDialog({ deleteAction }: DeleteDialogProps) {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
async function handleConfirm() {
setPending(true);
try {
await deleteAction();
} finally {
setPending(false);
setOpen(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2Icon />
Delete Reminder
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this reminder?</DialogTitle>
<DialogDescription>
This action cannot be undone. The reminder and all its run history
will be permanently deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="destructive"
size="sm"
disabled={pending}
onClick={handleConfirm}
>
{pending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,249 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ArrowLeftIcon,
CalendarIcon,
SmartphoneIcon,
UsersIcon,
ClockIcon,
FileTextIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getSeededOperator } from "@/lib/operator";
import { getReminderWithRuns } from "@/lib/queries";
import { DeleteDialog } from "./delete-dialog";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—";
return new Intl.DateTimeFormat("en-MY", {
timeZone: tz,
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(date));
}
// ---------------------------------------------------------------------------
// Status pill
// ---------------------------------------------------------------------------
const STATUS_STYLES: Record<string, string> = {
active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
ended:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
failed:
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
success:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
};
function StatusPill({ status }: { status: string }) {
const cls =
STATUS_STYLES[status] ??
"bg-secondary text-secondary-foreground border-transparent";
const label = status.charAt(0).toUpperCase() + status.slice(1);
return (
<Badge variant="secondary" className={cls}>
{label}
</Badge>
);
}
// ---------------------------------------------------------------------------
// Server action (no-op placeholder — real delete wired in Task 19)
// ---------------------------------------------------------------------------
async function _deleteReminderStub() {
"use server";
// wired in Task 19
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
interface Props {
params: Promise<{ id: string }>;
}
export default async function ReminderDetailPage({ params }: Props) {
const { id } = await params;
const op = await getSeededOperator();
const data = await getReminderWithRuns(op.id, id);
if (!data) {
notFound();
}
const { reminder, account, targets, messages, runs } = data;
const tz = op.defaultTimezone ?? "UTC";
return (
<div className="px-4 py-6 sm:px-6 sm:py-8 max-w-3xl 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={"/reminders" as any}>
<ArrowLeftIcon />
Back to Reminders
</Link>
</Button>
{/* Header */}
<div className="space-y-2">
<div className="flex items-start gap-3 flex-wrap">
<h1 className="text-2xl font-semibold tracking-tight leading-tight flex-1 min-w-0">
{reminder.name}
</h1>
<StatusPill status={reminder.status} />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<CalendarIcon className="size-3.5 shrink-0" />
<span>When: {formatWhen(reminder.scheduledAt, tz)}</span>
</div>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<SmartphoneIcon className="size-3.5 shrink-0" />
<span>Account: {account.label}</span>
</div>
</div>
</div>
<Separator />
{/* Message body */}
<section className="space-y-3">
<h2 className="text-base font-medium tracking-tight flex items-center gap-2">
<FileTextIcon className="size-4 text-muted-foreground" />
Message
</h2>
<Card>
<CardContent className="py-4 space-y-3">
{messages.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No message parts defined.</p>
) : (
messages.map((msg, i) => (
<div key={msg.id}>
{i > 0 && <Separator className="my-3" />}
{msg.kind === "text" && msg.textContent ? (
<p className="text-sm whitespace-pre-wrap">{msg.textContent}</p>
) : (
<div className="space-y-1">
<p className="text-sm font-mono text-muted-foreground">
[{msg.kind}]{msg.textContent ? ` ${msg.textContent}` : ""}
</p>
<p className="text-xs text-muted-foreground/70 italic">
Media preview coming soon.
</p>
</div>
)}
</div>
))
)}
</CardContent>
</Card>
</section>
{/* Target groups */}
{targets.length > 0 && (
<section className="space-y-3">
<h2 className="text-base font-medium tracking-tight flex items-center gap-2">
<UsersIcon className="size-4 text-muted-foreground" />
Groups
<Badge variant="outline" className="ml-1">
{targets.length}
</Badge>
</h2>
<div className="flex flex-wrap gap-2">
{targets.map((t) => (
<Badge key={t.groupId} variant="secondary">
{t.groupName}
</Badge>
))}
</div>
</section>
)}
<Separator />
{/* Run history */}
<section className="space-y-3">
<h2 className="text-base font-medium tracking-tight flex items-center gap-2">
<ClockIcon className="size-4 text-muted-foreground" />
Run history
</h2>
{runs.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-10 text-center">
<ClockIcon className="size-8 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">Has not fired yet.</p>
<p className="text-xs text-muted-foreground">
Runs will appear here once the reminder fires.
</p>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden sm:table-cell">Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow key={run.id}>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatWhen(run.firedAt, tz)}
</TableCell>
<TableCell>
<StatusPill status={run.status} />
</TableCell>
<TableCell className="hidden sm:table-cell text-xs text-muted-foreground max-w-xs truncate">
{run.errorSummary ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</section>
{/* Action footer */}
<div className="flex items-center justify-end pt-2 border-t">
<DeleteDialog deleteAction={_deleteReminderStub} />
</div>
</div>
);
}

View File

@ -0,0 +1,184 @@
import Link from "next/link";
import { PlusIcon, BellIcon, CalendarIcon, UsersIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getSeededOperator } from "@/lib/operator";
import { listReminders } from "@/lib/queries";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type FilterValue = "all" | "active" | "ended" | "failed";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatWhen(date: Date | null, tz: string): string {
if (!date) return "—";
return new Intl.DateTimeFormat("en-MY", {
timeZone: tz,
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(date));
}
// ---------------------------------------------------------------------------
// Status pill
// ---------------------------------------------------------------------------
const STATUS_STYLES: Record<string, string> = {
active:
"bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent",
ended:
"bg-slate-200/60 text-slate-500 dark:bg-slate-700/40 dark:text-slate-400 border-transparent",
paused:
"bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400 border-transparent",
failed:
"bg-red-500/15 text-red-600 dark:bg-red-500/20 dark:text-red-400 border-transparent",
};
function StatusPill({ status }: { status: string }) {
const cls =
STATUS_STYLES[status] ??
"bg-secondary text-secondary-foreground border-transparent";
const label = status.charAt(0).toUpperCase() + status.slice(1);
return (
<Badge variant="secondary" className={cls}>
{label}
</Badge>
);
}
// ---------------------------------------------------------------------------
// Filter tabs
// ---------------------------------------------------------------------------
const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "ended", label: "Ended" },
{ value: "failed", label: "Failed" },
];
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
interface PageProps {
searchParams: Promise<{ filter?: string }>;
}
export default async function RemindersPage({ searchParams }: PageProps) {
const { filter: rawFilter } = await searchParams;
const filter: FilterValue =
rawFilter === "active" || rawFilter === "ended" || rawFilter === "failed"
? rawFilter
: "all";
const op = await getSeededOperator();
const allReminders = await listReminders(op.id);
const tz = op.defaultTimezone ?? "UTC";
const filtered =
filter === "all"
? allReminders
: allReminders.filter((r) => r.status === filter);
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">Reminders</h1>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}>
<PlusIcon />
New Reminder
</Link>
</Button>
</div>
{/* Filter tabs — URL-driven, no client state */}
<Tabs value={filter}>
<TabsList>
{FILTER_TABS.map(({ value, label }) => (
<TabsTrigger key={value} value={value} asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={(value === "all" ? "/reminders" : `/reminders?filter=${value}`) as any}>
{label}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{/* Reminder list */}
{filtered.length > 0 ? (
<div className="flex flex-col gap-3">
{filtered.map((reminder) => (
<Link
key={reminder.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/reminders/${reminder.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="transition-shadow hover:shadow-md hover:ring-foreground/20">
<CardContent className="flex items-center gap-3 py-3 px-4">
{/* Status + name */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<StatusPill status={reminder.status} />
<span className="text-sm font-medium leading-none truncate">
{reminder.name}
</span>
</div>
<p className="text-xs text-muted-foreground truncate">
{reminder.accountLabel}
</p>
</div>
{/* When + group count */}
<div className="shrink-0 text-right space-y-1">
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" />
<span>{formatWhen(reminder.scheduledAt, tz)}</span>
</div>
{reminder.groupCount > 0 && (
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" />
<span>
{reminder.groupCount}{" "}
{reminder.groupCount === 1 ? "group" : "groups"}
</span>
</div>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<BellIcon className="size-10 text-muted-foreground/40" />
<div className="space-y-1">
<p className="text-sm font-medium">No reminders yet.</p>
<p className="text-xs text-muted-foreground">
Create a reminder to start sending scheduled WhatsApp messages.
</p>
</div>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any}>
<PlusIcon />
New Reminder
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -84,3 +84,73 @@ export async function getGroup(operatorId: string, groupId: string) {
if (!account) return null; if (!account) return null;
return { group, account }; return { group, account };
} }
export async function listReminders(operatorId: string) {
const rows = await db.execute(sql`
SELECT
r.id, r.name, r.schedule_kind, r.scheduled_at, r.timezone, r.status,
r.created_at, wa.label as account_label,
(SELECT count(*) FROM reminder_targets rt WHERE rt.reminder_id = r.id) as group_count
FROM reminders r
JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId}
ORDER BY r.scheduled_at DESC NULLS LAST, r.created_at DESC
LIMIT 100
`);
return (rows.rows as Array<Record<string, unknown>>).map((r) => ({
id: r.id as string,
name: r.name as string,
scheduleKind: r.schedule_kind as string,
scheduledAt: r.scheduled_at as Date | null,
timezone: r.timezone as string,
status: r.status as string,
createdAt: r.created_at as Date,
accountLabel: r.account_label as string,
groupCount: Number(r.group_count),
}));
}
export async function getReminderWithRuns(operatorId: string, reminderId: string) {
const reminder = await db.query.reminders.findFirst({
where: (r, { eq }) => eq(r.id, reminderId),
});
if (!reminder) return null;
// Verify operator owns the reminder via the account
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, reminder.accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const targets = await db.execute(sql`
SELECT rt.group_id, wg.name as group_name
FROM reminder_targets rt
JOIN whatsapp_groups wg ON wg.id = rt.group_id
WHERE rt.reminder_id = ${reminderId}
ORDER BY rt.position
`);
const messages = await db.query.reminderMessages.findMany({
where: (m, { eq }) => eq(m.reminderId, reminderId),
orderBy: (m, { asc }) => [asc(m.position)],
});
const runs = await db.execute(sql`
SELECT id, fired_at, status, error_summary
FROM reminder_runs
WHERE reminder_id = ${reminderId}
ORDER BY fired_at DESC
LIMIT 20
`);
return {
reminder,
account,
targets: (targets.rows as Array<Record<string, unknown>>).map((r) => ({
groupId: r.group_id as string,
groupName: r.group_name as string,
})),
messages,
runs: (runs.rows as Array<Record<string, unknown>>).map((r) => ({
id: r.id as string,
firedAt: r.fired_at as Date,
status: r.status as string,
errorSummary: r.error_summary as string | null,
})),
};
}