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 = {
all: ["recurring-transactions"] as const,
lists: () => [...recurringTransactionKeys.all, "list"] 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,
history: (id: string) => [...recurringTransactionKeys.all, id, "history"] as const,
};
......@@ -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) {
return useQuery({
queryKey: recurringTransactionKeys.preview(id),
......
......@@ -1272,8 +1272,11 @@
"goalDueMessage": "The target date is {{date}}.",
"unusualTitle": "Unusual transaction detected",
"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": {
"alertTitle": "Alert types",
"alertHint": "Choose which financial signals FinWise should monitor.",
......
......@@ -1319,8 +1319,11 @@
"goalDueMessage": "Ngày mục tiêu là {{date}}.",
"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í.",
"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": {
"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.",
......
......@@ -6,6 +6,7 @@ export interface FinWiseNavigationState {
fromAIRecommendations?: boolean;
fromReports?: boolean;
transactionId?: string;
scheduleId?: string;
transactionTypeFilter?: TransactionType;
categoryIdFilter?: string;
walletIdFilter?: string;
......@@ -43,6 +44,12 @@ export function getTransactionId(state: unknown): string | 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 {
if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).categoryIdFilter;
......
import { TranslationFunction } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { addCalendarDays, formatBusinessDate, instantToBusinessDate } from "@/lib/business-time";
import { NotificationItem, NotificationType } from "@/types/notification";
interface NotificationFormatter {
......@@ -46,6 +46,36 @@ function extractBusinessDate(message: string): string | undefined {
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(
notification: NotificationItem,
formatter: NotificationFormatter,
......@@ -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 (
(notification.type === "USER_REMINDER" || notification.type === "RECURRING_PAYMENT_DUE")
&& notification.message === "A scheduled reminder is due."
......
......@@ -94,6 +94,8 @@ const NotificationsPage: React.FC = () => {
if (/^https:\/\//i.test(item.actionUrl)) window.open(item.actionUrl, "_blank", "noopener,noreferrer");
else {
const transactionPathMatch = item.actionUrl.match(/^\/transactions\/([^/?#]+)/);
const recurringIdMatch = item.actionUrl.match(/[?&]id=([^/?#&]+)/);
const dataTransactionId = item.data?.transactionId;
const transactionId = typeof dataTransactionId === "string"
? dataTransactionId
......@@ -101,9 +103,20 @@ const NotificationsPage: React.FC = () => {
? item.sourceId
: transactionPathMatch?.[1];
const dataScheduleId = item.data?.scheduleId;
const scheduleId = typeof dataScheduleId === "string"
? dataScheduleId
: recurringIdMatch?.[1];
navigate(
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";
import { useRecurringHistory, useRecurringPreview } from "@/hooks/use-recurring-transactions";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format";
import { RecurringTransactionSchedule } from "@/types/recurring-transaction";
interface Props {
......@@ -13,7 +14,7 @@ interface Props {
}
export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, onClose }) => {
const { t, intlLocale } = useI18n();
const { t, formatCurrency, intlLocale } = useI18n();
const id = schedule?.id || "";
const preview = useRecurringPreview(id, Boolean(schedule));
const history = useRecurringHistory(id, Boolean(schedule));
......@@ -25,28 +26,61 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on
<Modal isOpen={Boolean(schedule)} onClose={onClose} title={t("recurringTransactions.detailsTitle")}>
<div className="space-y-5">
{schedule && (
<div className="rounded-clay-sm bg-clay-bg p-3 text-xs shadow-clay-pressed space-y-1.5">
<div className="flex justify-between">
<span className="text-clay-text-muted">{t("recurringTransactions.cycle")}:</span>
<span className="font-bold text-clay-text">{t(`recurringTransactions.frequency.${schedule.frequency}`)} × {schedule.repeatInterval}</span>
<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 items-start justify-between gap-3">
<div>
<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>
{schedule.remindDaysBefore !== null && schedule.remindDaysBefore !== undefined ? (
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1">🔔 {t("recurringTransactions.reminder")}:</span>
<span className="font-bold text-clay-primary">
{schedule.remindDaysBefore === 0
? t("recurringTransactions.reminderBadgeSameDay")
: t("recurringTransactions.reminderBadge", { days: schedule.remindDaysBefore })}
</span>
<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>
<span className="block text-clay-text-muted">{t("recurringTransactions.nextRun")}</span>
<b className="text-clay-text">
{schedule.nextRunAt ? formatDate(schedule.nextRunAt) : "—"}
</b>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1">🔕 {t("recurringTransactions.reminder")}:</span>
<span className="font-medium text-clay-text-muted">
{t("recurringTransactions.reminderOff")}
</span>
<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.reminderBadge", { days: schedule.remindDaysBefore }))
: t("recurringTransactions.reminderOff")}
</b>
</div>
{schedule.endDate && (
<div className="col-span-2">
<span className="block text-clay-text-muted">{t("recurringTransactions.form.endDate")}</span>
<b className="text-clay-text">{formatDate(schedule.endDate)}</b>
</div>
)}
</div>
</div>
)}
......
import React, { useState } from "react";
import { Header, Page, useSnackbar } from "zmp-ui";
import React, { useEffect, useRef, useState } from "react";
import { Header, Page, useLocation, useSnackbar } from "zmp-ui";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
......@@ -10,6 +10,7 @@ import {
useCreateRecurringTransaction,
useDeleteRecurringTransaction,
usePauseRecurringTransaction,
useRecurringTransaction,
useRecurringTransactions,
useResumeRecurringTransaction,
useUpdateRecurringTransaction,
......@@ -20,6 +21,7 @@ import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format";
import { normalizeAmountInput } from "@/lib/money-input";
import { getScheduleId } from "@/lib/navigation-state";
import {
RecurringTransactionInput,
RecurringTransactionSchedule,
......@@ -29,6 +31,7 @@ import { RecurringTransactionDetailsModal } from "./components/RecurringTransact
import { RecurringTransactionFormModal } from "./components/RecurringTransactionFormModal";
const RecurringTransactionsPage: React.FC = () => {
const location = useLocation();
const { t, formatCurrency, intlLocale } = useI18n();
const { openSnackbar } = useSnackbar();
......@@ -54,6 +57,45 @@ const RecurringTransactionsPage: React.FC = () => {
const schedules = query.data?.data || [];
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, {
day: "2-digit", month: "2-digit", year: "numeric",
});
......
......@@ -21,6 +21,11 @@ export const recurringTransactionService = {
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>> {
const response = await apiClient.post<RecurringTransactionResponse<RecurringTransactionSchedule>>("/recurring-transactions", input);
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