Commit 9a70ebeb authored by ThinhNC's avatar ThinhNC

feat(notifications): display recurring transaction due date and navigate to details modal

parent 43bf8bad
...@@ -12,6 +12,7 @@ export const recurringTransactionKeys = { ...@@ -12,6 +12,7 @@ export const recurringTransactionKeys = {
all: ["recurring-transactions"] as const, all: ["recurring-transactions"] as const,
lists: () => [...recurringTransactionKeys.all, "list"] as const, lists: () => [...recurringTransactionKeys.all, "list"] as const,
list: (query: RecurringTransactionQuery) => [...recurringTransactionKeys.lists(), query] as const, list: (query: RecurringTransactionQuery) => [...recurringTransactionKeys.lists(), query] as const,
detail: (id: string) => [...recurringTransactionKeys.all, id] as const,
preview: (id: string) => [...recurringTransactionKeys.all, id, "preview"] as const, preview: (id: string) => [...recurringTransactionKeys.all, id, "preview"] as const,
history: (id: string) => [...recurringTransactionKeys.all, id, "history"] as const, history: (id: string) => [...recurringTransactionKeys.all, id, "history"] as const,
}; };
...@@ -25,6 +26,14 @@ export function useRecurringTransactions(query: RecurringTransactionQuery) { ...@@ -25,6 +26,14 @@ export function useRecurringTransactions(query: RecurringTransactionQuery) {
}); });
} }
export function useRecurringTransaction(id: string, enabled = true) {
return useQuery({
queryKey: recurringTransactionKeys.detail(id),
queryFn: () => recurringTransactionService.getById(id),
enabled: enabled && Boolean(id),
});
}
export function useRecurringPreview(id: string, enabled: boolean) { export function useRecurringPreview(id: string, enabled: boolean) {
return useQuery({ return useQuery({
queryKey: recurringTransactionKeys.preview(id), queryKey: recurringTransactionKeys.preview(id),
......
...@@ -1272,8 +1272,11 @@ ...@@ -1272,8 +1272,11 @@
"goalDueMessage": "The target date is {{date}}.", "goalDueMessage": "The target date is {{date}}.",
"unusualTitle": "Unusual transaction detected", "unusualTitle": "Unusual transaction detected",
"unusualMessage": "This expense is significantly higher than your recent spending in the same wallet.", "unusualMessage": "This expense is significantly higher than your recent spending in the same wallet.",
"reminderFallback": "A scheduled financial reminder is due." "reminderFallback": "A scheduled financial reminder is due.",
"recurringDueMessage": "Recurring transaction is due on {{date}}.",
"recurringDueMessageWithName": "Recurring transaction ({{name}}) is due on {{date}}."
}, },
"dueDateLabel": "Due date",
"settings": { "settings": {
"alertTitle": "Alert types", "alertTitle": "Alert types",
"alertHint": "Choose which financial signals FinWise should monitor.", "alertHint": "Choose which financial signals FinWise should monitor.",
......
...@@ -1319,8 +1319,11 @@ ...@@ -1319,8 +1319,11 @@
"goalDueMessage": "Ngày mục tiêu là {{date}}.", "goalDueMessage": "Ngày mục tiêu là {{date}}.",
"unusualTitle": "Phát hiện giao dịch bất thường", "unusualTitle": "Phát hiện giao dịch bất thường",
"unusualMessage": "Khoản chi này cao hơn đáng kể so với lịch sử chi tiêu gần đây trong cùng ví.", "unusualMessage": "Khoản chi này cao hơn đáng kể so với lịch sử chi tiêu gần đây trong cùng ví.",
"reminderFallback": "Một lịch nhắc tài chính đã đến hạn." "reminderFallback": "Một lịch nhắc tài chính đã đến hạn.",
"recurringDueMessage": "Giao dịch định kỳ sắp đến hạn thực hiện vào ngày {{date}}.",
"recurringDueMessageWithName": "Giao dịch định kỳ ({{name}}) sắp đến hạn thực hiện vào ngày {{date}}."
}, },
"dueDateLabel": "Ngày đến hạn",
"settings": { "settings": {
"alertTitle": "Loại cảnh báo", "alertTitle": "Loại cảnh báo",
"alertHint": "Chọn những tín hiệu tài chính bạn muốn FinWise theo dõi.", "alertHint": "Chọn những tín hiệu tài chính bạn muốn FinWise theo dõi.",
......
...@@ -6,6 +6,7 @@ export interface FinWiseNavigationState { ...@@ -6,6 +6,7 @@ export interface FinWiseNavigationState {
fromAIRecommendations?: boolean; fromAIRecommendations?: boolean;
fromReports?: boolean; fromReports?: boolean;
transactionId?: string; transactionId?: string;
scheduleId?: string;
transactionTypeFilter?: TransactionType; transactionTypeFilter?: TransactionType;
categoryIdFilter?: string; categoryIdFilter?: string;
walletIdFilter?: string; walletIdFilter?: string;
...@@ -43,6 +44,12 @@ export function getTransactionId(state: unknown): string | undefined { ...@@ -43,6 +44,12 @@ export function getTransactionId(state: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined; return typeof value === "string" && value.trim() ? value : undefined;
} }
export function getScheduleId(state: unknown): string | undefined {
if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).scheduleId;
return typeof value === "string" && value.trim() ? value : undefined;
}
export function getCategoryIdFilter(state: unknown): string | undefined { export function getCategoryIdFilter(state: unknown): string | undefined {
if (!state || typeof state !== "object") return undefined; if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).categoryIdFilter; const value = (state as FinWiseNavigationState).categoryIdFilter;
......
import { TranslationFunction } from "@/i18n"; import { TranslationFunction } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time"; import { addCalendarDays, formatBusinessDate, instantToBusinessDate } from "@/lib/business-time";
import { NotificationItem, NotificationType } from "@/types/notification"; import { NotificationItem, NotificationType } from "@/types/notification";
interface NotificationFormatter { interface NotificationFormatter {
...@@ -46,6 +46,36 @@ function extractBusinessDate(message: string): string | undefined { ...@@ -46,6 +46,36 @@ function extractBusinessDate(message: string): string | undefined {
return message.match(/\b\d{4}-\d{2}-\d{2}\b/)?.[0]; return message.match(/\b\d{4}-\d{2}-\d{2}\b/)?.[0];
} }
export function extractNotificationDueDate(notification: NotificationItem): string | undefined {
// 1. Explicit dueDate in data
if (typeof notification.data?.dueDate === "string" && notification.data.dueDate.trim()) {
return notification.data.dueDate.trim();
}
// 2. dueDate query param in actionUrl
const urlDueDate = notification.actionUrl?.match(/[?&]dueDate=([^&#]+)/)?.[1];
if (urlDueDate) return urlDueDate;
// 3. Business date directly in message (e.g. "vào ngày YYYY-MM-DD")
const msgDate = extractBusinessDate(notification.message);
if (msgDate) return msgDate;
// 4. Compute from remindDaysBefore in actionUrl + notification trigger/creation time
const remindDaysMatch = notification.actionUrl?.match(/[?&]remindDaysBefore=(\d+)/);
if (remindDaysMatch && notification.type === "RECURRING_PAYMENT_DUE") {
const days = parseInt(remindDaysMatch[1], 10);
const baseDate = notification.data?.scheduledAt
? String(notification.data.scheduledAt)
: notification.createdAt;
if (baseDate) {
const baseBusiness = instantToBusinessDate(baseDate);
return addCalendarDays(baseBusiness, days);
}
}
return undefined;
}
export function formatNotificationContent( export function formatNotificationContent(
notification: NotificationItem, notification: NotificationItem,
formatter: NotificationFormatter, formatter: NotificationFormatter,
...@@ -106,6 +136,25 @@ export function formatNotificationContent( ...@@ -106,6 +136,25 @@ export function formatNotificationContent(
}; };
} }
if (notification.type === "RECURRING_PAYMENT_DUE") {
const dueDate = extractNotificationDueDate(notification);
if (dueDate) {
const formattedDueDate = formatBusinessDate(dueDate, formatter.intlLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const nameMatch = notification.title.match(/\((.+)\)/);
const recurringName = nameMatch ? nameMatch[1] : undefined;
return {
title: notification.title,
message: recurringName
? formatter.t("notification.content.recurringDueMessageWithName", { name: recurringName, date: formattedDueDate })
: formatter.t("notification.content.recurringDueMessage", { date: formattedDueDate }),
};
}
}
if ( if (
(notification.type === "USER_REMINDER" || notification.type === "RECURRING_PAYMENT_DUE") (notification.type === "USER_REMINDER" || notification.type === "RECURRING_PAYMENT_DUE")
&& notification.message === "A scheduled reminder is due." && notification.message === "A scheduled reminder is due."
......
...@@ -94,6 +94,8 @@ const NotificationsPage: React.FC = () => { ...@@ -94,6 +94,8 @@ const NotificationsPage: React.FC = () => {
if (/^https:\/\//i.test(item.actionUrl)) window.open(item.actionUrl, "_blank", "noopener,noreferrer"); if (/^https:\/\//i.test(item.actionUrl)) window.open(item.actionUrl, "_blank", "noopener,noreferrer");
else { else {
const transactionPathMatch = item.actionUrl.match(/^\/transactions\/([^/?#]+)/); const transactionPathMatch = item.actionUrl.match(/^\/transactions\/([^/?#]+)/);
const recurringIdMatch = item.actionUrl.match(/[?&]id=([^/?#&]+)/);
const dataTransactionId = item.data?.transactionId; const dataTransactionId = item.data?.transactionId;
const transactionId = typeof dataTransactionId === "string" const transactionId = typeof dataTransactionId === "string"
? dataTransactionId ? dataTransactionId
...@@ -101,9 +103,20 @@ const NotificationsPage: React.FC = () => { ...@@ -101,9 +103,20 @@ const NotificationsPage: React.FC = () => {
? item.sourceId ? item.sourceId
: transactionPathMatch?.[1]; : transactionPathMatch?.[1];
const dataScheduleId = item.data?.scheduleId;
const scheduleId = typeof dataScheduleId === "string"
? dataScheduleId
: recurringIdMatch?.[1];
navigate( navigate(
transactionPathMatch ? "/transactions" : item.actionUrl, transactionPathMatch ? "/transactions" : item.actionUrl,
{ state: { fromNotifications: true, ...(transactionId ? { transactionId } : {}) } }, {
state: {
fromNotifications: true,
...(transactionId ? { transactionId } : {}),
...(scheduleId ? { scheduleId } : {}),
},
},
); );
} }
}; };
......
...@@ -5,6 +5,7 @@ import { Modal } from "@/components/ui/Modal"; ...@@ -5,6 +5,7 @@ import { Modal } from "@/components/ui/Modal";
import { useRecurringHistory, useRecurringPreview } from "@/hooks/use-recurring-transactions"; import { useRecurringHistory, useRecurringPreview } from "@/hooks/use-recurring-transactions";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time"; import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format";
import { RecurringTransactionSchedule } from "@/types/recurring-transaction"; import { RecurringTransactionSchedule } from "@/types/recurring-transaction";
interface Props { interface Props {
...@@ -13,7 +14,7 @@ interface Props { ...@@ -13,7 +14,7 @@ interface Props {
} }
export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, onClose }) => { export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, onClose }) => {
const { t, intlLocale } = useI18n(); const { t, formatCurrency, intlLocale } = useI18n();
const id = schedule?.id || ""; const id = schedule?.id || "";
const preview = useRecurringPreview(id, Boolean(schedule)); const preview = useRecurringPreview(id, Boolean(schedule));
const history = useRecurringHistory(id, Boolean(schedule)); const history = useRecurringHistory(id, Boolean(schedule));
...@@ -25,29 +26,62 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on ...@@ -25,29 +26,62 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on
<Modal isOpen={Boolean(schedule)} onClose={onClose} title={t("recurringTransactions.detailsTitle")}> <Modal isOpen={Boolean(schedule)} onClose={onClose} title={t("recurringTransactions.detailsTitle")}>
<div className="space-y-5"> <div className="space-y-5">
{schedule && ( {schedule && (
<div className="rounded-clay-sm bg-clay-bg p-3 text-xs shadow-clay-pressed space-y-1.5"> <div className="space-y-3 rounded-clay-md bg-clay-surface p-3.5 shadow-clay-card border border-clay-border/40">
<div className="flex justify-between"> <div className="flex items-start justify-between gap-3">
<span className="text-clay-text-muted">{t("recurringTransactions.cycle")}:</span> <div>
<span className="font-bold text-clay-text">{t(`recurringTransactions.frequency.${schedule.frequency}`)} × {schedule.repeatInterval}</span> <div className="flex flex-wrap items-center gap-2">
<h3 className="font-baloo text-base font-bold text-clay-text">
{schedule.description || getCategoryDisplayName(schedule.category, t)}
</h3>
<Badge type={schedule.isActive ? "income" : "info"}>
{schedule.isActive ? t("recurringTransactions.active") : t("recurringTransactions.paused")}
</Badge>
</div>
<p className="mt-1 text-xs text-clay-text-muted">
{schedule.wallet?.name}{getCategoryDisplayName(schedule.category, t)}
{schedule.location ? ` • ${schedule.location}` : ""}
</p>
</div>
<p className={`font-baloo text-lg font-bold ${schedule.type === "INCOME" ? "text-clay-income" : "text-clay-expense"}`}>
{formatCurrency(Number(schedule.amount), schedule.wallet?.currency || "VND")}
</p>
</div>
<div className="grid grid-cols-2 gap-2 rounded-clay-sm bg-clay-bg p-3 text-xs shadow-clay-pressed">
<div>
<span className="block text-clay-text-muted">{t("recurringTransactions.cycle")}</span>
<b className="text-clay-text">
{t(`recurringTransactions.frequency.${schedule.frequency}`)} × {schedule.repeatInterval}
</b>
</div> </div>
{schedule.remindDaysBefore !== null && schedule.remindDaysBefore !== undefined ? ( <div>
<div className="flex justify-between items-center"> <span className="block text-clay-text-muted">{t("recurringTransactions.nextRun")}</span>
<span className="text-clay-text-muted flex items-center gap-1">🔔 {t("recurringTransactions.reminder")}:</span> <b className="text-clay-text">
<span className="font-bold text-clay-primary"> {schedule.nextRunAt ? formatDate(schedule.nextRunAt) : "—"}
{schedule.remindDaysBefore === 0 </b>
</div>
<div>
<span className="block text-clay-text-muted">{t("recurringTransactions.form.anchorDate")}</span>
<b className="text-clay-text">{formatDate(schedule.anchorDate)}</b>
</div>
<div>
<span className="block text-clay-text-muted">{t("recurringTransactions.reminder")}</span>
<b className="text-clay-primary">
{schedule.remindDaysBefore !== null && schedule.remindDaysBefore !== undefined
? (schedule.remindDaysBefore === 0
? t("recurringTransactions.reminderBadgeSameDay") ? t("recurringTransactions.reminderBadgeSameDay")
: t("recurringTransactions.reminderBadge", { days: schedule.remindDaysBefore })} : t("recurringTransactions.reminderBadge", { days: schedule.remindDaysBefore }))
</span> : t("recurringTransactions.reminderOff")}
</b>
</div> </div>
) : ( {schedule.endDate && (
<div className="flex justify-between items-center"> <div className="col-span-2">
<span className="text-clay-text-muted flex items-center gap-1">🔕 {t("recurringTransactions.reminder")}:</span> <span className="block text-clay-text-muted">{t("recurringTransactions.form.endDate")}</span>
<span className="font-medium text-clay-text-muted"> <b className="text-clay-text">{formatDate(schedule.endDate)}</b>
{t("recurringTransactions.reminderOff")}
</span>
</div> </div>
)} )}
</div> </div>
</div>
)} )}
<section className="space-y-2"> <section className="space-y-2">
......
import React, { useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { Header, Page, useSnackbar } from "zmp-ui"; import { Header, Page, useLocation, useSnackbar } from "zmp-ui";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
useCreateRecurringTransaction, useCreateRecurringTransaction,
useDeleteRecurringTransaction, useDeleteRecurringTransaction,
usePauseRecurringTransaction, usePauseRecurringTransaction,
useRecurringTransaction,
useRecurringTransactions, useRecurringTransactions,
useResumeRecurringTransaction, useResumeRecurringTransaction,
useUpdateRecurringTransaction, useUpdateRecurringTransaction,
...@@ -20,6 +21,7 @@ import { useI18n } from "@/i18n"; ...@@ -20,6 +21,7 @@ import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time"; import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format"; import { getCategoryDisplayName } from "@/lib/category-format";
import { normalizeAmountInput } from "@/lib/money-input"; import { normalizeAmountInput } from "@/lib/money-input";
import { getScheduleId } from "@/lib/navigation-state";
import { import {
RecurringTransactionInput, RecurringTransactionInput,
RecurringTransactionSchedule, RecurringTransactionSchedule,
...@@ -29,6 +31,7 @@ import { RecurringTransactionDetailsModal } from "./components/RecurringTransact ...@@ -29,6 +31,7 @@ import { RecurringTransactionDetailsModal } from "./components/RecurringTransact
import { RecurringTransactionFormModal } from "./components/RecurringTransactionFormModal"; import { RecurringTransactionFormModal } from "./components/RecurringTransactionFormModal";
const RecurringTransactionsPage: React.FC = () => { const RecurringTransactionsPage: React.FC = () => {
const location = useLocation();
const { t, formatCurrency, intlLocale } = useI18n(); const { t, formatCurrency, intlLocale } = useI18n();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
...@@ -54,6 +57,45 @@ const RecurringTransactionsPage: React.FC = () => { ...@@ -54,6 +57,45 @@ const RecurringTransactionsPage: React.FC = () => {
const schedules = query.data?.data || []; const schedules = query.data?.data || [];
const isSaving = createMutation.isPending || updateMutation.isPending; const isSaving = createMutation.isPending || updateMutation.isPending;
const stateScheduleId = getScheduleId(location.state);
const searchParams = new URLSearchParams(
location.search || (typeof window !== "undefined" ? window.location.search : "")
);
const urlScheduleId = searchParams.get("id");
const targetScheduleId = stateScheduleId || urlScheduleId || undefined;
const openedScheduleRef = useRef<string>();
// Fetch individual schedule if targetScheduleId is specified but not in current list
const shouldFetchSingle = Boolean(
targetScheduleId &&
!query.isLoading &&
!schedules.some((s) => s.id === targetScheduleId)
);
const singleScheduleQuery = useRecurringTransaction(targetScheduleId || "", shouldFetchSingle);
// Auto-open details modal when targetScheduleId matches a schedule in the list
useEffect(() => {
if (!targetScheduleId || openedScheduleRef.current === targetScheduleId) return;
const found = schedules.find((s) => s.id === targetScheduleId);
if (found) {
openedScheduleRef.current = targetScheduleId;
setDetails(found);
}
}, [schedules, targetScheduleId]);
// Auto-open details modal if loaded via singleScheduleQuery
useEffect(() => {
if (!targetScheduleId || openedScheduleRef.current === targetScheduleId) return;
const schedule = singleScheduleQuery.data?.data;
if (schedule && schedule.id === targetScheduleId) {
openedScheduleRef.current = targetScheduleId;
setDetails(schedule);
}
}, [singleScheduleQuery.data?.data, targetScheduleId]);
const formatDate = (value: string) => formatBusinessDate(value, intlLocale, { const formatDate = (value: string) => formatBusinessDate(value, intlLocale, {
day: "2-digit", month: "2-digit", year: "numeric", day: "2-digit", month: "2-digit", year: "numeric",
}); });
......
...@@ -21,6 +21,11 @@ export const recurringTransactionService = { ...@@ -21,6 +21,11 @@ export const recurringTransactionService = {
return ensureSuccess(response.data); return ensureSuccess(response.data);
}, },
async getById(id: string): Promise<RecurringTransactionResponse<RecurringTransactionSchedule>> {
const response = await apiClient.get<RecurringTransactionResponse<RecurringTransactionSchedule>>(`/recurring-transactions/${id}`);
return ensureSuccess(response.data);
},
async create(input: RecurringTransactionInput): Promise<RecurringTransactionResponse<RecurringTransactionSchedule>> { async create(input: RecurringTransactionInput): Promise<RecurringTransactionResponse<RecurringTransactionSchedule>> {
const response = await apiClient.post<RecurringTransactionResponse<RecurringTransactionSchedule>>("/recurring-transactions", input); const response = await apiClient.post<RecurringTransactionResponse<RecurringTransactionSchedule>>("/recurring-transactions", input);
return ensureSuccess(response.data); return ensureSuccess(response.data);
......
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