Commit cae2a4f9 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/recurring-reminders-and-home-alert-badges' into 'develop'

feat(fe): merge subscriptions into recurring transactions, add reminder...

See merge request !42
parents 41153c1d 9ccf6885
...@@ -35,10 +35,10 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -35,10 +35,10 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Thông báo & nhắc nhở**: Trang `/notifications` là trung tâm chung cho inbox thông báo, lịch nhắc và tùy chọn cảnh báo. Unread count dùng query key riêng và được hiển thị bằng badge chuông toàn cục sau đăng nhập. Cơ chế short polling 30 giây đã được thay thế hoàn toàn bằng Server-Sent Events (SSE) qua hook `useNotificationSSE` (kết nối tới `/notifications/stream` với token/cookie) kết hợp `NotificationRealtimeListener` trong `Layout`; khi có thông báo mới hoặc unread count thay đổi, server tự động đẩy event tới client để cập nhật badge và hiển thị popup tức thì; refetch khi focus và mọi mutation invalidate namespace liên quan. Reminder datetime luôn dùng Vietnamese wall time rồi chuyển sang UTC ISO bằng helper business-time. Với cảnh báo tự động do Backend hiện tạo bằng tiếng Anh, frontend nhận diện đúng mẫu theo `type`, dùng metadata để dựng title/message theo locale; nội dung người dùng nhập, legacy không khớp mẫu và AI tương lai được giữ nguyên. Notification `data` giữ kiểu mở `Record<string, unknown>` để tiếp nhận metadata cảnh báo/AI mà UI không phụ thuộc cấu trúc riêng. Điều hướng từ nút xem chi tiết gắn `fromNotifications` trong route state; các trang đích hệ thống dùng history back khi có marker này và giữ fallback back cố định cho luồng truy cập trực tiếp. Thông báo giao dịch bất thường phải truyền `transactionId` (fallback lần lượt từ `data`, `sourceId`, URL) để trang Giao dịch gọi chi tiết và tự mở đúng giao dịch, không chỉ chuyển tới danh sách chung. - **Thông báo & nhắc nhở**: Trang `/notifications` là trung tâm chung cho inbox thông báo, lịch nhắc và tùy chọn cảnh báo. Unread count dùng query key riêng và được hiển thị bằng badge chuông toàn cục sau đăng nhập. Cơ chế short polling 30 giây đã được thay thế hoàn toàn bằng Server-Sent Events (SSE) qua hook `useNotificationSSE` (kết nối tới `/notifications/stream` với token/cookie) kết hợp `NotificationRealtimeListener` trong `Layout`; khi có thông báo mới hoặc unread count thay đổi, server tự động đẩy event tới client để cập nhật badge và hiển thị popup tức thì; refetch khi focus và mọi mutation invalidate namespace liên quan. Reminder datetime luôn dùng Vietnamese wall time rồi chuyển sang UTC ISO bằng helper business-time. Với cảnh báo tự động do Backend hiện tạo bằng tiếng Anh, frontend nhận diện đúng mẫu theo `type`, dùng metadata để dựng title/message theo locale; nội dung người dùng nhập, legacy không khớp mẫu và AI tương lai được giữ nguyên. Notification `data` giữ kiểu mở `Record<string, unknown>` để tiếp nhận metadata cảnh báo/AI mà UI không phụ thuộc cấu trúc riêng. Điều hướng từ nút xem chi tiết gắn `fromNotifications` trong route state; các trang đích hệ thống dùng history back khi có marker này và giữ fallback back cố định cho luồng truy cập trực tiếp. Thông báo giao dịch bất thường phải truyền `transactionId` (fallback lần lượt từ `data`, `sourceId`, URL) để trang Giao dịch gọi chi tiết và tự mở đúng giao dịch, không chỉ chuyển tới danh sách chung.
- **Giao dịch tự động định kỳ**: Trang `/recurring-transactions` quản lý lịch thu/chi tự động qua - **Giao dịch tự động định kỳ**: Trang `/recurring-transactions` quản lý lịch thu/chi tự động qua
TanStack Query, gồm tạo/sửa, pause/resume, xóa mềm, preview ngày chạy và lịch sử occurrence. TanStack Query, gồm tạo/sửa, pause/resume, xóa mềm, preview ngày chạy và lịch sử occurrence.
Trên trang chủ, đây là một mục thuộc **Danh mục Nghiệp vụ**, đặt cạnh giao dịch thường; không xếp trong Hỗ trợ cài đặt thông báo nhắc trước hạn thanh toán (`remindDaysBefore`: cùng ngày, 1, 2, 3, 5, 7, 14 ngày)
**Tính năng Nâng cao** dù được phát triển trong Upgrade 6. ngay trong form tạo/sửa; hiển thị huy hiệu nhắc nhở trực quan trên từng thẻ lịch và modal chi tiết.
Màn subscription yêu cầu người dùng chọn ví và xác nhận rõ trước khi chuyển một gợi ý thành lịch Module Subscription Discovery được sáp nhập trực tiếp vào `/recurring-transactions`: trang hiển thị khối gợi ý các gói cước/khoản phí định kỳ phát hiện từ lịch sử 180 ngày (kèm thẻ thông tin hướng dẫn khi chưa có gợi ý); người dùng có thể chọn ví và đưa thẳng vào lịch tự động. Route cũ `/subscriptions` tự động chuyển hướng sang `/recurring-transactions`, nút riêng trên Trang chủ được gỡ bỏ để tránh phân mảnh tính năng.
tự động; UI luôn nêu rõ tính năng chỉ ghi sổ FinWise, không thực hiện thanh toán ngân hàng. UI luôn nêu rõ tính năng chỉ ghi sổ FinWise, không thực hiện thanh toán ngân hàng.
- **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định. - **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định.
- **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất. - **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất.
- **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend). - **Cấu hình API**: Base URL mặc định là `http://localhost:7777/api/v1` (tương tác trực tiếp với port 7777 của Backend).
...@@ -78,7 +78,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -78,7 +78,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Module dự báo dòng tiền và ngân sách (Upgrade 1) đã có frontend tại `/forecast`, kết nối Forecast API (`/forecast/runway`, `/forecast/budget-depletion`) qua TanStack Query; gồm chọn khoảng dự báo (14, 30, 60, 90 ngày), số dư dự kiến cuối khoảng và chênh lệch so với hiện tại, thước đo số ngày an toàn tài chính (runway) với exponential burn velocity ($\lambda=0.04$), phân tích nguy cơ vỡ ngân sách, hạn mức chi tối đa mỗi ngày, Claymorphism skeleton loading, error/retry state, và bản dịch Việt/Anh. - Module dự báo dòng tiền và ngân sách (Upgrade 1) đã có frontend tại `/forecast`, kết nối Forecast API (`/forecast/runway`, `/forecast/budget-depletion`) qua TanStack Query; gồm chọn khoảng dự báo (14, 30, 60, 90 ngày), số dư dự kiến cuối khoảng và chênh lệch so với hiện tại, thước đo số ngày an toàn tài chính (runway) với exponential burn velocity ($\lambda=0.04$), phân tích nguy cơ vỡ ngân sách, hạn mức chi tối đa mỗi ngày, Claymorphism skeleton loading, error/retry state, và bản dịch Việt/Anh.
- Module mô phỏng kịch bản tài chính What-if (Upgrade 2) đã có frontend tại `/simulations`, kết nối Simulation API (`/simulations/presets`, `/simulations/run`) qua TanStack Query; gồm áp dụng kịch bản mẫu có sẵn, thanh trượt thời gian 3-36 tháng đúng contract Backend, cấu hình biến động dòng tiền tùy chỉnh (tăng/giảm thu nhập, chi tiêu), bảng so sánh số dư baseline/simulated, cảnh báo nguy cơ thâm hụt, tác động đến mục tiêu tiết kiệm, diễn biến trajectory từng tháng, skeleton loading và bản dịch Việt/Anh. - Module mô phỏng kịch bản tài chính What-if (Upgrade 2) đã có frontend tại `/simulations`, kết nối Simulation API (`/simulations/presets`, `/simulations/run`) qua TanStack Query; gồm áp dụng kịch bản mẫu có sẵn, thanh trượt thời gian 3-36 tháng đúng contract Backend, cấu hình biến động dòng tiền tùy chỉnh (tăng/giảm thu nhập, chi tiêu), bảng so sánh số dư baseline/simulated, cảnh báo nguy cơ thâm hụt, tác động đến mục tiêu tiết kiệm, diễn biến trajectory từng tháng, skeleton loading và bản dịch Việt/Anh.
- Module phát hiện chi tiêu bất thường (Upgrade 3) đã có frontend tại `/anomalies`, kết nối Anomaly API (`/anomalies/recent`, `/anomalies/evaluate`) qua TanStack Query; gồm công cụ kiểm tra nhanh giao dịch trước khi chi (Pre-Purchase Evaluator) dựa trên Modified Z-Score & MAD, danh sách lịch sử giao dịch bị gắn cờ với mức độ nghiêm trọng và mã lý do, skeleton loading và bản dịch Việt/Anh. - Module phát hiện chi tiêu bất thường (Upgrade 3) đã có frontend tại `/anomalies`, kết nối Anomaly API (`/anomalies/recent`, `/anomalies/evaluate`) qua TanStack Query; gồm công cụ kiểm tra nhanh giao dịch trước khi chi (Pre-Purchase Evaluator) dựa trên Modified Z-Score & MAD, danh sách lịch sử giao dịch bị gắn cờ với mức độ nghiêm trọng và mã lý do, skeleton loading và bản dịch Việt/Anh.
- Module tự động nhận diện dịch vụ định kỳ (Upgrade 4) đã có frontend tại `/subscriptions`, kết nối Subscription API (`/subscriptions/discover`, `/subscriptions/convert-to-reminder`) qua TanStack Query; gồm danh sách các gói cước định kỳ phát hiện được với chu kỳ và độ chắc chắn, cảnh báo tăng giá cước (price drift), nút 1-click chuyển đổi thành nhắc nhở tự động, skeleton loading và bản dịch Việt/Anh. - Module tự động nhận diện dịch vụ định kỳ (Upgrade 4) đã được sáp nhập trực tiếp vào module Giao dịch tự động định kỳ (`/recurring-transactions`), kế thừa toàn bộ engine phân tích độ tin cậy và cảnh báo tăng giá cước (price drift) hiển thị dạng khối gợi ý ngay trên trang quản lý lịch định kỳ. Route `/subscriptions` chuyển hướng về `/recurring-transactions`.
- Module truy vấn thông minh DSL (Upgrade 5) đã có frontend tại `/query`, kết nối Query API (`/query/parse`, `/query/execute`) qua TanStack Query; gồm thanh nhập liệu ngôn ngữ tự nhiên, chip câu hỏi gợi ý nhanh, bộ kiểm tra cây cú pháp trừu tượng AST, thẻ tổng hợp số liệu thống kê trực tiếp từ DB, phân tích theo nhóm (GroupBy) và danh sách giao dịch chi tiết, skeleton loading và bản dịch Việt/Anh. - Module truy vấn thông minh DSL (Upgrade 5) đã có frontend tại `/query`, kết nối Query API (`/query/parse`, `/query/execute`) qua TanStack Query; gồm thanh nhập liệu ngôn ngữ tự nhiên, chip câu hỏi gợi ý nhanh, bộ kiểm tra cây cú pháp trừu tượng AST, thẻ tổng hợp số liệu thống kê trực tiếp từ DB, phân tích theo nhóm (GroupBy) và danh sách giao dịch chi tiết, skeleton loading và bản dịch Việt/Anh.
- Module giao dịch tự động định kỳ (Upgrade 6) có frontend tại `/recurring-transactions`, kết nối - Module giao dịch tự động định kỳ (Upgrade 6) có frontend tại `/recurring-transactions`, kết nối
CRUD, pause/resume, preview và history API; hỗ trợ tạo lịch trực tiếp hoặc chuyển từ subscription, CRUD, pause/resume, preview và history API; hỗ trợ tạo lịch trực tiếp hoặc chuyển từ subscription,
......
...@@ -5,6 +5,7 @@ import { ...@@ -5,6 +5,7 @@ import {
Route, Route,
SnackbarProvider, SnackbarProvider,
ZMPRouter, ZMPRouter,
useNavigate,
} from "zmp-ui"; } from "zmp-ui";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
...@@ -41,7 +42,6 @@ import AIAssistantPage from "@/pages/ai-assistant/index"; ...@@ -41,7 +42,6 @@ import AIAssistantPage from "@/pages/ai-assistant/index";
import ForecastPage from "@/pages/forecast/index"; import ForecastPage from "@/pages/forecast/index";
import SimulationsPage from "@/pages/simulations/index"; import SimulationsPage from "@/pages/simulations/index";
import AnomaliesPage from "@/pages/anomalies/index"; import AnomaliesPage from "@/pages/anomalies/index";
import SubscriptionsPage from "@/pages/subscriptions/index";
import QueryPage from "@/pages/query/index"; import QueryPage from "@/pages/query/index";
import RecurringTransactionsPage from "@/pages/recurring-transactions/index"; import RecurringTransactionsPage from "@/pages/recurring-transactions/index";
import RolesPage from "@/pages/admin/roles/index"; import RolesPage from "@/pages/admin/roles/index";
...@@ -57,6 +57,13 @@ import { PermissionGate } from "@/components/shared/PermissionGate"; ...@@ -57,6 +57,13 @@ import { PermissionGate } from "@/components/shared/PermissionGate";
import { AccessDenied } from "@/components/shared/AccessDenied"; import { AccessDenied } from "@/components/shared/AccessDenied";
import { PERMISSIONS } from "@/common/constants"; import { PERMISSIONS } from "@/common/constants";
const SubscriptionsRedirect: React.FC = () => {
const navigate = useNavigate();
useEffect(() => {
navigate("/recurring-transactions", { replace: true });
}, [navigate]);
return null;
};
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => { const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore(); const { setAuth, clearAuth, setInitialized } = useAuthStore();
...@@ -128,7 +135,7 @@ export const Layout = () => { ...@@ -128,7 +135,7 @@ export const Layout = () => {
<Route path="/forecast" element={<AuthGuard><PermissionGate permission={PERMISSIONS.FORECAST_READ} fallback={<AccessDenied />}><ForecastPage /></PermissionGate></AuthGuard>}></Route> <Route path="/forecast" element={<AuthGuard><PermissionGate permission={PERMISSIONS.FORECAST_READ} fallback={<AccessDenied />}><ForecastPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/simulations" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SIMULATION_READ} fallback={<AccessDenied />}><SimulationsPage /></PermissionGate></AuthGuard>}></Route> <Route path="/simulations" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SIMULATION_READ} fallback={<AccessDenied />}><SimulationsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/anomalies" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ANOMALY_READ} fallback={<AccessDenied />}><AnomaliesPage /></PermissionGate></AuthGuard>}></Route> <Route path="/anomalies" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ANOMALY_READ} fallback={<AccessDenied />}><AnomaliesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/subscriptions" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SUBSCRIPTION_READ} fallback={<AccessDenied />}><SubscriptionsPage /></PermissionGate></AuthGuard>}></Route> <Route path="/subscriptions" element={<SubscriptionsRedirect />}></Route>
<Route path="/recurring-transactions" element={<AuthGuard><PermissionGate permission={PERMISSIONS.RECURRING_TRANSACTION_READ} fallback={<AccessDenied />}><RecurringTransactionsPage /></PermissionGate></AuthGuard>}></Route> <Route path="/recurring-transactions" element={<AuthGuard><PermissionGate permission={PERMISSIONS.RECURRING_TRANSACTION_READ} fallback={<AccessDenied />}><RecurringTransactionsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/query" element={<AuthGuard><PermissionGate permission={PERMISSIONS.QUERY_EXECUTE} fallback={<AccessDenied />}><QueryPage /></PermissionGate></AuthGuard>}></Route> <Route path="/query" element={<AuthGuard><PermissionGate permission={PERMISSIONS.QUERY_EXECUTE} fallback={<AccessDenied />}><QueryPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/notifications" element={<AuthGuard><PermissionGate permission={PERMISSIONS.NOTIFICATION_READ} fallback={<AccessDenied />}><NotificationsPage /></PermissionGate></AuthGuard>}></Route> <Route path="/notifications" element={<AuthGuard><PermissionGate permission={PERMISSIONS.NOTIFICATION_READ} fallback={<AccessDenied />}><NotificationsPage /></PermissionGate></AuthGuard>}></Route>
......
...@@ -1618,6 +1618,18 @@ ...@@ -1618,6 +1618,18 @@
"trackedInReminders": "Tracked in Reminders", "trackedInReminders": "Tracked in Reminders",
"convertToReminderBtn": "Add to Reminders", "convertToReminderBtn": "Add to Reminders",
"convertSuccess": "Successfully created reminder for {{name}}!", "convertSuccess": "Successfully created reminder for {{name}}!",
"reminderModalTitle": "Create reminder for {{name}}",
"reminderModalDesc": "Select the expected renewal date and when to receive payment reminder notifications.",
"renewalDateLabel": "Next renewal / billing date",
"remindDaysBeforeLabel": "Remind me",
"remindDaysBefore_0": "On renewal date (same day)",
"remindDaysBefore_1": "1 day before",
"remindDaysBefore_2": "2 days before (Recommended)",
"remindDaysBefore_3": "3 days before",
"remindDaysBefore_5": "5 days before",
"remindDaysBefore_7": "7 days before (Recommended for yearly)",
"remindDaysBefore_14": "14 days before",
"reminderModalConfirm": "Create Reminder",
"fetchError": "Could not discover recurring services. Please try again." "fetchError": "Could not discover recurring services. Please try again."
}, },
"recurringTransactions": { "recurringTransactions": {
...@@ -1648,6 +1660,9 @@ ...@@ -1648,6 +1660,9 @@
"deleteTitle": "Remove recurring transaction?", "deleteTitle": "Remove recurring transaction?",
"deleteConfirm": "Future postings will stop. Transactions already posted to the ledger will remain unchanged.", "deleteConfirm": "Future postings will stop. Transactions already posted to the ledger will remain unchanged.",
"attempts": "{{count}} attempt(s)", "attempts": "{{count}} attempt(s)",
"reminderBadge": "Remind {{days}}d before",
"reminderBadgeSameDay": "Remind same day",
"reminder": "Reminder",
"frequency": { "frequency": {
"DAILY": "Daily", "DAILY": "Daily",
"WEEKLY": "Weekly", "WEEKLY": "Weekly",
...@@ -1675,6 +1690,16 @@ ...@@ -1675,6 +1690,16 @@
"repeatInterval": "Repeat every", "repeatInterval": "Repeat every",
"anchorDate": "Start date", "anchorDate": "Start date",
"endDate": "End date (optional)", "endDate": "End date (optional)",
"enableReminder": "Enable payment reminder",
"remindDaysBeforeOptions": {
"0": "On the due date (09:00)",
"1": "1 day before (09:00)",
"2": "2 days before (09:00)",
"3": "3 days before (09:00)",
"5": "5 days before (09:00)",
"7": "1 week before (7 days)",
"14": "2 weeks before (14 days)"
},
"missedRunPolicy": "When dates were missed", "missedRunPolicy": "When dates were missed",
"ledgerNotice": "Saving activates automatic ledger posting. Each successful occurrence changes the selected wallet balance atomically." "ledgerNotice": "Saving activates automatic ledger posting. Each successful occurrence changes the selected wallet balance atomically."
}, },
...@@ -1692,7 +1717,15 @@ ...@@ -1692,7 +1717,15 @@
"wallet": "Payment wallet", "wallet": "Payment wallet",
"confirm": "Activate automation", "confirm": "Activate automation",
"success": "Automatic transaction created for {{name}}.", "success": "Automatic transaction created for {{name}}.",
"walletRequired": "Choose a wallet before activating." "walletRequired": "Choose a wallet before activating.",
"discoveredTitle": "Suggested from Transaction History",
"discoveredDesc": "FinWise identified recurring charges from your transactions. Add them to your automated schedule with 1 click.",
"discoveredBadge": "Suggested",
"discoveredConfidence": "Confidence",
"discoveredNext": "Next expected",
"priceHikeAlert": "Price increase detected!",
"emptyDiscovered": "No new suggestions yet",
"emptyDiscoveredDesc": "When you have 3 or more recurring transactions (Netflix, Spotify, rent...), AI will automatically detect and suggest them here."
} }
}, },
"query": { "query": {
......
...@@ -1719,6 +1719,18 @@ ...@@ -1719,6 +1719,18 @@
"trackedInReminders": "Đã theo dõi trong Nhắc nhở", "trackedInReminders": "Đã theo dõi trong Nhắc nhở",
"convertToReminderBtn": "Thêm vào Nhắc nhở", "convertToReminderBtn": "Thêm vào Nhắc nhở",
"convertSuccess": "Đã tạo nhắc nhở cho {{name}} thành công!", "convertSuccess": "Đã tạo nhắc nhở cho {{name}} thành công!",
"reminderModalTitle": "Tạo nhắc nhở {{name}}",
"reminderModalDesc": "Chọn ngày gia hạn dự kiến và thời điểm nhận thông báo nhắc nhở thanh toán.",
"renewalDateLabel": "Ngày gia hạn / thanh toán tiếp theo",
"remindDaysBeforeLabel": "Thời điểm nhắc nhở",
"remindDaysBefore_0": "Đúng ngày thanh toán (0 ngày trước)",
"remindDaysBefore_1": "Trước 1 ngày",
"remindDaysBefore_2": "Trước 2 ngày (Khuyên dùng)",
"remindDaysBefore_3": "Trước 3 ngày",
"remindDaysBefore_5": "Trước 5 ngày",
"remindDaysBefore_7": "Trước 7 ngày (Khuyên dùng gói năm)",
"remindDaysBefore_14": "Trước 14 ngày",
"reminderModalConfirm": "Tạo nhắc nhở",
"fetchError": "Không thể nhận diện dịch vụ định kỳ. Vui lòng thử lại." "fetchError": "Không thể nhận diện dịch vụ định kỳ. Vui lòng thử lại."
}, },
"recurringTransactions": { "recurringTransactions": {
...@@ -1749,6 +1761,9 @@ ...@@ -1749,6 +1761,9 @@
"deleteTitle": "Xóa giao dịch định kỳ?", "deleteTitle": "Xóa giao dịch định kỳ?",
"deleteConfirm": "Các lần ghi tương lai sẽ dừng. Giao dịch đã ghi vào sổ cái không bị thay đổi.", "deleteConfirm": "Các lần ghi tương lai sẽ dừng. Giao dịch đã ghi vào sổ cái không bị thay đổi.",
"attempts": "{{count}} lần thử", "attempts": "{{count}} lần thử",
"reminderBadge": "Nhắc trước {{days}} ngày",
"reminderBadgeSameDay": "Nhắc cùng ngày",
"reminder": "Nhắc nhở",
"frequency": { "frequency": {
"DAILY": "Hàng ngày", "DAILY": "Hàng ngày",
"WEEKLY": "Hàng tuần", "WEEKLY": "Hàng tuần",
...@@ -1776,6 +1791,16 @@ ...@@ -1776,6 +1791,16 @@
"repeatInterval": "Lặp lại mỗi", "repeatInterval": "Lặp lại mỗi",
"anchorDate": "Ngày bắt đầu", "anchorDate": "Ngày bắt đầu",
"endDate": "Ngày kết thúc", "endDate": "Ngày kết thúc",
"enableReminder": "Bật thông báo nhắc nhở thanh toán",
"remindDaysBeforeOptions": {
"0": "Vào đúng ngày đến hạn (09:00)",
"1": "Trước 1 ngày (09:00)",
"2": "Trước 2 ngày (09:00)",
"3": "Trước 3 ngày (09:00)",
"5": "Trước 5 ngày (09:00)",
"7": "Trước 1 tuần (7 ngày)",
"14": "Trước 2 tuần (14 ngày)"
},
"missedRunPolicy": "Khi bỏ lỡ kỳ chạy", "missedRunPolicy": "Khi bỏ lỡ kỳ chạy",
"ledgerNotice": "Khi lưu, hệ thống sẽ tự động ghi sổ. Mỗi kỳ thành công cập nhật nguyên tử số dư của ví đã chọn." "ledgerNotice": "Khi lưu, hệ thống sẽ tự động ghi sổ. Mỗi kỳ thành công cập nhật nguyên tử số dư của ví đã chọn."
}, },
...@@ -1793,7 +1818,15 @@ ...@@ -1793,7 +1818,15 @@
"wallet": "Ví thanh toán", "wallet": "Ví thanh toán",
"confirm": "Bật tự động", "confirm": "Bật tự động",
"success": "Đã tạo giao dịch tự động cho {{name}}.", "success": "Đã tạo giao dịch tự động cho {{name}}.",
"walletRequired": "Vui lòng chọn ví trước khi kích hoạt." "walletRequired": "Vui lòng chọn ví trước khi kích hoạt.",
"discoveredTitle": "Gợi ý tự động từ lịch sử chi tiêu",
"discoveredDesc": "FinWise nhận diện các khoản phí lặp lại thường xuyên. Bạn có thể thêm ngay vào lịch tự động.",
"discoveredBadge": "Đề xuất",
"discoveredConfidence": "Độ chính xác",
"discoveredNext": "Dự kiến",
"priceHikeAlert": "Phát hiện tăng giá cước!",
"emptyDiscovered": "Chưa có gợi ý định kỳ mới",
"emptyDiscoveredDesc": "Khi bạn có từ 3 giao dịch lặp lại theo chu kỳ (Netflix, Spotify, tiền nhà...), AI sẽ tự động phân tích và đề xuất tại đây."
} }
}, },
"query": { "query": {
......
import React from "react"; import React, { useMemo } from "react";
import { Page, Header, useNavigate } from "zmp-ui"; import { Page, Header, useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
...@@ -17,6 +17,11 @@ import { PermissionGate } from "@/components/shared/PermissionGate"; ...@@ -17,6 +17,11 @@ import { PermissionGate } from "@/components/shared/PermissionGate";
import { PERMISSIONS } from "@/common/constants"; import { PERMISSIONS } from "@/common/constants";
import { SYSTEM_ROLES } from "@/common/constants/system-role.constant"; import { SYSTEM_ROLES } from "@/common/constants/system-role.constant";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { useBudgets } from "@/hooks/use-budgets";
import { useRecurringTransactions } from "@/hooks/use-recurring-transactions";
import { useDiscoveredSubscriptions } from "@/hooks/use-subscriptions";
import { useUnreadNotificationCount } from "@/hooks/use-notifications";
import { todayInBusinessTime, addCalendarDays } from "@/lib/business-time";
function HomePage() { function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
...@@ -24,6 +29,76 @@ function HomePage() { ...@@ -24,6 +29,76 @@ function HomePage() {
const { t } = useI18n(); const { t } = useI18n();
const showStyleGuide = user?.role.name !== SYSTEM_ROLES.USER; const showStyleGuide = user?.role.name !== SYSTEM_ROLES.USER;
const today = todayInBusinessTime();
// Budgets alert count: unarchived budgets with EXCEEDED or NEAR_LIMIT status (usage >= 80%)
const budgetsQuery = useBudgets({
includeArchived: false,
sortBy: "startDate",
order: "desc",
page: 1,
limit: 100,
});
const { budgetAlertCount, hasExceededBudget } = useMemo(() => {
const list = budgetsQuery.data?.data || [];
let count = 0;
let exceeded = false;
for (const b of list) {
if (!b.isArchived) {
const usagePct = parseFloat(b.usage?.usagePercentage || "0");
const isExceeded = b.usage?.status === "EXCEEDED" || usagePct >= 100;
const isNearLimit = b.usage?.status === "NEAR_LIMIT" || (usagePct >= 80 && !isExceeded);
if (isExceeded) {
count += 1;
exceeded = true;
} else if (isNearLimit) {
count += 1;
}
}
}
return { budgetAlertCount: count, hasExceededBudget: exceeded };
}, [budgetsQuery.data?.data]);
// Recurring transactions alert count: schedules due soon / today + new discovered subscriptions
const recurringQuery = useRecurringTransactions({
isActive: true,
page: 1,
limit: 100,
});
const discoveryQuery = useDiscoveredSubscriptions();
const { recurringAlertCount, hasDueTodayOrOverdue } = useMemo(() => {
const schedules = recurringQuery.data?.data || [];
let count = 0;
let urgent = false;
for (const s of schedules) {
if (s.isActive && s.nextRunAt) {
const alertUntil = addCalendarDays(today, s.remindDaysBefore ?? 2);
if (s.nextRunAt <= alertUntil) {
count += 1;
if (s.nextRunAt <= today) {
urgent = true;
}
}
}
}
const discoveredItems = discoveryQuery.data?.data.items || [];
const unlinkedCount = discoveredItems.filter(
(item) => !item.isLinkedToSchedule && !schedules.some((s) => s.description?.toLowerCase() === item.merchantName.toLowerCase()),
).length;
count += unlinkedCount;
return { recurringAlertCount: count, hasDueTodayOrOverdue: urgent };
}, [recurringQuery.data?.data, discoveryQuery.data?.data.items, today]);
// Unread notifications count
const unreadNotificationsQuery = useUnreadNotificationCount();
const unreadCount = unreadNotificationsQuery.data?.data.count || 0;
return ( return (
<Page className="page flex flex-col justify-start overflow-y-auto"> <Page className="page flex flex-col justify-start overflow-y-auto">
<Header title={t("home.header")} showBackIcon={false} /> <Header title={t("home.header")} showBackIcon={false} />
...@@ -94,7 +169,6 @@ function HomePage() { ...@@ -94,7 +169,6 @@ function HomePage() {
PERMISSIONS.FORECAST_READ, PERMISSIONS.FORECAST_READ,
PERMISSIONS.SIMULATION_READ, PERMISSIONS.SIMULATION_READ,
PERMISSIONS.ANOMALY_READ, PERMISSIONS.ANOMALY_READ,
PERMISSIONS.SUBSCRIPTION_READ,
]} ]}
mode="any" mode="any"
> >
...@@ -102,27 +176,22 @@ function HomePage() { ...@@ -102,27 +176,22 @@ function HomePage() {
<span className="text-[11px] font-bold text-clay-text-muted uppercase tracking-wider block px-1 mb-2"> <span className="text-[11px] font-bold text-clay-text-muted uppercase tracking-wider block px-1 mb-2">
{t("home.advancedFeatures")} {t("home.advancedFeatures")}
</span> </span>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-3 gap-2">
<PermissionGate permission={PERMISSIONS.FORECAST_READ}> <PermissionGate permission={PERMISSIONS.FORECAST_READ}>
<Button variant="secondary" onClick={() => navigate("/forecast")} className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"> <Button variant="secondary" onClick={() => navigate("/forecast")} className="text-xs py-2 px-1.5 flex flex-col items-center justify-center gap-1 h-auto text-center">
<span>{t("home.forecast")}</span> <span>{t("home.forecast")}</span>
</Button> </Button>
</PermissionGate> </PermissionGate>
<PermissionGate permission={PERMISSIONS.SIMULATION_READ}> <PermissionGate permission={PERMISSIONS.SIMULATION_READ}>
<Button variant="secondary" onClick={() => navigate("/simulations")} className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"> <Button variant="secondary" onClick={() => navigate("/simulations")} className="text-xs py-2 px-1.5 flex flex-col items-center justify-center gap-1 h-auto text-center">
<span>{t("home.simulations")}</span> <span>{t("home.simulations")}</span>
</Button> </Button>
</PermissionGate> </PermissionGate>
<PermissionGate permission={PERMISSIONS.ANOMALY_READ}> <PermissionGate permission={PERMISSIONS.ANOMALY_READ}>
<Button variant="secondary" onClick={() => navigate("/anomalies")} className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"> <Button variant="secondary" onClick={() => navigate("/anomalies")} className="text-xs py-2 px-1.5 flex flex-col items-center justify-center gap-1 h-auto text-center">
<span>{t("home.anomalies")}</span> <span>{t("home.anomalies")}</span>
</Button> </Button>
</PermissionGate> </PermissionGate>
<PermissionGate permission={PERMISSIONS.SUBSCRIPTION_READ}>
<Button variant="secondary" onClick={() => navigate("/subscriptions")} className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto">
<span>{t("home.subscriptions")}</span>
</Button>
</PermissionGate>
</div> </div>
</div> </div>
</PermissionGate> </PermissionGate>
...@@ -149,8 +218,24 @@ function HomePage() { ...@@ -149,8 +218,24 @@ function HomePage() {
</Button> </Button>
</PermissionGate> </PermissionGate>
<PermissionGate permission={PERMISSIONS.RECURRING_TRANSACTION_READ}> <PermissionGate permission={PERMISSIONS.RECURRING_TRANSACTION_READ}>
<Button variant="secondary" fullWidth onClick={() => navigate("/recurring-transactions")} className="gap-2"> <Button
{t("home.recurringTransactions")} variant="secondary"
fullWidth
onClick={() => navigate("/recurring-transactions")}
className="relative gap-2"
>
<span>{t("home.recurringTransactions")}</span>
{recurringAlertCount > 0 && (
<span
className={`absolute right-4 top-1/2 -translate-y-1/2 flex min-h-5 min-w-5 items-center justify-center rounded-full px-1.5 font-nunito text-xs font-extrabold shadow-clay-raised ${
hasDueTodayOrOverdue
? "bg-clay-expense text-white animate-pulse"
: "bg-clay-warning text-clay-text"
}`}
>
{recurringAlertCount > 99 ? "99+" : recurringAlertCount}
</span>
)}
</Button> </Button>
</PermissionGate> </PermissionGate>
<PermissionGate permission={PERMISSIONS.TRANSFER_READ}> <PermissionGate permission={PERMISSIONS.TRANSFER_READ}>
...@@ -159,8 +244,24 @@ function HomePage() { ...@@ -159,8 +244,24 @@ function HomePage() {
</Button> </Button>
</PermissionGate> </PermissionGate>
<PermissionGate permission={PERMISSIONS.BUDGET_READ}> <PermissionGate permission={PERMISSIONS.BUDGET_READ}>
<Button variant="secondary" fullWidth onClick={() => navigate("/budgets")}> <Button
{t("home.budgets")} variant="secondary"
fullWidth
onClick={() => navigate("/budgets")}
className="relative gap-2"
>
<span>{t("home.budgets")}</span>
{budgetAlertCount > 0 && (
<span
className={`absolute right-4 top-1/2 -translate-y-1/2 flex min-h-5 min-w-5 items-center justify-center rounded-full px-1.5 font-nunito text-xs font-extrabold shadow-clay-raised ${
hasExceededBudget
? "bg-clay-expense text-white animate-pulse"
: "bg-clay-warning text-clay-text"
}`}
>
{budgetAlertCount > 99 ? "99+" : budgetAlertCount}
</span>
)}
</Button> </Button>
</PermissionGate> </PermissionGate>
<PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ}> <PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ}>
...@@ -174,8 +275,18 @@ function HomePage() { ...@@ -174,8 +275,18 @@ function HomePage() {
</Button> </Button>
</PermissionGate> </PermissionGate>
<PermissionGate permission={PERMISSIONS.NOTIFICATION_READ}> <PermissionGate permission={PERMISSIONS.NOTIFICATION_READ}>
<Button variant="secondary" fullWidth onClick={() => navigate("/notifications")}> <Button
{t("home.notifications")} variant="secondary"
fullWidth
onClick={() => navigate("/notifications")}
className="relative gap-2"
>
<span>{t("home.notifications")}</span>
{unreadCount > 0 && (
<span className="absolute right-4 top-1/2 -translate-y-1/2 flex min-h-5 min-w-5 items-center justify-center rounded-full bg-clay-primary px-1.5 font-nunito text-xs font-extrabold text-clay-on-primary shadow-clay-raised">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Button> </Button>
</PermissionGate> </PermissionGate>
<Button variant="secondary" fullWidth onClick={() => navigate("/profile")}> <Button variant="secondary" fullWidth onClick={() => navigate("/profile")}>
......
...@@ -24,6 +24,25 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on ...@@ -24,6 +24,25 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on
return ( return (
<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 && (
<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>
{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>
)}
</div>
)}
<section className="space-y-2"> <section className="space-y-2">
<h4 className="font-baloo text-sm font-bold text-clay-text">{t("recurringTransactions.previewTitle")}</h4> <h4 className="font-baloo text-sm font-bold text-clay-text">{t("recurringTransactions.previewTitle")}</h4>
{preview.isLoading && <div className="h-16 animate-pulse rounded-clay bg-clay-bg shadow-clay-pressed" />} {preview.isLoading && <div className="h-16 animate-pulse rounded-clay bg-clay-bg shadow-clay-pressed" />}
......
...@@ -35,6 +35,8 @@ function schema(t: TranslationFunction) { ...@@ -35,6 +35,8 @@ function schema(t: TranslationFunction) {
anchorDate: z.string().min(1, t("recurringTransactions.validation.anchorDate")), anchorDate: z.string().min(1, t("recurringTransactions.validation.anchorDate")),
endDate: z.string(), endDate: z.string(),
missedRunPolicy: z.enum(["SKIP", "CATCH_UP"]), missedRunPolicy: z.enum(["SKIP", "CATCH_UP"]),
enableReminder: z.boolean(),
remindDaysBefore: z.number().int().min(0).max(30).nullable().optional(),
}).superRefine((values, context) => { }).superRefine((values, context) => {
if (values.endDate && values.endDate < values.anchorDate) { if (values.endDate && values.endDate < values.anchorDate) {
context.addIssue({ context.addIssue({
...@@ -91,6 +93,9 @@ function parseAmount(value: string, locale: string) { ...@@ -91,6 +93,9 @@ function parseAmount(value: string, locale: string) {
} }
function defaults(schedule?: RecurringTransactionSchedule): FormValues { function defaults(schedule?: RecurringTransactionSchedule): FormValues {
const hasReminder = schedule
? (schedule.remindDaysBefore !== undefined && schedule.remindDaysBefore !== null)
: true;
return { return {
type: schedule?.type || "EXPENSE", type: schedule?.type || "EXPENSE",
amount: schedule?.amount || "", amount: schedule?.amount || "",
...@@ -103,6 +108,8 @@ function defaults(schedule?: RecurringTransactionSchedule): FormValues { ...@@ -103,6 +108,8 @@ function defaults(schedule?: RecurringTransactionSchedule): FormValues {
anchorDate: schedule?.anchorDate || todayInBusinessTime(), anchorDate: schedule?.anchorDate || todayInBusinessTime(),
endDate: schedule?.endDate || "", endDate: schedule?.endDate || "",
missedRunPolicy: schedule?.missedRunPolicy || "SKIP", missedRunPolicy: schedule?.missedRunPolicy || "SKIP",
enableReminder: hasReminder,
remindDaysBefore: schedule?.remindDaysBefore ?? 2,
}; };
} }
...@@ -126,6 +133,8 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -126,6 +133,8 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
const categoryId = watch("categoryId"); const categoryId = watch("categoryId");
const anchorDate = watch("anchorDate"); const anchorDate = watch("anchorDate");
const endDate = watch("endDate"); const endDate = watch("endDate");
const frequency = watch("frequency");
const enableReminder = watch("enableReminder");
const formId = schedule ? `edit-recurring-${schedule.id}` : "create-recurring"; const formId = schedule ? `edit-recurring-${schedule.id}` : "create-recurring";
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false); const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
...@@ -156,6 +165,22 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -156,6 +165,22 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
})), })),
], [categoriesQuery.data?.data, t, type]); ], [categoriesQuery.data?.data, t, type]);
const reminderOptions = useMemo(() => {
const all = [
{ value: "0", label: t("recurringTransactions.form.remindDaysBeforeOptions.0") },
{ value: "1", label: t("recurringTransactions.form.remindDaysBeforeOptions.1") },
{ value: "2", label: t("recurringTransactions.form.remindDaysBeforeOptions.2") },
{ value: "3", label: t("recurringTransactions.form.remindDaysBeforeOptions.3") },
{ value: "5", label: t("recurringTransactions.form.remindDaysBeforeOptions.5") },
{ value: "7", label: t("recurringTransactions.form.remindDaysBeforeOptions.7") },
{ value: "14", label: t("recurringTransactions.form.remindDaysBeforeOptions.14") },
];
if (frequency === "DAILY") return all.slice(0, 1);
if (frequency === "WEEKLY") return all.slice(0, 4);
if (frequency === "MONTHLY") return all.slice(0, 6);
return all;
}, [frequency, t]);
useEffect(() => { useEffect(() => {
if (categoryId && !categoryOptions.some((option) => option.value === categoryId)) { if (categoryId && !categoryOptions.some((option) => option.value === categoryId)) {
setValue("categoryId", ""); setValue("categoryId", "");
...@@ -185,6 +210,7 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -185,6 +210,7 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
anchorDate: values.anchorDate, anchorDate: values.anchorDate,
endDate: values.endDate || null, endDate: values.endDate || null,
missedRunPolicy: values.missedRunPolicy, missedRunPolicy: values.missedRunPolicy,
remindDaysBefore: values.enableReminder ? (values.remindDaysBefore ?? 0) : null,
...(schedule ? {} : { isActive: true }), ...(schedule ? {} : { isActive: true }),
}))}> }))}>
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
...@@ -243,6 +269,39 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -243,6 +269,39 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
<LocalizedDateInput type="date" value={anchorDate} label={t("recurringTransactions.form.anchorDate")} error={errors.anchorDate?.message} disabled={isSubmitting} {...register("anchorDate")} /> <LocalizedDateInput type="date" value={anchorDate} label={t("recurringTransactions.form.anchorDate")} error={errors.anchorDate?.message} disabled={isSubmitting} {...register("anchorDate")} />
<LocalizedDateInput type="date" value={endDate} label={t("recurringTransactions.form.endDate")} error={errors.endDate?.message} disabled={isSubmitting} {...register("endDate")} /> <LocalizedDateInput type="date" value={endDate} label={t("recurringTransactions.form.endDate")} error={errors.endDate?.message} disabled={isSubmitting} {...register("endDate")} />
</div> </div>
<div className="flex flex-col gap-2 rounded-clay-md bg-clay-surface p-3.5 shadow-clay-card border border-clay-border/40">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
className="w-4 h-4 rounded border-clay-border text-primary-500 focus:ring-primary-400 cursor-pointer"
disabled={isSubmitting}
{...register("enableReminder")}
/>
<span className="text-sm font-semibold text-clay-text flex items-center gap-1.5">
<span>🔔</span>
<span>{t("recurringTransactions.form.enableReminder")}</span>
</span>
</label>
{enableReminder && (
<div className="pt-2 pl-6">
<Controller
name="remindDaysBefore"
control={control}
render={({ field }) => (
<Select
options={reminderOptions}
disabled={isSubmitting}
value={String(field.value ?? (frequency === "DAILY" ? 0 : 2))}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
)}
/>
</div>
)}
</div>
<Select label={t("recurringTransactions.form.missedRunPolicy")} options={[ <Select label={t("recurringTransactions.form.missedRunPolicy")} options={[
{ value: "SKIP", label: t("recurringTransactions.policy.SKIP") }, { value: "SKIP", label: t("recurringTransactions.policy.SKIP") },
{ value: "CATCH_UP", label: t("recurringTransactions.policy.CATCH_UP") }, { value: "CATCH_UP", label: t("recurringTransactions.policy.CATCH_UP") },
......
This diff is collapsed.
...@@ -2,6 +2,7 @@ import React from "react"; ...@@ -2,6 +2,7 @@ import React from "react";
import { Header, Page, useSnackbar } from "zmp-ui"; import { Header, Page, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { useConvertToReminder, useDiscoveredSubscriptions } from "@/hooks/use-subscriptions"; import { useConvertToReminder, useDiscoveredSubscriptions } from "@/hooks/use-subscriptions";
...@@ -24,25 +25,40 @@ const SubscriptionsPage: React.FC = () => { ...@@ -24,25 +25,40 @@ const SubscriptionsPage: React.FC = () => {
const [recurringCandidate, setRecurringCandidate] = React.useState<DiscoveredSubscription | null>(null); const [recurringCandidate, setRecurringCandidate] = React.useState<DiscoveredSubscription | null>(null);
const [walletId, setWalletId] = React.useState(""); const [walletId, setWalletId] = React.useState("");
const [reminderCandidate, setReminderCandidate] = React.useState<DiscoveredSubscription | null>(null);
const [remindDaysBefore, setRemindDaysBefore] = React.useState("2");
const [renewalDate, setRenewalDate] = React.useState("");
const items = discoveryQuery.data?.data.items || []; const items = discoveryQuery.data?.data.items || [];
const isLoading = discoveryQuery.isLoading; const isLoading = discoveryQuery.isLoading;
const handleConvert = async (sub: DiscoveredSubscription) => { const handleOpenReminderModal = (sub: DiscoveredSubscription) => {
setReminderCandidate(sub);
setRenewalDate(sub.nextExpectedAt);
const defaultDays = sub.frequency === "MONTHLY" ? "2" : sub.frequency === "YEARLY" ? "7" : "0";
setRemindDaysBefore(defaultDays);
};
const handleConfirmReminder = async () => {
if (!reminderCandidate) return;
try { try {
const remindDate = businessWallTimeToIso(`${sub.nextExpectedAt}T09:00`); const remindDate = businessWallTimeToIso(`${renewalDate || reminderCandidate.nextExpectedAt}T09:00`);
await convertMutation.mutateAsync({ await convertMutation.mutateAsync({
merchantName: sub.merchantName, merchantName: reminderCandidate.merchantName,
amount: sub.latestAmount, amount: reminderCandidate.latestAmount,
frequency: sub.frequency, currency: reminderCandidate.currency,
frequency: reminderCandidate.frequency,
remindAt: remindDate, remindAt: remindDate,
categoryId: sub.categoryId, remindDaysBefore: parseInt(remindDaysBefore, 10),
categoryId: reminderCandidate.categoryId,
}); });
openSnackbar({ openSnackbar({
type: "success", type: "success",
text: t("subscriptions.convertSuccess", { name: sub.merchantName }), text: t("subscriptions.convertSuccess", { name: reminderCandidate.merchantName }),
}); });
} catch (e) { setReminderCandidate(null);
} catch {
openSnackbar({ openSnackbar({
type: "error", type: "error",
text: t("common.error"), text: t("common.error"),
...@@ -129,7 +145,7 @@ const SubscriptionsPage: React.FC = () => { ...@@ -129,7 +145,7 @@ const SubscriptionsPage: React.FC = () => {
<SubscriptionCard <SubscriptionCard
key={`${item.merchantName}-${item.categoryId}-${item.frequency}`} key={`${item.merchantName}-${item.categoryId}-${item.frequency}`}
item={item} item={item}
onConvertToReminder={handleConvert} onConvertToReminder={handleOpenReminderModal}
onConvertToRecurring={(candidate) => { setWalletId(""); setRecurringCandidate(candidate); }} onConvertToRecurring={(candidate) => { setWalletId(""); setRecurringCandidate(candidate); }}
isConverting={convertMutation.isPending && convertMutation.variables?.merchantName === item.merchantName} isConverting={convertMutation.isPending && convertMutation.variables?.merchantName === item.merchantName}
isCreatingRecurring={recurringMutation.isPending && recurringCandidate?.merchantName === item.merchantName} isCreatingRecurring={recurringMutation.isPending && recurringCandidate?.merchantName === item.merchantName}
...@@ -139,6 +155,64 @@ const SubscriptionsPage: React.FC = () => { ...@@ -139,6 +155,64 @@ const SubscriptionsPage: React.FC = () => {
)} )}
</div> </div>
</div> </div>
{/* Reminder Conversion Modal */}
<Modal
isOpen={Boolean(reminderCandidate)}
onClose={() => setReminderCandidate(null)}
title={t("subscriptions.reminderModalTitle", { name: reminderCandidate?.merchantName || "" })}
footer={
<>
<Button
variant="ghost"
className="text-sm"
disabled={convertMutation.isPending}
onClick={() => setReminderCandidate(null)}
>
{t("common.cancel")}
</Button>
<Button
className="text-sm"
disabled={convertMutation.isPending || !renewalDate}
onClick={() => void handleConfirmReminder()}
>
{convertMutation.isPending ? t("common.processing") : t("subscriptions.reminderModalConfirm")}
</Button>
</>
}
>
<div className="space-y-4">
<p className="text-sm text-clay-text-muted">
{t("subscriptions.reminderModalDesc")}
</p>
<Input
type="date"
label={t("subscriptions.renewalDateLabel")}
value={renewalDate}
onChange={(e) => setRenewalDate(e.target.value)}
disabled={convertMutation.isPending}
/>
<Select
label={t("subscriptions.remindDaysBeforeLabel")}
value={remindDaysBefore}
onChange={(e) => setRemindDaysBefore(e.target.value)}
disabled={convertMutation.isPending}
options={[
{ value: "0", label: t("subscriptions.remindDaysBefore_0") },
{ value: "1", label: t("subscriptions.remindDaysBefore_1") },
{ value: "2", label: t("subscriptions.remindDaysBefore_2") },
{ value: "3", label: t("subscriptions.remindDaysBefore_3") },
{ value: "5", label: t("subscriptions.remindDaysBefore_5") },
{ value: "7", label: t("subscriptions.remindDaysBefore_7") },
{ value: "14", label: t("subscriptions.remindDaysBefore_14") },
]}
/>
</div>
</Modal>
{/* Recurring Transaction Modal */}
<Modal <Modal
isOpen={Boolean(recurringCandidate)} isOpen={Boolean(recurringCandidate)}
onClose={() => { setRecurringCandidate(null); setWalletId(""); }} onClose={() => { setRecurringCandidate(null); setWalletId(""); }}
...@@ -167,3 +241,4 @@ const SubscriptionsPage: React.FC = () => { ...@@ -167,3 +241,4 @@ const SubscriptionsPage: React.FC = () => {
}; };
export default SubscriptionsPage; export default SubscriptionsPage;
...@@ -42,6 +42,67 @@ const CACHE_KEY = "finwise.currency_rates"; ...@@ -42,6 +42,67 @@ const CACHE_KEY = "finwise.currency_rates";
const CACHE_TIME_KEY = "finwise.currency_rates_timestamp"; const CACHE_TIME_KEY = "finwise.currency_rates_timestamp";
const CACHE_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours const CACHE_TTL_MS = 12 * 60 * 60 * 1000; // 12 hours
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Safe localStorage wrapper — logs a warning instead of silently swallowing
* quota errors or SecurityErrors (e.g. private-browsing mode).
*/
const safeStorage = {
get(key: string): string | null {
try {
return localStorage.getItem(key);
} catch (err) {
console.warn("[currency] localStorage.getItem failed:", err);
return null;
}
},
set(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch (err) {
// QuotaExceededError or SecurityError — non-fatal, but worth knowing
console.warn("[currency] localStorage.setItem failed:", err);
}
},
};
/**
* Fetch a URL with an explicit timeout. The AbortController timeout is
* cleared in a `finally` block so it is **always** cancelled regardless of
* whether the fetch succeeds, fails, or throws — preventing a timer leak.
*
* @param url - URL to fetch
* @param timeoutMs - Abort after this many milliseconds (default 5 000 ms)
*/
async function fetchWithTimeout(
url: string,
timeoutMs = 5000
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { signal: controller.signal });
} finally {
// Always clear the timer — even when fetch throws or is aborted
clearTimeout(timer);
}
}
/**
* Persist a merged rate map to localStorage and update the timestamp.
*/
function persistRates(rates: ExchangeRateMap, now: number): void {
safeStorage.set(CACHE_KEY, JSON.stringify(rates));
safeStorage.set(CACHE_TIME_KEY, String(now));
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** /**
* Calculate the exchange rate from one currency to another (1 unit of from = X units of to). * Calculate the exchange rate from one currency to another (1 unit of from = X units of to).
*/ */
...@@ -104,7 +165,7 @@ export function convertCurrency({ ...@@ -104,7 +165,7 @@ export function convertCurrency({
*/ */
export function getCachedRates(): ExchangeRateMap { export function getCachedRates(): ExchangeRateMap {
try { try {
const cached = localStorage.getItem(CACHE_KEY); const cached = safeStorage.get(CACHE_KEY);
if (cached) { if (cached) {
const parsed = JSON.parse(cached); const parsed = JSON.parse(cached);
if (typeof parsed === "object" && parsed !== null) { if (typeof parsed === "object" && parsed !== null) {
...@@ -112,17 +173,18 @@ export function getCachedRates(): ExchangeRateMap { ...@@ -112,17 +173,18 @@ export function getCachedRates(): ExchangeRateMap {
} }
} }
} catch { } catch {
// Ignore localStorage parse errors // Ignore JSON parse errors
} }
return DEFAULT_VND_RATES; return DEFAULT_VND_RATES;
} }
/** /**
* Fetch latest exchange rates from open exchange API with cache and fallback. * Fetch latest exchange rates from open exchange API with cache and fallback.
* Both external fetches are guarded with a 7-second timeout via fetchWithTimeout.
*/ */
export async function fetchLatestRates(): Promise<ExchangeRateMap> { export async function fetchLatestRates(): Promise<ExchangeRateMap> {
try { try {
const lastTimestamp = Number(localStorage.getItem(CACHE_TIME_KEY) || 0); const lastTimestamp = Number(safeStorage.get(CACHE_TIME_KEY) || 0);
const now = Date.now(); const now = Date.now();
if (now - lastTimestamp < CACHE_TTL_MS) { if (now - lastTimestamp < CACHE_TTL_MS) {
...@@ -132,9 +194,9 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> { ...@@ -132,9 +194,9 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
} }
} }
// 1. Try FloatRates (interbank feed) // 1. Try FloatRates (interbank feed) — with timeout
try { try {
const response = await fetch("https://www.floatrates.com/daily/usd.json"); const response = await fetchWithTimeout("https://www.floatrates.com/daily/usd.json", 7000);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data && data.vnd?.rate) { if (data && data.vnd?.rate) {
...@@ -152,10 +214,7 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> { ...@@ -152,10 +214,7 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
} }
} }
const merged = { ...DEFAULT_VND_RATES, ...newVndRates }; const merged = { ...DEFAULT_VND_RATES, ...newVndRates };
try { persistRates(merged, now);
localStorage.setItem(CACHE_KEY, JSON.stringify(merged));
localStorage.setItem(CACHE_TIME_KEY, String(now));
} catch {}
return merged; return merged;
} }
} }
...@@ -164,8 +223,8 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> { ...@@ -164,8 +223,8 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
// Continue to open.er-api fallback // Continue to open.er-api fallback
} }
// 2. Try fetching USD-based rates from open exchange API // 2. Try fetching USD-based rates from open exchange API — with timeout
const response = await fetch("https://open.er-api.com/v6/latest/USD"); const response = await fetchWithTimeout("https://open.er-api.com/v6/latest/USD", 7000);
if (!response.ok) { if (!response.ok) {
return getCachedRates(); return getCachedRates();
} }
...@@ -185,10 +244,7 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> { ...@@ -185,10 +244,7 @@ export async function fetchLatestRates(): Promise<ExchangeRateMap> {
} }
const merged = { ...DEFAULT_VND_RATES, ...newVndRates }; const merged = { ...DEFAULT_VND_RATES, ...newVndRates };
try { persistRates(merged, now);
localStorage.setItem(CACHE_KEY, JSON.stringify(merged));
localStorage.setItem(CACHE_TIME_KEY, String(now));
} catch {}
return merged; return merged;
} }
} catch { } catch {
...@@ -215,12 +271,10 @@ export async function fetchLiveExchangeRate( ...@@ -215,12 +271,10 @@ export async function fetchLiveExchangeRate(
// 1. Try FloatRates directly for real-time interbank market rate // 1. Try FloatRates directly for real-time interbank market rate
try { try {
const controller = new AbortController(); const res = await fetchWithTimeout(
const timeout = setTimeout(() => controller.abort(), 3500); `https://www.floatrates.com/daily/${fromCode.toLowerCase()}.json`,
const res = await fetch(`https://www.floatrates.com/daily/${fromCode.toLowerCase()}.json`, { 3500
signal: controller.signal, );
});
clearTimeout(timeout);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
const target = data[toCode.toLowerCase()]; const target = data[toCode.toLowerCase()];
...@@ -241,12 +295,10 @@ export async function fetchLiveExchangeRate( ...@@ -241,12 +295,10 @@ export async function fetchLiveExchangeRate(
// 2. Try Open Exchange Rates API // 2. Try Open Exchange Rates API
try { try {
const controller = new AbortController(); const res = await fetchWithTimeout(
const timeout = setTimeout(() => controller.abort(), 3500); `https://open.er-api.com/v6/latest/${fromCode}`,
const res = await fetch(`https://open.er-api.com/v6/latest/${fromCode}`, { 3500
signal: controller.signal, );
});
clearTimeout(timeout);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
const targetRate = data.rates?.[toCode]; const targetRate = data.rates?.[toCode];
...@@ -264,11 +316,21 @@ export async function fetchLiveExchangeRate( ...@@ -264,11 +316,21 @@ export async function fetchLiveExchangeRate(
// 3. Fallback to cached or DEFAULT_VND_RATES // 3. Fallback to cached or DEFAULT_VND_RATES
const cachedRates = getCachedRates(); const cachedRates = getCachedRates();
const cachedTimestamp = Number(safeStorage.get(CACHE_TIME_KEY) || 0);
const fallbackRate = getExchangeRate(fromCode, toCode, cachedRates); const fallbackRate = getExchangeRate(fromCode, toCode, cachedRates);
const ageMs = cachedTimestamp ? Date.now() - cachedTimestamp : null;
const ageNote = ageMs
? ageMs < 3_600_000
? "vừa cập nhật"
: ageMs < 86_400_000
? `${Math.floor(ageMs / 3_600_000)}h trước`
: `${Math.floor(ageMs / 86_400_000)}d trước`
: null;
return { return {
rate: fallbackRate, rate: fallbackRate,
isLive: false, isLive: false,
note: "Tỷ giá thị trường cơ sở (ngoại tuyến)", note: ageNote ? `Tỷ giá cơ sở · ngoại tuyến · ${ageNote}` : "Tỷ giá cơ sở · ngoại tuyến",
}; };
} }
...@@ -19,6 +19,7 @@ export interface RecurringTransactionSchedule { ...@@ -19,6 +19,7 @@ export interface RecurringTransactionSchedule {
nextRunAt: string | null; nextRunAt: string | null;
missedRunPolicy: RecurringTransactionMissedRunPolicy; missedRunPolicy: RecurringTransactionMissedRunPolicy;
lastRunAt: string | null; lastRunAt: string | null;
remindDaysBefore?: number | null;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
...@@ -44,6 +45,7 @@ export interface RecurringTransactionInput { ...@@ -44,6 +45,7 @@ export interface RecurringTransactionInput {
repeatInterval: number; repeatInterval: number;
anchorDate: string; anchorDate: string;
endDate?: string | null; endDate?: string | null;
remindDaysBefore?: number | null;
missedRunPolicy: RecurringTransactionMissedRunPolicy; missedRunPolicy: RecurringTransactionMissedRunPolicy;
isActive?: boolean; isActive?: boolean;
} }
......
...@@ -16,13 +16,16 @@ export interface DiscoveredSubscription { ...@@ -16,13 +16,16 @@ export interface DiscoveredSubscription {
isPriceDrift: boolean; isPriceDrift: boolean;
priceDriftPercentage: number | null; priceDriftPercentage: number | null;
isLinkedToReminder: boolean; isLinkedToReminder: boolean;
isLinkedToSchedule?: boolean;
} }
export interface ConvertSubscriptionInput { export interface ConvertSubscriptionInput {
merchantName: string; merchantName: string;
amount: string; amount: string;
currency?: string;
frequency: SubscriptionFrequency; frequency: SubscriptionFrequency;
remindAt: string; remindAt: string;
remindDaysBefore?: number;
categoryId?: string; categoryId?: string;
} }
......
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