feat(web): reminders list + detail pages with run history
This commit is contained in:
parent
6b1a9191ab
commit
8fd5468e3a
63
apps/web/src/app/reminders/[id]/delete-dialog.tsx
Normal file
63
apps/web/src/app/reminders/[id]/delete-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
apps/web/src/app/reminders/[id]/page.tsx
Normal file
249
apps/web/src/app/reminders/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
apps/web/src/app/reminders/page.tsx
Normal file
184
apps/web/src/app/reminders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -84,3 +84,73 @@ export async function getGroup(operatorId: string, groupId: string) {
|
||||
if (!account) return null;
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user