diff --git a/apps/web/src/app/reminders/[id]/delete-dialog.tsx b/apps/web/src/app/reminders/[id]/delete-dialog.tsx new file mode 100644 index 0000000..9c3d8c1 --- /dev/null +++ b/apps/web/src/app/reminders/[id]/delete-dialog.tsx @@ -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; +} + +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 ( + + + + + + + Delete this reminder? + + This action cannot be undone. The reminder and all its run history + will be permanently deleted. + + + + + + + + ); +} diff --git a/apps/web/src/app/reminders/[id]/page.tsx b/apps/web/src/app/reminders/[id]/page.tsx new file mode 100644 index 0000000..e27cb49 --- /dev/null +++ b/apps/web/src/app/reminders/[id]/page.tsx @@ -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 = { + 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 ( + + {label} + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ {/* Back link */} + + + {/* Header */} +
+
+

+ {reminder.name} +

+ +
+
+
+ + When: {formatWhen(reminder.scheduledAt, tz)} +
+
+ + Account: {account.label} +
+
+
+ + + + {/* Message body */} +
+

+ + Message +

+ + + {messages.length === 0 ? ( +

No message parts defined.

+ ) : ( + messages.map((msg, i) => ( +
+ {i > 0 && } + {msg.kind === "text" && msg.textContent ? ( +

{msg.textContent}

+ ) : ( +
+

+ [{msg.kind}]{msg.textContent ? ` ${msg.textContent}` : ""} +

+

+ Media preview coming soon. +

+
+ )} +
+ )) + )} +
+
+
+ + {/* Target groups */} + {targets.length > 0 && ( +
+

+ + Groups + + {targets.length} + +

+
+ {targets.map((t) => ( + + {t.groupName} + + ))} +
+
+ )} + + + + {/* Run history */} +
+

+ + Run history +

+ + {runs.length === 0 ? ( + + + +
+

Has not fired yet.

+

+ Runs will appear here once the reminder fires. +

+
+
+
+ ) : ( + + + + + + When + Status + Error + + + + {runs.map((run) => ( + + + {formatWhen(run.firedAt, tz)} + + + + + + {run.errorSummary ?? "—"} + + + ))} + +
+
+
+ )} +
+ + {/* Action footer */} +
+ +
+
+ ); +} diff --git a/apps/web/src/app/reminders/page.tsx b/apps/web/src/app/reminders/page.tsx new file mode 100644 index 0000000..ba2f224 --- /dev/null +++ b/apps/web/src/app/reminders/page.tsx @@ -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 = { + 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 ( + + {label} + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ {/* Header */} +
+

Reminders

+ +
+ + {/* Filter tabs — URL-driven, no client state */} + + + {FILTER_TABS.map(({ value, label }) => ( + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + {label} + + + ))} + + + + {/* Reminder list */} + {filtered.length > 0 ? ( +
+ {filtered.map((reminder) => ( + + + + {/* Status + name */} +
+
+ + + {reminder.name} + +
+

+ {reminder.accountLabel} +

+
+ + {/* When + group count */} +
+
+ + {formatWhen(reminder.scheduledAt, tz)} +
+ {reminder.groupCount > 0 && ( +
+ + + {reminder.groupCount}{" "} + {reminder.groupCount === 1 ? "group" : "groups"} + +
+ )} +
+
+
+ + ))} +
+ ) : ( + + + +
+

No reminders yet.

+

+ Create a reminder to start sending scheduled WhatsApp messages. +

+
+ +
+
+ )} +
+ ); +} diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index 560f701..5328e86 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -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>).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>).map((r) => ({ + groupId: r.group_id as string, + groupName: r.group_name as string, + })), + messages, + runs: (runs.rows as Array>).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, + })), + }; +}