Commit 42aea742 authored by ThinhNC's avatar ThinhNC

feat(reminders): implement daily transaction reminder card and settings

- Add DailyTransactionReminderCard component with toggle, time picker, and today status badge

- Integrate daily transaction reminder into notifications Reminders tab

- Add service methods, react-query hooks, and types for daily reminder API

- Localize daily reminder UI strings in Vietnamese and English
parent 187a6849
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { reminderService } from "@/services/reminder.service"; import { reminderService } from "@/services/reminder.service";
import { CreateReminderInput, ReminderQuery, UpdateReminderInput } from "@/types/reminder"; import { CreateReminderInput, ReminderQuery, UpdateDailyTransactionReminderInput, UpdateReminderInput } from "@/types/reminder";
import { notificationKeys } from "./use-notifications"; import { notificationKeys } from "./use-notifications";
export const reminderKeys = { export const reminderKeys = {
all: ["reminders"] as const, all: ["reminders"] as const,
lists: () => [...reminderKeys.all, "list"] as const, lists: () => [...reminderKeys.all, "list"] as const,
list: (query: ReminderQuery) => [...reminderKeys.lists(), query] as const, list: (query: ReminderQuery) => [...reminderKeys.lists(), query] as const,
dailyTransaction: () => [...reminderKeys.all, "daily-transaction"] as const,
}; };
export function useReminders(query: ReminderQuery, enabled = true) { export function useReminders(query: ReminderQuery, enabled = true) {
...@@ -46,3 +47,29 @@ export function useUpdateReminder() { ...@@ -46,3 +47,29 @@ export function useUpdateReminder() {
export function useDeleteReminder() { export function useDeleteReminder() {
return useReminderMutation((id: string) => reminderService.remove(id)); return useReminderMutation((id: string) => reminderService.remove(id));
} }
// ─── Daily Transaction Reminder ──────────────────────────────────────────────
export function useDailyTransactionReminder(enabled = true) {
return useQuery({
queryKey: reminderKeys.dailyTransaction(),
queryFn: () => reminderService.getDailyTransactionReminder(),
enabled,
staleTime: 30_000,
refetchOnMount: "always",
refetchOnWindowFocus: true,
select: (res) => res.data,
});
}
export function useUpdateDailyTransactionReminder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateDailyTransactionReminderInput) =>
reminderService.updateDailyTransactionReminder(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: reminderKeys.dailyTransaction() });
},
});
}
...@@ -1291,16 +1291,16 @@ ...@@ -1291,16 +1291,16 @@
"save": "Save preferences", "saveSuccess": "Notification preferences saved.", "saveFailed": "Could not save notification preferences." "save": "Save preferences", "saveSuccess": "Notification preferences saved.", "saveFailed": "Could not save notification preferences."
}, },
"channel": { "IN_APP": "In app", "EMAIL": "Email", "ZALO": "Zalo", "PUSH": "Push" }, "channel": { "IN_APP": "In app", "EMAIL": "Email", "ZALO": "Zalo", "PUSH": "Push" },
"channelHint": { "IN_APP": "Inbox and badge", "EMAIL": "Send to account email", "ZALO": "Personal Zalo Bot message", "PUSH": "Connection coming soon" }, "channelHint": { "IN_APP": "Inbox and badge", "EMAIL": "Send to account email", "ZALO": "Personal Bot Zalo message", "PUSH": "Connection coming soon" },
"zaloBotLink": { "zaloBotLink": {
"title": "Link Zalo Bot", "title": "Link Bot Zalo",
"hint": "Send a message to Bot FinWise on Zalo to automatically connect alerts.", "hint": "Send a message to Bot FinWise on Zalo to automatically connect alerts.",
"placeholder": "e.g. dbcb8889dbdc32826bcd", "placeholder": "e.g. dbcb8889dbdc32826bcd",
"linked": "Zalo Bot linked", "linked": "Bot Zalo linked",
"unlink": "Unlink", "unlink": "Unlink",
"saveChat": "Save Chat ID", "saveChat": "Save Chat ID",
"oneClickBtn": "Link via Zalo Bot", "oneClickBtn": "Link via Bot Zalo",
"oneClickTitle": "Link Zalo Bot", "oneClickTitle": "Link Bot Zalo",
"oneClickDesc": "Send this code to Bot Finwise on Zalo to automatically connect:", "oneClickDesc": "Send this code to Bot Finwise on Zalo to automatically connect:",
"openZalo": "Open Zalo Chat", "openZalo": "Open Zalo Chat",
"copyCode": "Copy", "copyCode": "Copy",
...@@ -1309,11 +1309,11 @@ ...@@ -1309,11 +1309,11 @@
"copiedCommand": "Link command copied!", "copiedCommand": "Link command copied!",
"autoCopiedToast": "Command copied! Opening Zalo...", "autoCopiedToast": "Command copied! Opening Zalo...",
"waiting": "Waiting for message on Zalo...", "waiting": "Waiting for message on Zalo...",
"successLinked": "Zalo Bot linked successfully!", "successLinked": "Bot Zalo linked successfully!",
"codeExpires": "Code expires in", "codeExpires": "Code expires in",
"manualToggle": "Or enter Chat ID manually", "manualToggle": "Or enter Chat ID manually",
"hideManual": "Hide manual input", "hideManual": "Hide manual input",
"guideTitle": "How to link with Zalo Bot:", "guideTitle": "How to link with Bot Zalo:",
"guideStep1": "Click \"Open Zalo Chat\" or \"Copy Code\" (the link command is copied to your clipboard).", "guideStep1": "Click \"Open Zalo Chat\" or \"Copy Code\" (the link command is copied to your clipboard).",
"guideStep2": "Open Zalo app → search for \"{{botName}}\" (or ID {{botId}}) → paste and send.", "guideStep2": "Open Zalo app → search for \"{{botName}}\" (or ID {{botId}}) → paste and send.",
"step1Title": "Copy the link command below:", "step1Title": "Copy the link command below:",
...@@ -1362,6 +1362,17 @@ ...@@ -1362,6 +1362,17 @@
"messageMax": "Note must not exceed 2000 characters", "remindAtRequired": "Please select a reminder time", "messageMax": "Note must not exceed 2000 characters", "remindAtRequired": "Please select a reminder time",
"interval": "Repeat interval must be from 1 to 365", "future": "A one-time reminder must be in the future", "interval": "Repeat interval must be from 1 to 365", "future": "A one-time reminder must be in the future",
"onceNoEnd": "A one-time reminder cannot have an end time", "endAfter": "End time must be after the start time" "onceNoEnd": "A one-time reminder cannot have an end time", "endAfter": "End time must be after the start time"
},
"daily": {
"title": "Daily Transaction Logging Reminder",
"hint": "The system will send a notification at the configured time if you haven't logged any transactions today.",
"timeLabel": "Daily reminder time",
"toggleLabel": "Toggle daily transaction reminder",
"todayDone": "Logged today ✓",
"todayPending": "Not logged today",
"nextTrigger": "Next reminder:",
"saveSuccess": "Daily transaction reminder enabled.",
"saveDisabled": "Daily transaction reminder disabled."
} }
}, },
"ai": { "ai": {
...@@ -2054,8 +2065,8 @@ ...@@ -2054,8 +2065,8 @@
"inAppDesc": "Deliver notifications directly to the in-app notification inbox", "inAppDesc": "Deliver notifications directly to the in-app notification inbox",
"emailTitle": "Transactional Email (EMAIL)", "emailTitle": "Transactional Email (EMAIL)",
"emailDesc": "Send budget limit warning emails and monthly account statements", "emailDesc": "Send budget limit warning emails and monthly account statements",
"zaloTitle": "Zalo Notification Service (ZNS)", "zaloTitle": "Bot Zalo",
"zaloDesc": "Deliver automated messages via verified Zalo Official Account", "zaloDesc": "Deliver automated messages via Bot Zalo",
"pushTitle": "Mobile Push Notifications (PUSH)", "pushTitle": "Mobile Push Notifications (PUSH)",
"pushDesc": "Native push notifications via Firebase FCM and Apple APNs", "pushDesc": "Native push notifications via Firebase FCM and Apple APNs",
"error": "Failed to update channel configuration" "error": "Failed to update channel configuration"
......
...@@ -1353,27 +1353,27 @@ ...@@ -1353,27 +1353,27 @@
"channelHint": { "channelHint": {
"IN_APP": "Hộp thư và badge", "IN_APP": "Hộp thư và badge",
"EMAIL": "Gửi tới email tài khoản", "EMAIL": "Gửi tới email tài khoản",
"ZALO": "Tin nhắn qua Zalo Bot cá nhân", "ZALO": "Tin nhắn qua Bot Zalo",
"PUSH": "Kết nối sắp tới" "PUSH": "Kết nối sắp tới"
}, },
"zaloBotLink": { "zaloBotLink": {
"title": "Liên kết Zalo Bot", "title": "Liên kết Bot Zalo",
"hint": "Gửi tin nhắn cho Bot FinWise để kết nối nhận thông báo tự động.", "hint": "Gửi tin nhắn cho Bot FinWise để kết nối nhận thông báo tự động.",
"placeholder": "Ví dụ: dbcb8889dbdc32826bcd", "placeholder": "Ví dụ: dbcb8889dbdc32826bcd",
"linked": "Đã liên kết Zalo Bot", "linked": "Đã liên kết Bot Zalo",
"unlink": "Hủy liên kết", "unlink": "Hủy liên kết",
"saveChat": "Lưu Chat ID", "saveChat": "Lưu Chat ID",
"oneClickBtn": "Kết nối với Zalo Bot", "oneClickBtn": "Kết nối với Bot Zalo",
"oneClickTitle": "Kết Nối Zalo Bot", "oneClickTitle": "Kết Nối Bot Zalo",
"oneClickDesc": "Nhắn mã bên dưới cho Bot Finwise để hoàn tất kết nối tự động:", "oneClickDesc": "Nhắn mã bên dưới cho Bot Finwise để hoàn tất kết nối tự động:",
"openZalo": "Mở khung chat Zalo Bot", "openZalo": "Mở khung chat Zalo",
"copyCode": "Sao chép", "copyCode": "Sao chép",
"copyCommand": "Sao chép", "copyCommand": "Sao chép",
"copied": "Đã sao chép!", "copied": "Đã sao chép!",
"copiedCommand": "Đã sao chép lệnh liên kết!", "copiedCommand": "Đã sao chép lệnh liên kết!",
"autoCopiedToast": "Đã sao chép lệnh! Đang chuyển sang Zalo...", "autoCopiedToast": "Đã sao chép lệnh! Đang chuyển sang Zalo...",
"waiting": "Đang chờ nhận tin nhắn từ Zalo...", "waiting": "Đang chờ nhận tin nhắn từ Zalo...",
"successLinked": "Đã liên kết Zalo Bot thành công!", "successLinked": "Đã liên kết Bot Zalo thành công!",
"codeExpires": "Mã hết hạn sau", "codeExpires": "Mã hết hạn sau",
"manualToggle": "Hoặc nhập Chat ID thủ công", "manualToggle": "Hoặc nhập Chat ID thủ công",
"hideManual": "Ẩn nhập thủ công", "hideManual": "Ẩn nhập thủ công",
...@@ -1463,6 +1463,17 @@ ...@@ -1463,6 +1463,17 @@
"future": "Lịch nhắc một lần phải nằm trong tương lai", "future": "Lịch nhắc một lần phải nằm trong tương lai",
"onceNoEnd": "Lịch nhắc một lần không có ngày kết thúc", "onceNoEnd": "Lịch nhắc một lần không có ngày kết thúc",
"endAfter": "Thời gian kết thúc phải sau thời gian bắt đầu" "endAfter": "Thời gian kết thúc phải sau thời gian bắt đầu"
},
"daily": {
"title": "Nhắc nhở ghi chép giao dịch hôm nay",
"hint": "Hệ thống sẽ gửi thông báo vào giờ đã cài đặt nếu hôm nay bạn chưa ghi bất kỳ giao dịch nào.",
"timeLabel": "Giờ nhắc mỗi ngày",
"toggleLabel": "Bật/tắt nhắc nhở ghi chép giao dịch",
"todayDone": "Đã ghi chép hôm nay ✓",
"todayPending": "Chưa ghi chép hôm nay",
"nextTrigger": "Lần nhắc tiếp theo:",
"saveSuccess": "Đã bật nhắc nhở ghi chép giao dịch.",
"saveDisabled": "Đã tắt nhắc nhở ghi chép giao dịch."
} }
}, },
"ai": { "ai": {
...@@ -2155,8 +2166,8 @@ ...@@ -2155,8 +2166,8 @@
"inAppDesc": "Gửi tin nhắn trực tiếp vào hộp thư thông báo trên app", "inAppDesc": "Gửi tin nhắn trực tiếp vào hộp thư thông báo trên app",
"emailTitle": "Email Transactional (EMAIL)", "emailTitle": "Email Transactional (EMAIL)",
"emailDesc": "Gửi email cảnh báo vượt hạn mức & sao kê tháng", "emailDesc": "Gửi email cảnh báo vượt hạn mức & sao kê tháng",
"zaloTitle": "Zalo Notification Service (ZNS)", "zaloTitle": "Bot Zalo",
"zaloDesc": "Gửi tin nhắn qua Zalo Official Account", "zaloDesc": "Gửi tin nhắn tự động qua Bot Zalo",
"pushTitle": "Mobile Push Notifications (PUSH)", "pushTitle": "Mobile Push Notifications (PUSH)",
"pushDesc": "Thông báo đẩy native qua FCM / Apple APNs", "pushDesc": "Thông báo đẩy native qua FCM / Apple APNs",
"error": "Lưu cấu hình kênh thất bại" "error": "Lưu cấu hình kênh thất bại"
......
import React, { useEffect, useState } from "react";
import { Card } from "@/components/ui/Card";
import { useI18n } from "@/i18n";
import { DailyTransactionReminder, UpdateDailyTransactionReminderInput } from "@/types/reminder";
interface DailyTransactionReminderCardProps {
reminder: DailyTransactionReminder | undefined;
isLoading: boolean;
isSaving: boolean;
onUpdate: (payload: UpdateDailyTransactionReminderInput) => void;
}
export const DailyTransactionReminderCard: React.FC<DailyTransactionReminderCardProps> = ({
reminder,
isLoading,
isSaving,
onUpdate,
}) => {
const { t } = useI18n();
const [isActive, setIsActive] = useState(reminder?.isActive ?? false);
const [time, setTime] = useState(reminder?.time ?? "20:00");
useEffect(() => {
if (reminder) {
setIsActive(reminder.isActive);
setTime(reminder.time);
}
}, [reminder]);
const hasTransactionToday = reminder?.hasCreatedTransactionToday ?? false;
const handleToggle = () => {
const newActive = !isActive;
setIsActive(newActive);
onUpdate({ isActive: newActive, time });
};
const handleTimeChange = (newTime: string) => {
setTime(newTime);
onUpdate({ isActive, time: newTime });
};
return (
<Card className="p-5">
{/* ── Header row ─────────────────────────────────────── */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Icon */}
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-clay shadow-clay-raised transition-all duration-200 ${
isActive ? "bg-clay-warning" : "bg-clay-bg shadow-clay-pressed"
}`}
aria-hidden="true"
>
<span className="text-xl">⏰</span>
</div>
{/* Title + today badge */}
<div className="min-w-0">
<p className="font-nunito text-sm font-bold text-clay-text leading-tight">
{t("reminder.daily.title")}
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{isLoading ? (
<span className="h-5 w-24 animate-pulse rounded-full bg-clay-bg" />
) : hasTransactionToday ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-2.5 py-0.5 font-nunito text-xs font-bold text-green-700">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
{t("reminder.daily.todayDone")}
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-clay-warning/20 px-2.5 py-0.5 font-nunito text-xs font-bold text-clay-warning">
<span className="h-1.5 w-1.5 rounded-full bg-clay-warning" />
{t("reminder.daily.todayPending")}
</span>
)}
</div>
</div>
</div>
{/* Toggle switch */}
<button
type="button"
role="switch"
aria-checked={isActive}
aria-label={t("reminder.daily.toggleLabel")}
disabled={isSaving || isLoading}
onClick={handleToggle}
className={`relative h-7 w-12 shrink-0 rounded-full border transition-all duration-200 ease-in-out disabled:opacity-50 ${
isActive
? "border-clay-primary-dark/30 bg-clay-primary"
: "border-clay-border bg-clay-surface shadow-clay-pressed"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out ${
isActive ? "left-6" : "left-1"
}`}
/>
</button>
</div>
{/* ── Description ────────────────────────────────────── */}
<p className="clay-caption mt-3">{t("reminder.daily.hint")}</p>
{/* ── Settings panel — shown when active ─────────────── */}
{isActive && (
<div className="mt-4 flex flex-col gap-3">
{/* Time picker */}
<div className="flex items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<span className="font-nunito text-sm font-semibold text-clay-text-muted flex-1">
{t("reminder.daily.timeLabel")}
</span>
<input
type="time"
value={time}
disabled={isSaving}
onChange={(e) => handleTimeChange(e.target.value)}
className="rounded-clay-sm border border-transparent bg-clay-surface px-3 py-1.5 font-nunito text-sm font-bold text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 disabled:opacity-50"
aria-label={t("reminder.daily.timeLabel")}
/>
</div>
</div>
)}
{/* ── Next trigger info ───────────────────────────────── */}
{isActive && reminder?.nextTriggerAt && (
<p className="clay-caption mt-2 text-center">
{t("reminder.daily.nextTrigger")}{" "}
<span className="font-bold text-clay-text">
{new Intl.DateTimeFormat("vi-VN", {
timeZone: "Asia/Ho_Chi_Minh",
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(reminder.nextTriggerAt))}
</span>
</p>
)}
</Card>
);
};
...@@ -15,11 +15,12 @@ import { ...@@ -15,11 +15,12 @@ import {
useUnreadNotificationCount, useUnreadNotificationCount,
useUpdateNotificationSetting, useUpdateNotificationSetting,
} from "@/hooks/use-notifications"; } from "@/hooks/use-notifications";
import { useCreateReminder, useDeleteReminder, useReminders, useUpdateReminder } from "@/hooks/use-reminders"; import { useCreateReminder, useDailyTransactionReminder, useDeleteReminder, useReminders, useUpdateDailyTransactionReminder, useUpdateReminder } from "@/hooks/use-reminders";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { NotificationItem, NotificationPriority, NotificationQuery, NotificationType } from "@/types/notification"; import { NotificationItem, NotificationPriority, NotificationQuery, NotificationType } from "@/types/notification";
import { CreateReminderInput, Reminder, ReminderQuery, ReminderType } from "@/types/reminder"; import { CreateReminderInput, Reminder, ReminderQuery, ReminderType } from "@/types/reminder";
import { DailyTransactionReminderCard } from "./components/DailyTransactionReminderCard";
import { NotificationCard } from "./components/NotificationCard"; import { NotificationCard } from "./components/NotificationCard";
import { NotificationSettings } from "./components/NotificationSettings"; import { NotificationSettings } from "./components/NotificationSettings";
import { NotificationSkeleton } from "./components/NotificationSkeleton"; import { NotificationSkeleton } from "./components/NotificationSkeleton";
...@@ -73,6 +74,8 @@ const NotificationsPage: React.FC = () => { ...@@ -73,6 +74,8 @@ const NotificationsPage: React.FC = () => {
const createReminderMutation = useCreateReminder(); const createReminderMutation = useCreateReminder();
const updateReminderMutation = useUpdateReminder(); const updateReminderMutation = useUpdateReminder();
const deleteReminderMutation = useDeleteReminder(); const deleteReminderMutation = useDeleteReminder();
const dailyReminderQuery = useDailyTransactionReminder(tab === "REMINDERS");
const updateDailyReminderMutation = useUpdateDailyTransactionReminder();
const notifications = notificationsQuery.data?.data || []; const notifications = notificationsQuery.data?.data || [];
const reminders = remindersQuery.data?.data || []; const reminders = remindersQuery.data?.data || [];
...@@ -169,7 +172,7 @@ const NotificationsPage: React.FC = () => { ...@@ -169,7 +172,7 @@ const NotificationsPage: React.FC = () => {
<section className="flex flex-col gap-3" aria-labelledby="notification-list-title"><div className="flex items-center justify-between px-1"><div><h2 id="notification-list-title" className="clay-title-h2">{t("notification.list")}</h2><p className="clay-caption">{t("common.results", { count: notificationsQuery.data?.meta.total || 0 })}</p></div>{notificationsQuery.isFetching && !notificationsQuery.isLoading && <span className="clay-caption animate-pulse">{t("notification.syncing")}</span>}</div>{renderState("notification", notificationsQuery.isLoading, notificationsQuery.isError, notifications.length === 0, () => { void notificationsQuery.refetch(); })}{notifications.map((item) => <NotificationCard key={item.id} notification={item} isProcessing={mutationPending} onOpen={() => openNotification(item)} onMarkRead={() => markRead(item.id)} onDelete={() => setDeleteTarget({ kind: "notification", item })} />)}{notificationPages > 1 && <nav className="flex items-center justify-between gap-3 pt-2" aria-label={t("notification.paginationLabel")}><Button variant="secondary" className="px-4 text-sm" disabled={notificationPage <= 1 || notificationsQuery.isFetching} onClick={() => setNotificationPage((value) => value - 1)}>{t("common.previous")}</Button><span className="font-nunito text-sm font-bold text-clay-text">{t("common.pageOf", { page: notificationPage, total: notificationPages })}</span><Button variant="secondary" className="px-4 text-sm" disabled={notificationPage >= notificationPages || notificationsQuery.isFetching} onClick={() => setNotificationPage((value) => value + 1)}>{t("common.next")}</Button></nav>}</section> <section className="flex flex-col gap-3" aria-labelledby="notification-list-title"><div className="flex items-center justify-between px-1"><div><h2 id="notification-list-title" className="clay-title-h2">{t("notification.list")}</h2><p className="clay-caption">{t("common.results", { count: notificationsQuery.data?.meta.total || 0 })}</p></div>{notificationsQuery.isFetching && !notificationsQuery.isLoading && <span className="clay-caption animate-pulse">{t("notification.syncing")}</span>}</div>{renderState("notification", notificationsQuery.isLoading, notificationsQuery.isError, notifications.length === 0, () => { void notificationsQuery.refetch(); })}{notifications.map((item) => <NotificationCard key={item.id} notification={item} isProcessing={mutationPending} onOpen={() => openNotification(item)} onMarkRead={() => markRead(item.id)} onDelete={() => setDeleteTarget({ kind: "notification", item })} />)}{notificationPages > 1 && <nav className="flex items-center justify-between gap-3 pt-2" aria-label={t("notification.paginationLabel")}><Button variant="secondary" className="px-4 text-sm" disabled={notificationPage <= 1 || notificationsQuery.isFetching} onClick={() => setNotificationPage((value) => value - 1)}>{t("common.previous")}</Button><span className="font-nunito text-sm font-bold text-clay-text">{t("common.pageOf", { page: notificationPage, total: notificationPages })}</span><Button variant="secondary" className="px-4 text-sm" disabled={notificationPage >= notificationPages || notificationsQuery.isFetching} onClick={() => setNotificationPage((value) => value + 1)}>{t("common.next")}</Button></nav>}</section>
</>} </>}
{tab === "REMINDERS" && <><Card className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2"><Select label={t("reminder.filter.type")} value={reminderType} onChange={(event) => setReminderType(event.target.value as "ALL" | ReminderType)} options={[{ value: "ALL", label: t("reminder.filter.allTypes") }, { value: "GENERAL", label: t("reminder.type.GENERAL") }, { value: "RECURRING_PAYMENT", label: t("reminder.type.RECURRING_PAYMENT") }]} /><Select label={t("reminder.filter.status")} value={activeFilter} onChange={(event) => setActiveFilter(event.target.value as ActiveFilter)} options={[{ value: "ALL", label: t("reminder.filter.allStatuses") }, { value: "ACTIVE", label: t("reminder.active") }, { value: "PAUSED", label: t("reminder.paused") }]} /></Card><section className="flex flex-col gap-3" aria-labelledby="reminder-list-title"><div className="flex items-center justify-between px-1"><div><h2 id="reminder-list-title" className="clay-title-h2">{t("reminder.list")}</h2><p className="clay-caption">{t("common.results", { count: remindersQuery.data?.meta.total || 0 })}</p></div>{remindersQuery.isFetching && !remindersQuery.isLoading && <span className="clay-caption animate-pulse">{t("reminder.syncing")}</span>}</div>{renderState("reminder", remindersQuery.isLoading, remindersQuery.isError, reminders.length === 0, () => { void remindersQuery.refetch(); })}{reminders.map((item) => <ReminderCard key={item.id} reminder={item} t={t} formatDate={formatDate} isProcessing={mutationPending} onEdit={() => { setEditingReminder(item); setIsReminderFormOpen(true); }} onToggle={() => updateReminderMutation.mutate({ id: item.id, input: { isActive: !item.isActive } }, { onSuccess: () => openSnackbar({ type: "success", text: t(item.isActive ? "reminder.pauseSuccess" : "reminder.resumeSuccess") }), onError: (error) => showError(error, "reminder.saveFailed") })} onDelete={() => setDeleteTarget({ kind: "reminder", item })} />)}{reminderPages > 1 && <nav className="flex items-center justify-between gap-3 pt-2" aria-label={t("reminder.paginationLabel")}><Button variant="secondary" className="px-4 text-sm" disabled={reminderPage <= 1 || remindersQuery.isFetching} onClick={() => setReminderPage((value) => value - 1)}>{t("common.previous")}</Button><span className="font-nunito text-sm font-bold text-clay-text">{t("common.pageOf", { page: reminderPage, total: reminderPages })}</span><Button variant="secondary" className="px-4 text-sm" disabled={reminderPage >= reminderPages || remindersQuery.isFetching} onClick={() => setReminderPage((value) => value + 1)}>{t("common.next")}</Button></nav>}</section></>} {tab === "REMINDERS" && <><Card className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2"><Select label={t("reminder.filter.type")} value={reminderType} onChange={(event) => setReminderType(event.target.value as "ALL" | ReminderType)} options={[{ value: "ALL", label: t("reminder.filter.allTypes") }, { value: "GENERAL", label: t("reminder.type.GENERAL") }, { value: "RECURRING_PAYMENT", label: t("reminder.type.RECURRING_PAYMENT") }]} /><Select label={t("reminder.filter.status")} value={activeFilter} onChange={(event) => setActiveFilter(event.target.value as ActiveFilter)} options={[{ value: "ALL", label: t("reminder.filter.allStatuses") }, { value: "ACTIVE", label: t("reminder.active") }, { value: "PAUSED", label: t("reminder.paused") }]} /></Card><DailyTransactionReminderCard reminder={dailyReminderQuery.data} isLoading={dailyReminderQuery.isLoading} isSaving={updateDailyReminderMutation.isPending} onUpdate={(payload) => updateDailyReminderMutation.mutate(payload, { onSuccess: () => openSnackbar({ type: "success", text: t(payload.isActive ? "reminder.daily.saveSuccess" : "reminder.daily.saveDisabled") }), onError: (error) => showError(error, "reminder.saveFailed") })} /><section className="flex flex-col gap-3" aria-labelledby="reminder-list-title"><div className="flex items-center justify-between px-1"><div><h2 id="reminder-list-title" className="clay-title-h2">{t("reminder.list")}</h2><p className="clay-caption">{t("common.results", { count: remindersQuery.data?.meta.total || 0 })}</p></div>{remindersQuery.isFetching && !remindersQuery.isLoading && <span className="clay-caption animate-pulse">{t("reminder.syncing")}</span>}</div>{renderState("reminder", remindersQuery.isLoading, remindersQuery.isError, reminders.length === 0, () => { void remindersQuery.refetch(); })}{reminders.map((item) => <ReminderCard key={item.id} reminder={item} t={t} formatDate={formatDate} isProcessing={mutationPending} onEdit={() => { setEditingReminder(item); setIsReminderFormOpen(true); }} onToggle={() => updateReminderMutation.mutate({ id: item.id, input: { isActive: !item.isActive } }, { onSuccess: () => openSnackbar({ type: "success", text: t(item.isActive ? "reminder.pauseSuccess" : "reminder.resumeSuccess") }), onError: (error) => showError(error, "reminder.saveFailed") })} onDelete={() => setDeleteTarget({ kind: "reminder", item })} />)}{reminderPages > 1 && <nav className="flex items-center justify-between gap-3 pt-2" aria-label={t("reminder.paginationLabel")}><Button variant="secondary" className="px-4 text-sm" disabled={reminderPage <= 1 || remindersQuery.isFetching} onClick={() => setReminderPage((value) => value - 1)}>{t("common.previous")}</Button><span className="font-nunito text-sm font-bold text-clay-text">{t("common.pageOf", { page: reminderPage, total: reminderPages })}</span><Button variant="secondary" className="px-4 text-sm" disabled={reminderPage >= reminderPages || remindersQuery.isFetching} onClick={() => setReminderPage((value) => value + 1)}>{t("common.next")}</Button></nav>}</section></>}
{tab === "SETTINGS" && <>{settingQuery.isLoading && <div className="flex flex-col gap-3"><NotificationSkeleton /><NotificationSkeleton /></div>}{settingQuery.isError && renderState("notification", false, true, false, () => { void settingQuery.refetch(); })}{settingQuery.data?.data && <NotificationSettings setting={settingQuery.data.data} isSaving={updateSettingMutation.isPending} onSave={(setting) => updateSettingMutation.mutate(setting, { onSuccess: () => openSnackbar({ type: "success", text: t("notification.settings.saveSuccess") }), onError: (error) => showError(error, "notification.settings.saveFailed") })} />}</>} {tab === "SETTINGS" && <>{settingQuery.isLoading && <div className="flex flex-col gap-3"><NotificationSkeleton /><NotificationSkeleton /></div>}{settingQuery.isError && renderState("notification", false, true, false, () => { void settingQuery.refetch(); })}{settingQuery.data?.data && <NotificationSettings setting={settingQuery.data.data} isSaving={updateSettingMutation.isPending} onSave={(setting) => updateSettingMutation.mutate(setting, { onSuccess: () => openSnackbar({ type: "success", text: t("notification.settings.saveSuccess") }), onError: (error) => showError(error, "notification.settings.saveFailed") })} />}</>}
</main> </main>
......
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { import {
CreateReminderInput, CreateReminderInput,
DailyTransactionReminderResponse,
ReminderDeleteResponse, ReminderDeleteResponse,
ReminderListResponse, ReminderListResponse,
ReminderQuery, ReminderQuery,
ReminderResponse, ReminderResponse,
UpdateDailyTransactionReminderInput,
UpdateReminderInput, UpdateReminderInput,
} from "@/types/reminder"; } from "@/types/reminder";
...@@ -30,5 +32,16 @@ export const reminderService = { ...@@ -30,5 +32,16 @@ export const reminderService = {
const response = await apiClient.delete<ReminderDeleteResponse>(`/reminders/${id}`); const response = await apiClient.delete<ReminderDeleteResponse>(`/reminders/${id}`);
return ensureSuccess(response.data); return ensureSuccess(response.data);
}, },
// ─── Daily Transaction Reminder ─────────────────────────────────────────
async getDailyTransactionReminder(): Promise<DailyTransactionReminderResponse> {
const response = await apiClient.get<DailyTransactionReminderResponse>("/reminders/daily-transaction");
return ensureSuccess(response.data);
},
async updateDailyTransactionReminder(input: UpdateDailyTransactionReminderInput): Promise<DailyTransactionReminderResponse> {
const response = await apiClient.put<DailyTransactionReminderResponse>("/reminders/daily-transaction", input);
return ensureSuccess(response.data);
},
}; };
...@@ -65,3 +65,28 @@ export interface ReminderDeleteResponse { ...@@ -65,3 +65,28 @@ export interface ReminderDeleteResponse {
errors?: unknown[] | null; errors?: unknown[] | null;
} }
// ─── Daily Transaction Reminder ──────────────────────────────────────────────
export interface DailyTransactionReminder {
id: string | null;
isActive: boolean;
time: string; // HH:mm format, e.g. "20:00"
title: string;
message: string;
nextTriggerAt: string | null;
hasCreatedTransactionToday: boolean;
}
export interface UpdateDailyTransactionReminderInput {
isActive: boolean;
time?: string; // HH:mm format
}
export interface DailyTransactionReminderResponse {
success: boolean;
message?: string;
data: DailyTransactionReminder;
errors?: unknown[] | null;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment