Commit b3935c3d authored by ThinhNC's avatar ThinhNC

feat(recurring): implement recurring transaction management and wallet balance formatting

parent 8f1603d9
...@@ -29,6 +29,12 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -29,6 +29,12 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Quản lý Saving Goal**: Trang `/saving-goals``/saving-goals/:id` dùng trực tiếp progress do Saving Goal API tính (`savedAmount`, `remainingAmount`, `progressPercentage`, `daysRemaining`, `isOverdue`) để giữ một nguồn sự thật chung cho UI, báo cáo và AI. `DELETE /saving-goals/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; mục tiêu archive chỉ đọc đến khi restore. Contribution được quản lý riêng và mọi mutation contribution phải làm mới cả detail, list goal và lịch sử contribution. - **Quản lý Saving Goal**: Trang `/saving-goals``/saving-goals/:id` dùng trực tiếp progress do Saving Goal API tính (`savedAmount`, `remainingAmount`, `progressPercentage`, `daysRemaining`, `isOverdue`) để giữ một nguồn sự thật chung cho UI, báo cáo và AI. `DELETE /saving-goals/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; mục tiêu archive chỉ đọc đến khi restore. Contribution được quản lý riêng và mọi mutation contribution phải làm mới cả detail, list goal và lịch sử contribution.
- **Báo cáo tài chính**: Trang `/reports` dùng trực tiếp bốn Report API (`overview`, `cash-flow`, `spending-by-category`, `budget-performance`) qua TanStack Query và luôn giữ số liệu tách theo tiền tệ; không cộng gộp các loại tiền tệ khác nhau. Bộ lọc khoảng ngày tùy chọn gửi mốc kết thúc dạng exclusive sang Backend nhưng hiển thị ngày kết thúc dạng inclusive cho người dùng. Biểu đồ dùng SVG/CSS với semantic Clay tokens, không phụ thuộc thư viện chart. Thẻ Tổng quan ngân sách phải thể hiện số liệu tổng hợp (đã chi, còn lại, hạn mức và số lượng theo trạng thái), còn Hiệu suất ngân sách trình bày từng ngân sách; tổng quan chỉ ưu tiên summary `OVERALL` khi `budgetCount > 0`, nếu không fallback sang `CATEGORY`, và hiển thị `—` thay vì `0%` khi không có ngân sách hợp lệ. Khối AI tại báo cáo phải hiển thị trọn bản tóm tắt cùng tối đa ba điểm đáng chú ý, sau đó mới dẫn sang Trợ lý AI để phân tích sâu; khi điều hướng phải giữ `dateFrom`/`dateTo`/`currency` của kỳ báo cáo và nút quay lại phải trở về báo cáo. - **Báo cáo tài chính**: Trang `/reports` dùng trực tiếp bốn Report API (`overview`, `cash-flow`, `spending-by-category`, `budget-performance`) qua TanStack Query và luôn giữ số liệu tách theo tiền tệ; không cộng gộp các loại tiền tệ khác nhau. Bộ lọc khoảng ngày tùy chọn gửi mốc kết thúc dạng exclusive sang Backend nhưng hiển thị ngày kết thúc dạng inclusive cho người dùng. Biểu đồ dùng SVG/CSS với semantic Clay tokens, không phụ thuộc thư viện chart. Thẻ Tổng quan ngân sách phải thể hiện số liệu tổng hợp (đã chi, còn lại, hạn mức và số lượng theo trạng thái), còn Hiệu suất ngân sách trình bày từng ngân sách; tổng quan chỉ ưu tiên summary `OVERALL` khi `budgetCount > 0`, nếu không fallback sang `CATEGORY`, và hiển thị `—` thay vì `0%` khi không có ngân sách hợp lệ. Khối AI tại báo cáo phải hiển thị trọn bản tóm tắt cùng tối đa ba điểm đáng chú ý, sau đó mới dẫn sang Trợ lý AI để phân tích sâu; khi điều hướng phải giữ `dateFrom`/`dateTo`/`currency` của kỳ báo cáo và nút quay lại phải trở về báo cáo.
- **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; danh sách/count/reminder polling mỗi 30 giây khi đang hoạt động, 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 & 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; danh sách/count/reminder polling mỗi 30 giây khi đang hoạt động, 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.
- **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.
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
**Tính năng Nâng cao** dù được phát triển trong Upgrade 6.
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
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.
- **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).
...@@ -69,6 +75,9 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -69,6 +75,9 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- 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ó 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 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
CRUD, pause/resume, preview và history API; hỗ trợ tạo lịch trực tiếp hoặc chuyển từ subscription,
có loading/error/empty state và bản dịch Việt/Anh.
## Khi cập nhật file này ## Khi cập nhật file này
......
...@@ -42,6 +42,7 @@ import SimulationsPage from "@/pages/simulations/index"; ...@@ -42,6 +42,7 @@ 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 SubscriptionsPage from "@/pages/subscriptions/index";
import QueryPage from "@/pages/query/index"; import QueryPage from "@/pages/query/index";
import RecurringTransactionsPage from "@/pages/recurring-transactions/index";
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();
...@@ -113,6 +114,7 @@ const Layout = () => { ...@@ -113,6 +114,7 @@ const Layout = () => {
<Route path="/simulations" element={<AuthGuard><SimulationsPage /></AuthGuard>}></Route> <Route path="/simulations" element={<AuthGuard><SimulationsPage /></AuthGuard>}></Route>
<Route path="/anomalies" element={<AuthGuard><AnomaliesPage /></AuthGuard>}></Route> <Route path="/anomalies" element={<AuthGuard><AnomaliesPage /></AuthGuard>}></Route>
<Route path="/subscriptions" element={<AuthGuard><SubscriptionsPage /></AuthGuard>}></Route> <Route path="/subscriptions" element={<AuthGuard><SubscriptionsPage /></AuthGuard>}></Route>
<Route path="/recurring-transactions" element={<AuthGuard><RecurringTransactionsPage /></AuthGuard>}></Route>
<Route path="/query" element={<AuthGuard><QueryPage /></AuthGuard>}></Route> <Route path="/query" element={<AuthGuard><QueryPage /></AuthGuard>}></Route>
<Route path="/notifications" element={<AuthGuard><NotificationsPage /></AuthGuard>}></Route> <Route path="/notifications" element={<AuthGuard><NotificationsPage /></AuthGuard>}></Route>
<Route path="/ai-assistant" element={<AuthGuard><AIAssistantPage /></AuthGuard>}></Route> <Route path="/ai-assistant" element={<AuthGuard><AIAssistantPage /></AuthGuard>}></Route>
......
...@@ -28,6 +28,7 @@ const PAGE_TITLES: ReadonlyArray<{ ...@@ -28,6 +28,7 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/simulations", key: "document.simulations" }, { matches: (pathname) => pathname === "/simulations", key: "document.simulations" },
{ matches: (pathname) => pathname === "/anomalies", key: "document.anomalies" }, { matches: (pathname) => pathname === "/anomalies", key: "document.anomalies" },
{ matches: (pathname) => pathname === "/subscriptions", key: "document.subscriptions" }, { matches: (pathname) => pathname === "/subscriptions", key: "document.subscriptions" },
{ matches: (pathname) => pathname === "/recurring-transactions", key: "document.recurringTransactions" },
{ matches: (pathname) => pathname === "/query", key: "document.query" }, { matches: (pathname) => pathname === "/query", key: "document.query" },
{ matches: (pathname) => pathname === "/notifications", key: "document.notifications" }, { matches: (pathname) => pathname === "/notifications", key: "document.notifications" },
{ matches: (pathname) => pathname === "/ai-assistant", key: "document.aiAssistant" }, { matches: (pathname) => pathname === "/ai-assistant", key: "document.aiAssistant" },
......
...@@ -272,7 +272,7 @@ body { ...@@ -272,7 +272,7 @@ body {
var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 8px var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 8px
); );
right: calc( right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 80px var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 96px
); );
} }
......
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { recurringTransactionService } from "@/services/recurring-transaction.service";
import {
ConvertSubscriptionToRecurringInput,
RecurringTransactionInput,
RecurringTransactionQuery,
UpdateRecurringTransactionInput,
} from "@/types/recurring-transaction";
import { SUBSCRIPTION_QUERY_KEYS } from "./use-subscriptions";
export const recurringTransactionKeys = {
all: ["recurring-transactions"] as const,
lists: () => [...recurringTransactionKeys.all, "list"] as const,
list: (query: RecurringTransactionQuery) => [...recurringTransactionKeys.lists(), query] as const,
preview: (id: string) => [...recurringTransactionKeys.all, id, "preview"] as const,
history: (id: string) => [...recurringTransactionKeys.all, id, "history"] as const,
};
export function useRecurringTransactions(query: RecurringTransactionQuery) {
return useQuery({
queryKey: recurringTransactionKeys.list(query),
queryFn: () => recurringTransactionService.list(query),
placeholderData: keepPreviousData,
staleTime: 30_000,
});
}
export function useRecurringPreview(id: string, enabled: boolean) {
return useQuery({
queryKey: recurringTransactionKeys.preview(id),
queryFn: () => recurringTransactionService.preview(id),
enabled: enabled && Boolean(id),
});
}
export function useRecurringHistory(id: string, enabled: boolean) {
return useQuery({
queryKey: recurringTransactionKeys.history(id),
queryFn: () => recurringTransactionService.history(id),
enabled: enabled && Boolean(id),
});
}
function useScheduleMutation<TInput>(mutationFn: (input: TInput) => Promise<unknown>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: recurringTransactionKeys.all });
},
});
}
export function useCreateRecurringTransaction() {
return useScheduleMutation((input: RecurringTransactionInput) => recurringTransactionService.create(input));
}
export function useUpdateRecurringTransaction() {
return useScheduleMutation(({ id, input }: { id: string; input: UpdateRecurringTransactionInput }) => recurringTransactionService.update(id, input));
}
export function usePauseRecurringTransaction() {
return useScheduleMutation((id: string) => recurringTransactionService.pause(id));
}
export function useResumeRecurringTransaction() {
return useScheduleMutation((id: string) => recurringTransactionService.resume(id));
}
export function useDeleteRecurringTransaction() {
return useScheduleMutation((id: string) => recurringTransactionService.remove(id));
}
export function useConvertSubscriptionToRecurring() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ConvertSubscriptionToRecurringInput) => recurringTransactionService.convertSubscription(input),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: recurringTransactionKeys.all }),
queryClient.invalidateQueries({ queryKey: SUBSCRIPTION_QUERY_KEYS.all }),
]);
},
});
}
...@@ -53,7 +53,7 @@ export function useUpdateWallet(id: string) { ...@@ -53,7 +53,7 @@ export function useUpdateWallet(id: string) {
mutationFn: (input: UpdateWalletInput) => walletService.updateWallet(id, input), mutationFn: (input: UpdateWalletInput) => walletService.updateWallet(id, input),
onSuccess: async (response) => { onSuccess: async (response) => {
queryClient.setQueryData(walletKeys.detail(id), response); queryClient.setQueryData(walletKeys.detail(id), response);
await queryClient.invalidateQueries({ queryKey: walletKeys.lists() }); await queryClient.invalidateQueries({ queryKey: walletKeys.all });
}, },
}); });
} }
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save", "save": "Save",
"edit": "Edit", "edit": "Edit",
"detail": "Details",
"saving": "Saving...", "saving": "Saving...",
"delete": "Delete", "delete": "Delete",
"error": "Something went wrong", "error": "Something went wrong",
...@@ -57,6 +58,7 @@ ...@@ -57,6 +58,7 @@
"simulations": "What-if Financial Simulation Sandbox", "simulations": "What-if Financial Simulation Sandbox",
"anomalies": "Multi-dimensional Anomaly Detection", "anomalies": "Multi-dimensional Anomaly Detection",
"subscriptions": "Auto Subscriptions & Recurring Bills", "subscriptions": "Auto Subscriptions & Recurring Bills",
"recurringTransactions": "Automated Recurring Transactions",
"query": "Smart Natural Language Query DSL", "query": "Smart Natural Language Query DSL",
"notifications": "Notifications & Reminders", "notifications": "Notifications & Reminders",
"aiAssistant": "AI Financial Assistant" "aiAssistant": "AI Financial Assistant"
...@@ -1360,6 +1362,7 @@ ...@@ -1360,6 +1362,7 @@
"simulations": "What-if Sandbox", "simulations": "What-if Sandbox",
"anomalies": "Anomaly Alerts", "anomalies": "Anomaly Alerts",
"subscriptions": "Recurring Bills", "subscriptions": "Recurring Bills",
"recurringTransactions": "Auto Transactions",
"coreModules": "Core Financials", "coreModules": "Core Financials",
"wallets": "Wallets & Balances", "wallets": "Wallets & Balances",
"transactions": "Transactions", "transactions": "Transactions",
...@@ -1529,6 +1532,81 @@ ...@@ -1529,6 +1532,81 @@
"convertSuccess": "Successfully created reminder for {{name}}!", "convertSuccess": "Successfully created reminder for {{name}}!",
"fetchError": "Could not discover recurring services. Please try again." "fetchError": "Could not discover recurring services. Please try again."
}, },
"recurringTransactions": {
"pageTitle": "Automated Recurring Transactions",
"bannerTitle": "Put repeat entries on autopilot",
"bannerDesc": "FinWise records income or expenses on your chosen schedule. This changes your in-app wallet balance but never initiates an external bank payment.",
"createButton": "Create recurring transaction",
"createTitle": "New recurring transaction",
"editTitle": "Edit future transactions",
"detailsTitle": "Schedule details",
"previewTitle": "Upcoming dates",
"historyTitle": "Execution history",
"noFutureDates": "No future dates remain in this schedule.",
"noHistory": "No occurrences have been processed yet.",
"detailsError": "Could not load schedule details.",
"fetchError": "Could not load recurring transactions.",
"emptyTitle": "No automated transactions yet",
"emptyDesc": "Create a schedule for rent, salary, subscriptions, or any repeat entry.",
"active": "Active",
"paused": "Paused",
"cycle": "Cycle",
"nextRun": "Next posting",
"pause": "Pause",
"resume": "Resume",
"actionError": "The schedule could not be updated. Check its wallet and category.",
"saveSuccess": "Recurring transaction saved.",
"deleteSuccess": "Recurring transaction removed. Posted transactions were preserved.",
"deleteTitle": "Remove recurring transaction?",
"deleteConfirm": "Future postings will stop. Transactions already posted to the ledger will remain unchanged.",
"attempts": "{{count}} attempt(s)",
"frequency": {
"DAILY": "Daily",
"WEEKLY": "Weekly",
"MONTHLY": "Monthly",
"YEARLY": "Yearly"
},
"policy": {
"SKIP": "Skip missed dates",
"CATCH_UP": "Post missed dates gradually"
},
"status": {
"POSTED": "Posted",
"FAILED": "Failed"
},
"form": {
"type": "Transaction type",
"amount": "Amount",
"wallet": "Wallet",
"category": "Category",
"selectWallet": "Choose a wallet",
"selectCategory": "Choose a category",
"description": "Description",
"location": "Location",
"frequency": "Frequency",
"repeatInterval": "Repeat every",
"anchorDate": "Start date",
"endDate": "End date (optional)",
"missedRunPolicy": "When dates were missed",
"ledgerNotice": "Saving activates automatic ledger posting. Each successful occurrence changes the selected wallet balance atomically."
},
"validation": {
"amount": "Enter a valid amount greater than zero.",
"wallet": "Choose a wallet.",
"category": "Choose a category.",
"anchorDate": "Choose a start date.",
"endDate": "End date cannot be before the start date."
},
"subscription": {
"button": "Automate transaction",
"title": "Automate {{name}}?",
"description": "Choose the wallet that will be adjusted when this recurring expense is recorded.",
"wallet": "Payment wallet",
"confirm": "Activate automation",
"success": "Automatic transaction created for {{name}}.",
"walletRequired": "Choose a wallet before activating."
}
},
"query": { "query": {
"pageTitle": "Smart Natural Language Query DSL", "pageTitle": "Smart Natural Language Query DSL",
"bannerTitle": "Natural Language Financial Query", "bannerTitle": "Natural Language Financial Query",
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
"cancel": "Hủy", "cancel": "Hủy",
"save": "Lưu", "save": "Lưu",
"edit": "Chỉnh sửa", "edit": "Chỉnh sửa",
"detail": "Chi tiết",
"saving": "Đang lưu...", "saving": "Đang lưu...",
"delete": "Xóa", "delete": "Xóa",
"error": "Đã xảy ra lỗi", "error": "Đã xảy ra lỗi",
...@@ -57,6 +58,7 @@ ...@@ -57,6 +58,7 @@
"simulations": "Mô phỏng Kịch bản Tài chính (What-if)", "simulations": "Mô phỏng Kịch bản Tài chính (What-if)",
"anomalies": "Phát hiện Chi tiêu Bất thường", "anomalies": "Phát hiện Chi tiêu Bất thường",
"subscriptions": "Hóa đơn & Dịch vụ Định kỳ", "subscriptions": "Hóa đơn & Dịch vụ Định kỳ",
"recurringTransactions": "Giao dịch Tự động Định kỳ",
"query": "Truy vấn Thông minh DSL", "query": "Truy vấn Thông minh DSL",
"notifications": "Thông báo & nhắc nhở", "notifications": "Thông báo & nhắc nhở",
"aiAssistant": "Trợ lý tài chính AI" "aiAssistant": "Trợ lý tài chính AI"
...@@ -215,7 +217,11 @@ ...@@ -215,7 +217,11 @@
"header": "Tài Khoản", "header": "Tài Khoản",
"editAvatar": "Chỉnh ảnh đại diện", "editAvatar": "Chỉnh ảnh đại diện",
"notSet": "Chưa thiết lập", "notSet": "Chưa thiết lập",
"tabs": { "info": "Thông Tin", "password": "Mật Khẩu", "devices": "Thiết Bị" }, "tabs": {
"info": "Thông Tin",
"password": "Mật Khẩu",
"devices": "Thiết Bị"
},
"personalInfo": "Thông tin cá nhân", "personalInfo": "Thông tin cá nhân",
"fullName": "Họ và tên", "fullName": "Họ và tên",
"fullNamePlaceholder": "Nhập họ và tên...", "fullNamePlaceholder": "Nhập họ và tên...",
...@@ -357,8 +363,21 @@ ...@@ -357,8 +363,21 @@
"defaultTitle": "Đặt làm ví mặc định", "defaultTitle": "Đặt làm ví mặc định",
"defaultHint": "Ví đầu tiên luôn tự động trở thành ví mặc định." "defaultHint": "Ví đầu tiên luôn tự động trở thành ví mặc định."
}, },
"icons": { "wallet": "Ví", "cash": "Tiền mặt", "bank": "Ngân hàng", "card": "Thẻ", "savings": "Tiết kiệm" }, "icons": {
"sort": { "createdDesc": "Mới tạo gần đây", "updatedDesc": "Mới cập nhật", "nameAsc": "Tên A → Z", "nameDesc": "Tên Z → A", "balanceDesc": "Số dư cao nhất", "balanceAsc": "Số dư thấp nhất" } "wallet": "Ví",
"cash": "Tiền mặt",
"bank": "Ngân hàng",
"card": "Thẻ",
"savings": "Tiết kiệm"
},
"sort": {
"createdDesc": "Mới tạo gần đây",
"updatedDesc": "Mới cập nhật",
"nameAsc": "Tên A → Z",
"nameDesc": "Tên Z → A",
"balanceDesc": "Số dư cao nhất",
"balanceAsc": "Số dư thấp nhất"
}
}, },
"category": { "category": {
"header": "Quản lý danh mục", "header": "Quản lý danh mục",
...@@ -760,8 +779,16 @@ ...@@ -760,8 +779,16 @@
"header": "Style Guide & Design System", "header": "Style Guide & Design System",
"title": "Hệ thống giao diện Claymorphism", "title": "Hệ thống giao diện Claymorphism",
"subtitle": "Design token thích ứng sáng/tối cho giao diện đất sét mềm mại của FinWise", "subtitle": "Design token thích ứng sáng/tối cho giao diện đất sét mềm mại của FinWise",
"tabs": { "overview": "Tổng Quan", "income": "Thu Nhập", "expense": "Chi Tiêu" }, "tabs": {
"currencies": { "vnd": "Việt Nam Đồng (VND)", "usd": "Đô la Mỹ (USD)", "eur": "Đồng Euro (EUR)" }, "overview": "Tổng Quan",
"income": "Thu Nhập",
"expense": "Chi Tiêu"
},
"currencies": {
"vnd": "Việt Nam Đồng (VND)",
"usd": "Đô la Mỹ (USD)",
"eur": "Đồng Euro (EUR)"
},
"typographyColors": "1. Kiểu chữ & Màu sắc", "typographyColors": "1. Kiểu chữ & Màu sắc",
"typographyScale": "Thang kiểu chữ", "typographyScale": "Thang kiểu chữ",
"typeH1": "clay-title-h1 (32px / Baloo 2):", "typeH1": "clay-title-h1 (32px / Baloo 2):",
...@@ -1125,7 +1152,11 @@ ...@@ -1125,7 +1152,11 @@
"overview": "Trung tâm tài chính", "overview": "Trung tâm tài chính",
"unreadCount": "thông báo chưa đọc", "unreadCount": "thông báo chưa đọc",
"unread": "Chưa đọc", "unread": "Chưa đọc",
"tabs": { "inbox": "Thông báo", "reminders": "Nhắc nhở", "settings": "Tùy chọn" }, "tabs": {
"inbox": "Thông báo",
"reminders": "Nhắc nhở",
"settings": "Tùy chọn"
},
"list": "Thông báo gần đây", "list": "Thông báo gần đây",
"syncing": "Đang cập nhật...", "syncing": "Đang cập nhật...",
"loading": "Đang tải thông báo...", "loading": "Đang tải thông báo...",
...@@ -1144,11 +1175,20 @@ ...@@ -1144,11 +1175,20 @@
"deleteSuccess": "Đã xóa thông báo.", "deleteSuccess": "Đã xóa thông báo.",
"actionFailed": "Không thể cập nhật thông báo. Vui lòng thử lại.", "actionFailed": "Không thể cập nhật thông báo. Vui lòng thử lại.",
"filter": { "filter": {
"all": "Tất cả", "unread": "Chưa đọc", "read": "Đã đọc", "all": "Tất cả",
"type": "Loại sự kiện", "allTypes": "Tất cả loại", "unread": "Chưa đọc",
"priority": "Mức ưu tiên", "allPriorities": "Tất cả mức" "read": "Đã đọc",
"type": "Loại sự kiện",
"allTypes": "Tất cả loại",
"priority": "Mức ưu tiên",
"allPriorities": "Tất cả mức"
},
"priority": {
"LOW": "Thấp",
"NORMAL": "Bình thường",
"HIGH": "Quan trọng",
"CRITICAL": "Khẩn cấp"
}, },
"priority": { "LOW": "Thấp", "NORMAL": "Bình thường", "HIGH": "Quan trọng", "CRITICAL": "Khẩn cấp" },
"type": { "type": {
"BUDGET_NEAR_LIMIT": "Sắp vượt ngân sách", "BUDGET_NEAR_LIMIT": "Sắp vượt ngân sách",
"BUDGET_EXCEEDED": "Vượt ngân sách", "BUDGET_EXCEEDED": "Vượt ngân sách",
...@@ -1177,16 +1217,33 @@ ...@@ -1177,16 +1217,33 @@
"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.",
"budget": "Cảnh báo ngân sách", "budgetHint": "Sắp chạm hoặc vượt hạn mức chi tiêu.", "budget": "Cảnh báo ngân sách",
"savingGoal": "Tiến độ tiết kiệm", "savingGoalHint": "Tiến độ và thời hạn của mục tiêu.", "budgetHint": "Sắp chạm hoặc vượt hạn mức chi tiêu.",
"reminder": "Khoản thanh toán & nhắc nhở", "reminderHint": "Lịch nhắc cá nhân và khoản chi định kỳ.", "savingGoal": "Tiến độ tiết kiệm",
"unusual": "Giao dịch bất thường", "unusualHint": "Khoản chi lớn bất thường so với lịch sử.", "savingGoalHint": "Tiến độ và thời hạn của mục tiêu.",
"channelTitle": "Kênh nhận thông báo", "channelHint": "Chọn ít nhất một kênh nhận cảnh báo.", "reminder": "Khoản thanh toán & nhắc nhở",
"reminderHint": "Lịch nhắc cá nhân và khoản chi định kỳ.",
"unusual": "Giao dịch bất thường",
"unusualHint": "Khoản chi lớn bất thường so với lịch sử.",
"channelTitle": "Kênh nhận thông báo",
"channelHint": "Chọn ít nhất một kênh nhận cảnh báo.",
"channelRequired": "Luôn cần ít nhất một kênh. Zalo và Push đã được chuẩn bị cho giai đoạn kết nối nhà cung cấp.", "channelRequired": "Luôn cần ít nhất một kênh. Zalo và Push đã được chuẩn bị cho giai đoạn kết nối nhà cung cấp.",
"save": "Lưu tùy chọn", "saveSuccess": "Đã lưu tùy chọn thông báo.", "saveFailed": "Không thể lưu tùy chọn thông báo." "save": "Lưu tùy chọn",
"saveSuccess": "Đã lưu tùy chọn thông báo.",
"saveFailed": "Không thể lưu tùy chọn thông báo."
}, },
"channel": { "IN_APP": "Trong ứng dụng", "EMAIL": "Email", "ZALO": "Zalo", "PUSH": "Push" }, "channel": {
"channelHint": { "IN_APP": "Hộp thư và badge", "EMAIL": "Gửi tới email tài khoản", "ZALO": "Kết nối sắp tới", "PUSH": "Kết nối sắp tới" } "IN_APP": "Trong ứng dụng",
"EMAIL": "Email",
"ZALO": "Zalo",
"PUSH": "Push"
},
"channelHint": {
"IN_APP": "Hộp thư và badge",
"EMAIL": "Gửi tới email tài khoản",
"ZALO": "Kết nối sắp tới",
"PUSH": "Kết nối sắp tới"
}
}, },
"reminder": { "reminder": {
"createShort": "Tạo lịch", "createShort": "Tạo lịch",
...@@ -1201,31 +1258,68 @@ ...@@ -1201,31 +1258,68 @@
"createFirst": "Tạo nhắc nhở đầu tiên", "createFirst": "Tạo nhắc nhở đầu tiên",
"syncing": "Đang cập nhật...", "syncing": "Đang cập nhật...",
"paginationLabel": "Phân trang nhắc nhở", "paginationLabel": "Phân trang nhắc nhở",
"active": "Đang bật", "paused": "Tạm dừng", "pause": "Tạm dừng", "resume": "Bật lại", "active": "Đang bật",
"schedule": "Chu kỳ", "nextTrigger": "Lần nhắc tiếp theo", "noNextTrigger": "Chưa có lịch tiếp theo", "paused": "Tạm dừng",
"createSuccess": "Đã tạo nhắc nhở.", "updateSuccess": "Đã cập nhật nhắc nhở.", "pause": "Tạm dừng",
"pauseSuccess": "Đã tạm dừng nhắc nhở.", "resumeSuccess": "Đã bật lại nhắc nhở.", "resume": "Bật lại",
"schedule": "Chu kỳ",
"nextTrigger": "Lần nhắc tiếp theo",
"noNextTrigger": "Chưa có lịch tiếp theo",
"createSuccess": "Đã tạo nhắc nhở.",
"updateSuccess": "Đã cập nhật nhắc nhở.",
"pauseSuccess": "Đã tạm dừng nhắc nhở.",
"resumeSuccess": "Đã bật lại nhắc nhở.",
"saveFailed": "Không thể lưu nhắc nhở. Hãy kiểm tra lịch và thử lại.", "saveFailed": "Không thể lưu nhắc nhở. Hãy kiểm tra lịch và thử lại.",
"deleteTitle": "Xóa nhắc nhở?", "deleteHint": "Lịch nhắc này sẽ bị xóa vĩnh viễn.", "deleteTitle": "Xóa nhắc nhở?",
"deleteSuccess": "Đã xóa nhắc nhở.", "deleteFailed": "Không thể xóa nhắc nhở.", "deleteHint": "Lịch nhắc này sẽ bị xóa vĩnh viễn.",
"type": { "GENERAL": "Nhắc nhở chung", "RECURRING_PAYMENT": "Thanh toán định kỳ" }, "deleteSuccess": "Đã xóa nhắc nhở.",
"deleteFailed": "Không thể xóa nhắc nhở.",
"type": {
"GENERAL": "Nhắc nhở chung",
"RECURRING_PAYMENT": "Thanh toán định kỳ"
},
"frequency": { "frequency": {
"ONCE": "Một lần", "DAILY": "Mỗi {{count}} ngày", "WEEKLY": "Mỗi {{count}} tuần", "ONCE": "Một lần",
"MONTHLY": "Mỗi {{count}} tháng", "YEARLY": "Mỗi {{count}} năm" "DAILY": "Mỗi {{count}} ngày",
"WEEKLY": "Mỗi {{count}} tuần",
"MONTHLY": "Mỗi {{count}} tháng",
"YEARLY": "Mỗi {{count}} năm"
},
"frequencyOption": {
"ONCE": "Một lần",
"DAILY": "Hàng ngày",
"WEEKLY": "Hàng tuần",
"MONTHLY": "Hàng tháng",
"YEARLY": "Hàng năm"
},
"filter": {
"type": "Loại nhắc nhở",
"allTypes": "Tất cả loại",
"status": "Trạng thái",
"allStatuses": "Tất cả trạng thái"
}, },
"frequencyOption": { "ONCE": "Một lần", "DAILY": "Hàng ngày", "WEEKLY": "Hàng tuần", "MONTHLY": "Hàng tháng", "YEARLY": "Hàng năm" },
"filter": { "type": "Loại nhắc nhở", "allTypes": "Tất cả loại", "status": "Trạng thái", "allStatuses": "Tất cả trạng thái" },
"form": { "form": {
"type": "Loại nhắc nhở", "title": "Tiêu đề", "titlePlaceholder": "Ví dụ: Thanh toán tiền điện", "type": "Loại nhắc nhở",
"message": "Ghi chú", "messagePlaceholder": "Số tiền, nhà cung cấp hoặc thông tin cần nhớ...", "title": "Tiêu đề",
"remindAt": "Thời gian bắt đầu", "frequency": "Tần suất", "repeatInterval": "Lặp lại sau mỗi", "titlePlaceholder": "Ví dụ: Thanh toán tiền điện",
"endAt": "Kết thúc lặp (không bắt buộc)", "active": "Bật nhắc nhở", "activeHint": "Thông báo sẽ được tạo khi lịch đến hạn." "message": "Ghi chú",
"messagePlaceholder": "Số tiền, nhà cung cấp hoặc thông tin cần nhớ...",
"remindAt": "Thời gian bắt đầu",
"frequency": "Tần suất",
"repeatInterval": "Lặp lại sau mỗi",
"endAt": "Kết thúc lặp (không bắt buộc)",
"active": "Bật nhắc nhở",
"activeHint": "Thông báo sẽ được tạo khi lịch đến hạn."
}, },
"validation": { "validation": {
"titleRequired": "Vui lòng nhập tiêu đề", "titleMax": "Tiêu đề tối đa 160 ký tự", "titleRequired": "Vui lòng nhập tiêu đề",
"messageMax": "Ghi chú tối đa 2000 ký tự", "remindAtRequired": "Vui lòng chọn thời gian nhắc", "titleMax": "Tiêu đề tối đa 160 ký tự",
"interval": "Chu kỳ lặp phải từ 1 đến 365", "future": "Lịch nhắc một lần phải nằm trong tương lai", "messageMax": "Ghi chú tối đa 2000 ký tự",
"onceNoEnd": "Lịch nhắc một lần không có ngày kết thúc", "endAfter": "Thời gian kết thúc phải sau thời gian bắt đầu" "remindAtRequired": "Vui lòng chọn thời gian nhắc",
"interval": "Chu kỳ lặp phải từ 1 đến 365",
"future": "Lịch nhắc một lần phải nằm trong tương lai",
"onceNoEnd": "Lịch nhắc một lần không có ngày kết thúc",
"endAfter": "Thời gian kết thúc phải sau thời gian bắt đầu"
} }
}, },
"ai": { "ai": {
...@@ -1369,6 +1463,7 @@ ...@@ -1369,6 +1463,7 @@
"simulations": "Mô phỏng What-if", "simulations": "Mô phỏng What-if",
"anomalies": "Cảnh báo Bất thường", "anomalies": "Cảnh báo Bất thường",
"subscriptions": "Hóa đơn Định kỳ", "subscriptions": "Hóa đơn Định kỳ",
"recurringTransactions": "Giao dịch Tự động",
"coreModules": "Danh mục Nghiệp vụ", "coreModules": "Danh mục Nghiệp vụ",
"wallets": "Ví & Số dư", "wallets": "Ví & Số dư",
"transactions": "Giao dịch", "transactions": "Giao dịch",
...@@ -1453,7 +1548,7 @@ ...@@ -1453,7 +1548,7 @@
"adjustmentName": "Tên biến động", "adjustmentName": "Tên biến động",
"adjustmentAmount": "Số tiền mỗi tháng", "adjustmentAmount": "Số tiền mỗi tháng",
"percentageChange": "Thay đổi {{percentage}}%", "percentageChange": "Thay đổi {{percentage}}%",
"descPlaceholder": "Mô tả (ví dụ: Tăng lương 20%, Tiền thuê nhà mới...)", "descPlaceholder": "Mô tả",
"amountPlaceholder": "Số tiền mỗi tháng", "amountPlaceholder": "Số tiền mỗi tháng",
"addAdjustmentBtn": "Thêm vào kịch bản", "addAdjustmentBtn": "Thêm vào kịch bản",
"runSimulationBtn": "Chạy Mô phỏng Kịch bản", "runSimulationBtn": "Chạy Mô phỏng Kịch bản",
...@@ -1538,6 +1633,81 @@ ...@@ -1538,6 +1633,81 @@
"convertSuccess": "Đã tạo nhắc nhở cho {{name}} thành công!", "convertSuccess": "Đã tạo nhắc nhở cho {{name}} thành công!",
"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": {
"pageTitle": "Giao dịch Tự động Định kỳ",
"bannerTitle": "Tự động ghi các khoản lặp lại",
"bannerDesc": "FinWise tự ghi khoản thu hoặc chi theo lịch bạn chọn. Tính năng này thay đổi số dư ví trong ứng dụng nhưng không thực hiện thanh toán ngân hàng bên ngoài.",
"createButton": "Tạo giao dịch định kỳ",
"createTitle": "Giao dịch định kỳ mới",
"editTitle": "Sửa các kỳ giao dịch tương lai",
"detailsTitle": "Chi tiết lịch giao dịch",
"previewTitle": "Các ngày sắp tới",
"historyTitle": "Lịch sử thực thi",
"noFutureDates": "Lịch này không còn ngày thực thi nào.",
"noHistory": "Chưa có kỳ giao dịch nào được xử lý.",
"detailsError": "Không thể tải chi tiết lịch giao dịch.",
"fetchError": "Không thể tải các giao dịch định kỳ.",
"emptyTitle": "Chưa có giao dịch tự động",
"emptyDesc": "Tạo lịch cho tiền thuê nhà, lương, gói đăng ký hoặc khoản lặp lại bất kỳ.",
"active": "Đang chạy",
"paused": "Tạm dừng",
"cycle": "Chu kỳ",
"nextRun": "Lần ghi tiếp theo",
"pause": "Tạm dừng",
"resume": "Tiếp tục",
"actionError": "Không thể cập nhật lịch. Vui lòng kiểm tra ví và danh mục.",
"saveSuccess": "Đã lưu giao dịch định kỳ.",
"deleteSuccess": "Đã xóa lịch định kỳ. Các giao dịch đã ghi vẫn được giữ nguyên.",
"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.",
"attempts": "{{count}} lần thử",
"frequency": {
"DAILY": "Hàng ngày",
"WEEKLY": "Hàng tuần",
"MONTHLY": "Hàng tháng",
"YEARLY": "Hàng năm"
},
"policy": {
"SKIP": "Bỏ qua các ngày đã lỡ",
"CATCH_UP": "Ghi bù dần các ngày đã lỡ"
},
"status": {
"POSTED": "Đã ghi",
"FAILED": "Thất bại"
},
"form": {
"type": "Loại giao dịch",
"amount": "Số tiền",
"wallet": "Ví",
"category": "Danh mục",
"selectWallet": "Chọn ví",
"selectCategory": "Chọn danh mục",
"description": "Mô tả",
"location": "Địa điểm",
"frequency": "Tần suất",
"repeatInterval": "Lặp lại mỗi",
"anchorDate": "Ngày bắt đầu",
"endDate": "Ngày kết thúc",
"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."
},
"validation": {
"amount": "Nhập số tiền hợp lệ lớn hơn 0.",
"wallet": "Vui lòng chọn ví.",
"category": "Vui lòng chọn danh mục.",
"anchorDate": "Vui lòng chọn ngày bắt đầu.",
"endDate": "Ngày kết thúc không được trước ngày bắt đầu."
},
"subscription": {
"button": "Tự động ghi giao dịch",
"title": "Tự động hóa {{name}}?",
"description": "Chọn ví sẽ được điều chỉnh khi khoản chi định kỳ này được ghi nhận.",
"wallet": "Ví thanh toán",
"confirm": "Bật tự động",
"success": "Đã tạo giao dịch tự động cho {{name}}.",
"walletRequired": "Vui lòng chọn ví trước khi kích hoạt."
}
},
"query": { "query": {
"pageTitle": "Truy vấn Thông minh DSL", "pageTitle": "Truy vấn Thông minh DSL",
"bannerTitle": "Hỏi đáp Tài chính bằng Ngôn ngữ Tự nhiên", "bannerTitle": "Hỏi đáp Tài chính bằng Ngôn ngữ Tự nhiên",
......
...@@ -16,14 +16,15 @@ export function formatWalletBalance(balance: string, currency: string, locale: s ...@@ -16,14 +16,15 @@ export function formatWalletBalance(balance: string, currency: string, locale: s
return `${balance} ${currency}`; return `${balance} ${currency}`;
} }
const isVnd = currency === "VND";
try { try {
return new Intl.NumberFormat(locale, { return new Intl.NumberFormat(locale, {
style: "currency", style: "currency",
currency, currency,
maximumFractionDigits: currency === "VND" ? 0 : 2, maximumFractionDigits: isVnd ? 0 : 2,
}).format(numericBalance); }).format(numericBalance);
} catch { } catch {
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(numericBalance)} ${currency}`; return `${new Intl.NumberFormat(locale, { maximumFractionDigits: isVnd ? 0 : 2 }).format(numericBalance)} ${currency}`;
} }
} }
...@@ -37,6 +38,6 @@ export function groupBalancesByCurrency(wallets: Wallet[]): Array<{ currency: st ...@@ -37,6 +38,6 @@ export function groupBalancesByCurrency(wallets: Wallet[]): Array<{ currency: st
return Array.from(balances.entries()).map(([currency, balance]) => ({ return Array.from(balances.entries()).map(([currency, balance]) => ({
currency, currency,
balance: balance.toFixed(2), balance: currency === "VND" ? Math.round(balance).toString() : balance.toFixed(2),
})); }));
} }
...@@ -142,6 +142,14 @@ function HomePage() { ...@@ -142,6 +142,14 @@ function HomePage() {
> >
{t("home.transactions")} {t("home.transactions")}
</Button> </Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/recurring-transactions")}
className="gap-2"
>
{t("home.recurringTransactions")}
</Button>
<Button <Button
variant="secondary" variant="secondary"
fullWidth fullWidth
......
import React from "react";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
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 { RecurringTransactionSchedule } from "@/types/recurring-transaction";
interface Props {
schedule: RecurringTransactionSchedule | null;
onClose: () => void;
}
export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, onClose }) => {
const { t, intlLocale } = useI18n();
const id = schedule?.id || "";
const preview = useRecurringPreview(id, Boolean(schedule));
const history = useRecurringHistory(id, Boolean(schedule));
const formatDate = (value: string) => formatBusinessDate(value, intlLocale, {
day: "2-digit", month: "2-digit", year: "numeric",
});
return (
<Modal isOpen={Boolean(schedule)} onClose={onClose} title={t("recurringTransactions.detailsTitle")}>
<div className="space-y-5">
<section className="space-y-2">
<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.isError && <p className="text-xs text-clay-expense">{t("recurringTransactions.detailsError")}</p>}
{preview.data && (
<div className="flex flex-wrap gap-2">
{preview.data.data.dates.map((date) => <Badge key={date} type="primary">{formatDate(date)}</Badge>)}
{preview.data.data.dates.length === 0 && <p className="text-xs text-clay-text-muted">{t("recurringTransactions.noFutureDates")}</p>}
</div>
)}
</section>
<section className="space-y-2">
<h4 className="font-baloo text-sm font-bold text-clay-text">{t("recurringTransactions.historyTitle")}</h4>
{history.isLoading && <div className="space-y-2"><div className="h-16 animate-pulse rounded-clay bg-clay-bg shadow-clay-pressed" /><div className="h-16 animate-pulse rounded-clay bg-clay-bg shadow-clay-pressed" /></div>}
{history.isError && <p className="text-xs text-clay-expense">{t("recurringTransactions.detailsError")}</p>}
{history.data?.data.length === 0 && <p className="text-xs text-clay-text-muted">{t("recurringTransactions.noHistory")}</p>}
<div className="space-y-2">
{history.data?.data.map((occurrence) => (
<Card key={occurrence.id} className="flex items-start justify-between gap-3 p-3 shadow-clay-pressed">
<div>
<p className="font-nunito text-sm font-bold text-clay-text">{formatDate(occurrence.scheduledFor)}</p>
{occurrence.failureMessage && <p className="mt-1 text-xs text-clay-expense">{occurrence.failureMessage}</p>}
<p className="mt-1 text-[10px] text-clay-text-muted">{t("recurringTransactions.attempts", { count: occurrence.attemptCount })}</p>
</div>
<Badge type={occurrence.status === "POSTED" ? "income" : "expense"}>{t(`recurringTransactions.status.${occurrence.status}`)}</Badge>
</Card>
))}
</div>
</section>
</div>
</Modal>
);
};
import React, { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { useI18n, TranslationFunction } from "@/i18n";
import { useWallets } from "@/hooks/use-wallets";
import { useCategoryTree } from "@/hooks/use-categories";
import { getCategoryDisplayName } from "@/lib/category-format";
import { todayInBusinessTime } from "@/lib/business-time";
import { CategoryTreeNode, TransactionType } from "@/types/category";
import {
RecurringTransactionInput,
RecurringTransactionSchedule,
} from "@/types/recurring-transaction";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function schema(t: TranslationFunction) {
return z.object({
type: z.enum(["INCOME", "EXPENSE"]),
amount: z.string().regex(amountPattern, t("recurringTransactions.validation.amount"))
.refine((value) => Number(value) > 0, t("recurringTransactions.validation.amount")),
walletId: z.string().min(1, t("recurringTransactions.validation.wallet")),
categoryId: z.string().min(1, t("recurringTransactions.validation.category")),
description: z.string().max(500),
location: z.string().max(255),
frequency: z.enum(["DAILY", "WEEKLY", "MONTHLY", "YEARLY"]),
repeatInterval: z.number().int().min(1).max(365),
anchorDate: z.string().min(1, t("recurringTransactions.validation.anchorDate")),
endDate: z.string(),
missedRunPolicy: z.enum(["SKIP", "CATCH_UP"]),
}).superRefine((values, context) => {
if (values.endDate && values.endDate < values.anchorDate) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: t("recurringTransactions.validation.endDate"),
});
}
});
}
type FormValues = z.infer<ReturnType<typeof schema>>;
interface Props {
isOpen: boolean;
schedule?: RecurringTransactionSchedule;
isSubmitting: boolean;
onClose: () => void;
onSubmit: (input: RecurringTransactionInput) => void;
}
interface FlatCategory { node: CategoryTreeNode; depth: number }
function flatten(nodes: CategoryTreeNode[], depth = 0): FlatCategory[] {
return nodes.flatMap((node) => [
{ node, depth },
...flatten(node.children, depth + 1),
]);
}
function numberSeparators(locale: string) {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmount(value: string, locale: string) {
if (!value) return "";
const [integer, fraction] = value.split(".");
const { group, decimal } = numberSeparators(locale);
const grouped = integer.replace(/\B(?=(\d{3})+(?!\d))/g, group);
return fraction === undefined ? grouped : `${grouped}${decimal}${fraction}`;
}
function parseAmount(value: string, locale: string) {
const { group, decimal } = numberSeparators(locale);
const normalized = value.trim().split(group).join("").replace(decimal, ".");
const [integer = "", fraction] = normalized.split(".", 2);
const integerDigits = integer.replace(/\D/g, "").slice(0, 16).replace(/^0+(?=\d)/, "") || "0";
const fractionDigits = fraction?.replace(/\D/g, "").slice(0, 2);
return fraction === undefined ? integerDigits : `${integerDigits}.${fractionDigits}`;
}
function defaults(schedule?: RecurringTransactionSchedule): FormValues {
return {
type: schedule?.type || "EXPENSE",
amount: schedule?.amount || "",
walletId: schedule?.walletId || "",
categoryId: schedule?.categoryId || "",
description: schedule?.description || "",
location: schedule?.location || "",
frequency: schedule?.frequency || "MONTHLY",
repeatInterval: schedule?.repeatInterval || 1,
anchorDate: schedule?.anchorDate || todayInBusinessTime(),
endDate: schedule?.endDate || "",
missedRunPolicy: schedule?.missedRunPolicy || "SKIP",
};
}
export const RecurringTransactionFormModal: React.FC<Props> = ({
isOpen,
schedule,
isSubmitting,
onClose,
onSubmit,
}) => {
const { t, intlLocale } = useI18n();
const formSchema = useMemo(() => schema(t), [t]);
const defaultValues = useMemo(() => defaults(schedule), [schedule]);
const walletsQuery = useWallets({ includeArchived: false, sortBy: "name", order: "asc", page: 1, limit: 100 });
const categoriesQuery = useCategoryTree({ source: "ALL", includeArchived: false });
const { control, register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues,
});
const type = watch("type") as TransactionType;
const categoryId = watch("categoryId");
const anchorDate = watch("anchorDate");
const endDate = watch("endDate");
const formId = schedule ? `edit-recurring-${schedule.id}` : "create-recurring";
useEffect(() => { if (isOpen) reset(defaultValues); }, [defaultValues, isOpen, reset]);
useEffect(() => {
if (schedule || watch("walletId")) return;
const wallets = walletsQuery.data?.data || [];
const preferred = wallets.find((wallet) => wallet.isDefault) || wallets[0];
if (preferred) setValue("walletId", preferred.id, { shouldValidate: true });
}, [schedule, setValue, walletsQuery.data?.data, watch]);
const walletOptions = useMemo(() => [
{ value: "", label: t("recurringTransactions.form.selectWallet") },
...(walletsQuery.data?.data || []).filter((wallet) => !wallet.isArchived).map((wallet) => ({
value: wallet.id,
label: `${wallet.name} (${wallet.currency})`,
})),
], [t, walletsQuery.data?.data]);
const categoryOptions = useMemo(() => [
{ value: "", label: t("recurringTransactions.form.selectCategory") },
...flatten(categoriesQuery.data?.data || [])
.filter(({ node }) => node.type === type && !node.isArchived)
.map(({ node, depth }) => ({
value: node.id,
label: `${"— ".repeat(Math.min(depth, 3))}${getCategoryDisplayName(node, t)}`,
})),
], [categoriesQuery.data?.data, t, type]);
useEffect(() => {
if (categoryId && !categoryOptions.some((option) => option.value === categoryId)) {
setValue("categoryId", "");
}
}, [categoryId, categoryOptions, setValue]);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={schedule ? t("recurringTransactions.editTitle") : t("recurringTransactions.createTitle")}
footer={<>
<Button type="button" variant="ghost" className="px-4 text-sm" disabled={isSubmitting} onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" form={formId} className="px-4 text-sm" disabled={isSubmitting}>{isSubmitting ? t("common.saving") : t("common.save")}</Button>
</>}
>
<form id={formId} className="flex flex-col gap-4" noValidate onSubmit={handleSubmit((values) => onSubmit({
walletId: values.walletId,
categoryId: values.categoryId,
amount: values.amount,
type: values.type,
description: values.description.trim() || null,
location: values.location.trim() || null,
frequency: values.frequency,
repeatInterval: values.repeatInterval,
anchorDate: values.anchorDate,
endDate: values.endDate || null,
missedRunPolicy: values.missedRunPolicy,
...(schedule ? {} : { isActive: true }),
}))}>
<div className="grid grid-cols-2 gap-3">
<Select label={t("recurringTransactions.form.type")} options={[
{ value: "EXPENSE", label: t("transaction.typeExpense") },
{ value: "INCOME", label: t("transaction.typeIncome") },
]} disabled={isSubmitting} {...register("type")} />
<Controller name="amount" control={control} render={({ field }) => (
<Input {...field} label={t("recurringTransactions.form.amount")} inputMode="decimal" error={errors.amount?.message} disabled={isSubmitting} value={formatAmount(field.value, intlLocale)} onChange={(event) => field.onChange(parseAmount(event.target.value, intlLocale))} />
)} />
</div>
<div className="grid grid-cols-2 gap-3">
<Select label={t("recurringTransactions.form.wallet")} options={walletOptions} error={errors.walletId?.message} disabled={isSubmitting || walletsQuery.isLoading} {...register("walletId")} />
<Select label={t("recurringTransactions.form.category")} options={categoryOptions} error={errors.categoryId?.message} disabled={isSubmitting || categoriesQuery.isLoading} {...register("categoryId")} />
</div>
<Input label={t("recurringTransactions.form.description")} maxLength={500} disabled={isSubmitting} {...register("description")} />
<Input label={t("recurringTransactions.form.location")} maxLength={255} disabled={isSubmitting} {...register("location")} />
<div className="grid grid-cols-2 gap-3">
<Select label={t("recurringTransactions.form.frequency")} options={["DAILY", "WEEKLY", "MONTHLY", "YEARLY"].map((value) => ({ value, label: t(`recurringTransactions.frequency.${value}`) }))} disabled={isSubmitting} {...register("frequency")} />
<Input type="number" min={1} max={365} label={t("recurringTransactions.form.repeatInterval")} error={errors.repeatInterval?.message} disabled={isSubmitting} {...register("repeatInterval", { valueAsNumber: true })} />
</div>
<div className="grid grid-cols-2 gap-3">
<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")} />
</div>
<Select label={t("recurringTransactions.form.missedRunPolicy")} options={[
{ value: "SKIP", label: t("recurringTransactions.policy.SKIP") },
{ value: "CATCH_UP", label: t("recurringTransactions.policy.CATCH_UP") },
]} disabled={isSubmitting} {...register("missedRunPolicy")} />
<p className="rounded-clay-sm bg-clay-warning-soft p-3 text-xs font-medium text-clay-text shadow-clay-pressed">
{t("recurringTransactions.form.ledgerNotice")}
</p>
</form>
</Modal>
);
};
import React, { useState } from "react";
import { Header, Page, useSnackbar } from "zmp-ui";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Modal } from "@/components/ui/Modal";
import {
useCreateRecurringTransaction,
useDeleteRecurringTransaction,
usePauseRecurringTransaction,
useRecurringTransactions,
useResumeRecurringTransaction,
useUpdateRecurringTransaction,
} from "@/hooks/use-recurring-transactions";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format";
import {
RecurringTransactionInput,
RecurringTransactionSchedule,
} from "@/types/recurring-transaction";
import { RecurringTransactionDetailsModal } from "./components/RecurringTransactionDetailsModal";
import { RecurringTransactionFormModal } from "./components/RecurringTransactionFormModal";
const RecurringTransactionsPage: React.FC = () => {
const { t, formatCurrency, intlLocale } = useI18n();
const { openSnackbar } = useSnackbar();
const query = useRecurringTransactions({ page: 1, limit: 100 });
const createMutation = useCreateRecurringTransaction();
const updateMutation = useUpdateRecurringTransaction();
const pauseMutation = usePauseRecurringTransaction();
const resumeMutation = useResumeRecurringTransaction();
const deleteMutation = useDeleteRecurringTransaction();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<RecurringTransactionSchedule | undefined>();
const [details, setDetails] = useState<RecurringTransactionSchedule | null>(null);
const [deleting, setDeleting] = useState<RecurringTransactionSchedule | null>(null);
const schedules = query.data?.data || [];
const isSaving = createMutation.isPending || updateMutation.isPending;
const formatDate = (value: string) => formatBusinessDate(value, intlLocale, {
day: "2-digit", month: "2-digit", year: "numeric",
});
const notifyError = () => openSnackbar({ type: "error", text: t("recurringTransactions.actionError") });
const submit = async (input: RecurringTransactionInput) => {
try {
if (editing) await updateMutation.mutateAsync({ id: editing.id, input });
else await createMutation.mutateAsync(input);
setFormOpen(false);
setEditing(undefined);
openSnackbar({ type: "success", text: t("recurringTransactions.saveSuccess") });
} catch { notifyError(); }
};
const toggle = async (schedule: RecurringTransactionSchedule) => {
try {
if (schedule.isActive) await pauseMutation.mutateAsync(schedule.id);
else await resumeMutation.mutateAsync(schedule.id);
} catch { notifyError(); }
};
const remove = async () => {
if (!deleting) return;
try {
await deleteMutation.mutateAsync(deleting.id);
setDeleting(null);
openSnackbar({ type: "success", text: t("recurringTransactions.deleteSuccess") });
} catch { notifyError(); }
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t("recurringTransactions.pageTitle")} showBackIcon />
<div className="mx-auto max-w-md space-y-5 p-4">
<Card className="space-y-3 border border-clay-primary/30 p-5">
<div>
<h2 className="font-baloo text-base font-bold text-clay-primary">{t("recurringTransactions.bannerTitle")}</h2>
<p className="mt-1 text-xs font-medium leading-relaxed text-clay-text-muted">{t("recurringTransactions.bannerDesc")}</p>
</div>
<Button fullWidth className="text-sm" onClick={() => { setEditing(undefined); setFormOpen(true); }}>{t("recurringTransactions.createButton")}</Button>
</Card>
{query.isLoading && <div className="space-y-3">{[0, 1, 2].map((item) => <div key={item} className="h-40 animate-pulse rounded-clay-lg bg-clay-surface shadow-clay-raised" />)}</div>}
{query.isError && <Card className="space-y-3 p-5 text-center"><p className="text-sm font-bold text-clay-expense">{t("recurringTransactions.fetchError")}</p><Button variant="secondary" className="text-sm" onClick={() => void query.refetch()}>{t("common.retry")}</Button></Card>}
{!query.isLoading && !query.isError && schedules.length === 0 && <Card className="p-6 text-center"><p className="font-baloo font-bold text-clay-text">{t("recurringTransactions.emptyTitle")}</p><p className="mt-1 text-xs text-clay-text-muted">{t("recurringTransactions.emptyDesc")}</p></Card>}
<div className="space-y-3">
{schedules.map((schedule) => (
<Card key={schedule.id} className="space-y-4 p-4">
<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" : "default"}>{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)}</p>
</div>
<p className={`font-baloo text-base font-bold ${schedule.type === "INCOME" ? "text-clay-income" : "text-clay-expense"}`}>{formatCurrency(Number(schedule.amount), schedule.wallet.currency)}</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><span className="block text-clay-text-muted">{t("recurringTransactions.nextRun")}</span><b className="text-clay-text">{schedule.nextRunAt ? formatDate(schedule.nextRunAt) : "—"}</b></div>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Button variant="secondary" className="px-2 py-2 text-xs" onClick={() => setDetails(schedule)}>{t("common.detail")}</Button>
<Button variant="secondary" className="px-2 py-2 text-xs" onClick={() => { setEditing(schedule); setFormOpen(true); }}>{t("common.edit")}</Button>
<Button variant="secondary" className="px-2 py-2 text-xs" disabled={pauseMutation.isPending || resumeMutation.isPending} onClick={() => void toggle(schedule)}>{schedule.isActive ? t("recurringTransactions.pause") : t("recurringTransactions.resume")}</Button>
<Button variant="ghost" className="px-2 py-2 text-xs text-clay-expense" onClick={() => setDeleting(schedule)}>{t("common.delete")}</Button>
</div>
</Card>
))}
</div>
</div>
<RecurringTransactionFormModal isOpen={formOpen} schedule={editing} isSubmitting={isSaving} onClose={() => { setFormOpen(false); setEditing(undefined); }} onSubmit={(input) => void submit(input)} />
<RecurringTransactionDetailsModal schedule={details} onClose={() => setDetails(null)} />
<Modal isOpen={Boolean(deleting)} onClose={() => setDeleting(null)} title={t("recurringTransactions.deleteTitle")} footer={<><Button variant="ghost" className="text-sm" onClick={() => setDeleting(null)}>{t("common.cancel")}</Button><Button className="bg-clay-expense text-sm" disabled={deleteMutation.isPending} onClick={() => void remove()}>{t("common.delete")}</Button></>}>
<p className="text-sm text-clay-text">{t("recurringTransactions.deleteConfirm")}</p>
</Modal>
</Page>
);
};
export default RecurringTransactionsPage;
...@@ -145,15 +145,15 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({ ...@@ -145,15 +145,15 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
type="button" type="button"
variant={adjustmentType === "RECURRING_INCOME" ? "primary" : "secondary"} variant={adjustmentType === "RECURRING_INCOME" ? "primary" : "secondary"}
onClick={() => setValue("type", "RECURRING_INCOME")} onClick={() => setValue("type", "RECURRING_INCOME")}
className="px-2 py-1.5 text-[11px]" className="px-2 py-1.5 text-xs"
> >
{t("simulations.income")} {t("simulations.income")}
</Button> </Button>
<Button <Button
type="button" type="button"
variant={adjustmentType === "RECURRING_EXPENSE" ? "primary" : "secondary"} variant={adjustmentType === "RECURRING_EXPENSE" ? "primary" : "secondary"}
onClick={() => setValue("type", "RECURRING_EXPENSE")} onClick={() => setValue("type", "RECURRING_EXPENSE")}
className="px-2 py-1.5 text-[11px]" className="px-2 py-1.5 text-xs"
> >
{t("simulations.expense")} {t("simulations.expense")}
</Button> </Button>
......
...@@ -10,13 +10,17 @@ import { getCategoryDisplayName } from "@/lib/category-format"; ...@@ -10,13 +10,17 @@ import { getCategoryDisplayName } from "@/lib/category-format";
interface SubscriptionCardProps { interface SubscriptionCardProps {
item: DiscoveredSubscription; item: DiscoveredSubscription;
onConvertToReminder: (item: DiscoveredSubscription) => void; onConvertToReminder: (item: DiscoveredSubscription) => void;
onConvertToRecurring: (item: DiscoveredSubscription) => void;
isConverting?: boolean; isConverting?: boolean;
isCreatingRecurring?: boolean;
} }
export const SubscriptionCard: React.FC<SubscriptionCardProps> = ({ export const SubscriptionCard: React.FC<SubscriptionCardProps> = ({
item, item,
onConvertToReminder, onConvertToReminder,
onConvertToRecurring,
isConverting, isConverting,
isCreatingRecurring,
}) => { }) => {
const { t, formatCurrency, formatNumber, intlLocale } = useI18n(); const { t, formatCurrency, formatNumber, intlLocale } = useI18n();
...@@ -83,6 +87,16 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = ({ ...@@ -83,6 +87,16 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = ({
</Button> </Button>
)} )}
</div> </div>
<Button
variant="primary"
fullWidth
onClick={() => onConvertToRecurring(item)}
disabled={isCreatingRecurring}
className="text-xs py-2"
>
{isCreatingRecurring ? t("common.processing") : t("recurringTransactions.subscription.button")}
</Button>
</Card> </Card>
); );
}; };
import React from "react"; 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 { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { useConvertToReminder, useDiscoveredSubscriptions } from "@/hooks/use-subscriptions"; import { useConvertToReminder, useDiscoveredSubscriptions } from "@/hooks/use-subscriptions";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { DiscoveredSubscription } from "@/types/subscription"; import { DiscoveredSubscription } from "@/types/subscription";
import { businessWallTimeToIso } from "@/lib/business-time"; import { businessWallTimeToIso } from "@/lib/business-time";
import { SubscriptionCard } from "./components/SubscriptionCard"; import { SubscriptionCard } from "./components/SubscriptionCard";
import { SubscriptionSkeleton } from "./components/SubscriptionSkeleton"; import { SubscriptionSkeleton } from "./components/SubscriptionSkeleton";
import { useWallets } from "@/hooks/use-wallets";
import { useConvertSubscriptionToRecurring } from "@/hooks/use-recurring-transactions";
const SubscriptionsPage: React.FC = () => { const SubscriptionsPage: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
...@@ -14,6 +19,10 @@ const SubscriptionsPage: React.FC = () => { ...@@ -14,6 +19,10 @@ const SubscriptionsPage: React.FC = () => {
const discoveryQuery = useDiscoveredSubscriptions(); const discoveryQuery = useDiscoveredSubscriptions();
const convertMutation = useConvertToReminder(); const convertMutation = useConvertToReminder();
const recurringMutation = useConvertSubscriptionToRecurring();
const walletsQuery = useWallets({ includeArchived: false, sortBy: "name", order: "asc", page: 1, limit: 100 });
const [recurringCandidate, setRecurringCandidate] = React.useState<DiscoveredSubscription | null>(null);
const [walletId, setWalletId] = React.useState("");
const items = discoveryQuery.data?.data.items || []; const items = discoveryQuery.data?.data.items || [];
const isLoading = discoveryQuery.isLoading; const isLoading = discoveryQuery.isLoading;
...@@ -41,6 +50,36 @@ const SubscriptionsPage: React.FC = () => { ...@@ -41,6 +50,36 @@ const SubscriptionsPage: React.FC = () => {
} }
}; };
React.useEffect(() => {
if (!recurringCandidate || walletId) return;
const wallets = walletsQuery.data?.data || [];
const preferred = wallets.find((wallet) => wallet.isDefault) || wallets[0];
if (preferred) setWalletId(preferred.id);
}, [recurringCandidate, walletId, walletsQuery.data?.data]);
const handleRecurringConvert = async () => {
if (!recurringCandidate) return;
if (!walletId) {
openSnackbar({ type: "error", text: t("recurringTransactions.subscription.walletRequired") });
return;
}
try {
await recurringMutation.mutateAsync({
merchantName: recurringCandidate.merchantName,
walletId,
categoryId: recurringCandidate.categoryId,
amount: recurringCandidate.latestAmount,
frequency: recurringCandidate.frequency,
nextExpectedAt: recurringCandidate.nextExpectedAt,
});
openSnackbar({ type: "success", text: t("recurringTransactions.subscription.success", { name: recurringCandidate.merchantName }) });
setRecurringCandidate(null);
setWalletId("");
} catch {
openSnackbar({ type: "error", text: t("common.error") });
}
};
return ( return (
<Page className="page min-h-screen pb-12 bg-clay-bg"> <Page className="page min-h-screen pb-12 bg-clay-bg">
<Header title={t("subscriptions.pageTitle")} showBackIcon={true} /> <Header title={t("subscriptions.pageTitle")} showBackIcon={true} />
...@@ -91,13 +130,38 @@ const SubscriptionsPage: React.FC = () => { ...@@ -91,13 +130,38 @@ const SubscriptionsPage: React.FC = () => {
key={`${item.merchantName}-${item.categoryId}-${item.frequency}`} key={`${item.merchantName}-${item.categoryId}-${item.frequency}`}
item={item} item={item}
onConvertToReminder={handleConvert} onConvertToReminder={handleConvert}
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}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
</div> </div>
<Modal
isOpen={Boolean(recurringCandidate)}
onClose={() => { setRecurringCandidate(null); setWalletId(""); }}
title={t("recurringTransactions.subscription.title", { name: recurringCandidate?.merchantName || "" })}
footer={<>
<Button variant="ghost" className="text-sm" disabled={recurringMutation.isPending} onClick={() => { setRecurringCandidate(null); setWalletId(""); }}>{t("common.cancel")}</Button>
<Button className="text-sm" disabled={recurringMutation.isPending || !walletId} onClick={() => void handleRecurringConvert()}>{recurringMutation.isPending ? t("common.processing") : t("recurringTransactions.subscription.confirm")}</Button>
</>}
>
<div className="space-y-4">
<p className="text-sm text-clay-text-muted">{t("recurringTransactions.subscription.description")}</p>
<Select
label={t("recurringTransactions.subscription.wallet")}
value={walletId}
onChange={(event) => setWalletId(event.target.value)}
disabled={walletsQuery.isLoading || recurringMutation.isPending}
options={[
{ value: "", label: t("recurringTransactions.form.selectWallet") },
...(walletsQuery.data?.data || []).filter((wallet) => !wallet.isArchived).map((wallet) => ({ value: wallet.id, label: `${wallet.name} (${wallet.currency})` })),
]}
/>
</div>
</Modal>
</Page> </Page>
); );
}; };
......
...@@ -20,28 +20,40 @@ function getNumberSeparators(locale: string): { group: string; decimal: string } ...@@ -20,28 +20,40 @@ function getNumberSeparators(locale: string): { group: string; decimal: string }
}; };
} }
function formatBalanceInput(value: string, locale: string): string { function formatBalanceInput(value: string, locale: string, currency?: string): string {
if (!value || value === "-") { if (!value || value === "-") {
return value; return value;
} }
const isNegative = value.startsWith("-"); const isNegative = value.startsWith("-");
const [integerPart, decimalPart] = value.replace("-", "").split("."); const unsignedValue = value.replace(/^-/, "");
const [integerPart, decimalPart] = unsignedValue.split(".");
const { group, decimal } = getNumberSeparators(locale); const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group); const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
if (currency === "VND") {
return `${isNegative ? "-" : ""}${groupedInteger}`;
}
return `${isNegative ? "-" : ""}${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`; return `${isNegative ? "-" : ""}${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
} }
function parseBalanceInput(value: string, locale: string): string { function parseBalanceInput(value: string, locale: string, currency?: string): string {
const trimmedValue = value.trim(); const trimmedValue = value.trim();
if (!trimmedValue) { if (!trimmedValue || trimmedValue === "-") {
return ""; return trimmedValue;
} }
const { group, decimal } = getNumberSeparators(locale); const { group, decimal } = getNumberSeparators(locale);
const isNegative = trimmedValue.startsWith("-"); const isNegative = trimmedValue.startsWith("-");
const unsignedValue = trimmedValue.replace(/-/g, ""); const unsignedValue = trimmedValue.replace(/^-/, "");
if (currency === "VND") {
const integerDigits = unsignedValue.replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || (integerDigits.length > 0 ? "0" : "");
return `${isNegative ? "-" : ""}${normalizedInteger}`;
}
let integerDisplay = unsignedValue; let integerDisplay = unsignedValue;
let decimalDisplay: string | undefined; let decimalDisplay: string | undefined;
...@@ -63,6 +75,18 @@ function parseBalanceInput(value: string, locale: string): string { ...@@ -63,6 +75,18 @@ function parseBalanceInput(value: string, locale: string): string {
return `${isNegative ? "-" : ""}${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`; return `${isNegative ? "-" : ""}${normalizedInteger}${decimalDisplay !== undefined ? `.${decimalDigits}` : ""}`;
} }
function normalizeInitialBalance(balance?: string, currency?: string): string {
if (!balance) return "0";
const trimmed = balance.trim();
if (currency === "VND" || trimmed.endsWith(".00")) {
const num = Number(trimmed);
if (Number.isFinite(num) && (currency === "VND" || num % 1 === 0)) {
return String(Math.trunc(num));
}
}
return trimmed;
}
const createWalletFormSchema = (t: TranslationFunction) => z.object({ const createWalletFormSchema = (t: TranslationFunction) => z.object({
name: z.string().trim().min(1, t("validation.walletNameRequired")).max(100, t("validation.walletNameMax")), name: z.string().trim().min(1, t("validation.walletNameRequired")).max(100, t("validation.walletNameMax")),
balance: z.string().trim().regex(balancePattern, t("validation.balanceInvalid")), balance: z.string().trim().regex(balancePattern, t("validation.balanceInvalid")),
...@@ -84,10 +108,11 @@ interface WalletFormModalProps { ...@@ -84,10 +108,11 @@ interface WalletFormModalProps {
} }
function getDefaultValues(wallet?: Wallet): WalletFormValues { function getDefaultValues(wallet?: Wallet): WalletFormValues {
const currency = wallet?.currency || "VND";
return { return {
name: wallet?.name || "", name: wallet?.name || "",
balance: wallet?.balance || "0", balance: normalizeInitialBalance(wallet?.balance, currency),
currency: wallet?.currency || "VND", currency,
icon: wallet?.icon || "wallet", icon: wallet?.icon || "wallet",
color: wallet?.color || WALLET_COLORS[0], color: wallet?.color || WALLET_COLORS[0],
description: wallet?.description || "", description: wallet?.description || "",
...@@ -126,12 +151,17 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({ ...@@ -126,12 +151,17 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
const selectedIcon = watch("icon"); const selectedIcon = watch("icon");
const selectedColor = watch("color"); const selectedColor = watch("color");
const currentCurrency = watch("currency")?.trim()?.toUpperCase() || "VND";
const submitForm = (values: WalletFormValues) => { const submitForm = (values: WalletFormValues) => {
const currency = values.currency.trim().toUpperCase();
const balance = values.balance.trim();
const normalizedBalance = currency === "VND" ? normalizeInitialBalance(balance, "VND") : balance;
onSubmit({ onSubmit({
name: values.name.trim(), name: values.name.trim(),
balance: values.balance.trim(), balance: normalizedBalance,
currency: values.currency.trim().toUpperCase(), currency,
icon: values.icon, icon: values.icon,
color: values.color, color: values.color,
description: values.description.trim() || null, description: values.description.trim() || null,
...@@ -174,13 +204,13 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({ ...@@ -174,13 +204,13 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
<Input <Input
{...field} {...field}
label={t("wallet.form.balance")} label={t("wallet.form.balance")}
inputMode="decimal" inputMode={currentCurrency === "VND" ? "numeric" : "decimal"}
placeholder="0" placeholder="0"
error={errors.balance?.message} error={errors.balance?.message}
disabled={isSubmitting} disabled={isSubmitting}
className="text-right tabular-nums" className="text-right tabular-nums"
value={formatBalanceInput(field.value, intlLocale)} value={formatBalanceInput(field.value, intlLocale, currentCurrency)}
onChange={(event) => field.onChange(parseBalanceInput(event.target.value, intlLocale))} onChange={(event) => field.onChange(parseBalanceInput(event.target.value, intlLocale, currentCurrency))}
/> />
)} )}
/> />
......
import { apiClient } from "@/lib/api-client";
import {
ConvertSubscriptionToRecurringInput,
RecurringTransactionHistoryResponse,
RecurringTransactionInput,
RecurringTransactionListResponse,
RecurringTransactionQuery,
RecurringTransactionResponse,
RecurringTransactionSchedule,
UpdateRecurringTransactionInput,
} from "@/types/recurring-transaction";
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T): T {
if (!response.success) throw new Error(response.message || "Recurring transaction request failed");
return response;
}
export const recurringTransactionService = {
async list(query: RecurringTransactionQuery): Promise<RecurringTransactionListResponse> {
const response = await apiClient.get<RecurringTransactionListResponse>("/recurring-transactions", { params: query });
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);
},
async update(id: string, input: UpdateRecurringTransactionInput): Promise<RecurringTransactionResponse<RecurringTransactionSchedule>> {
const response = await apiClient.patch<RecurringTransactionResponse<RecurringTransactionSchedule>>(`/recurring-transactions/${id}`, input);
return ensureSuccess(response.data);
},
async pause(id: string): Promise<RecurringTransactionResponse<RecurringTransactionSchedule>> {
const response = await apiClient.post<RecurringTransactionResponse<RecurringTransactionSchedule>>(`/recurring-transactions/${id}/pause`);
return ensureSuccess(response.data);
},
async resume(id: string): Promise<RecurringTransactionResponse<RecurringTransactionSchedule>> {
const response = await apiClient.post<RecurringTransactionResponse<RecurringTransactionSchedule>>(`/recurring-transactions/${id}/resume`);
return ensureSuccess(response.data);
},
async remove(id: string): Promise<RecurringTransactionResponse<{ id: string }>> {
const response = await apiClient.delete<RecurringTransactionResponse<{ id: string }>>(`/recurring-transactions/${id}`);
return ensureSuccess(response.data);
},
async preview(id: string, count = 6): Promise<RecurringTransactionResponse<{ dates: string[] }>> {
const response = await apiClient.get<RecurringTransactionResponse<{ dates: string[] }>>(`/recurring-transactions/${id}/preview`, { params: { count } });
return ensureSuccess(response.data);
},
async history(id: string): Promise<RecurringTransactionHistoryResponse> {
const response = await apiClient.get<RecurringTransactionHistoryResponse>(`/recurring-transactions/${id}/history`, { params: { page: 1, limit: 20 } });
return ensureSuccess(response.data);
},
async convertSubscription(input: ConvertSubscriptionToRecurringInput): Promise<RecurringTransactionResponse<RecurringTransactionSchedule>> {
const response = await apiClient.post<RecurringTransactionResponse<RecurringTransactionSchedule>>("/subscriptions/convert-to-recurring-transaction", input);
return ensureSuccess(response.data);
},
};
import { TransactionType } from "./category";
export type RecurringTransactionFrequency = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
export type RecurringTransactionMissedRunPolicy = "SKIP" | "CATCH_UP";
export type RecurringTransactionOccurrenceStatus = "POSTED" | "FAILED";
export interface RecurringTransactionSchedule {
id: string;
walletId: string;
categoryId: string;
amount: string;
type: TransactionType;
description: string | null;
location: string | null;
frequency: RecurringTransactionFrequency;
repeatInterval: number;
anchorDate: string;
endDate: string | null;
nextRunAt: string | null;
missedRunPolicy: RecurringTransactionMissedRunPolicy;
lastRunAt: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
wallet: { id: string; name: string; currency: string; isArchived: boolean };
category: {
id: string;
name: string;
type: TransactionType;
icon: string | null;
color: string | null;
isArchived: boolean;
};
}
export interface RecurringTransactionInput {
walletId: string;
categoryId: string;
amount: string;
type: TransactionType;
description?: string | null;
location?: string | null;
frequency: RecurringTransactionFrequency;
repeatInterval: number;
anchorDate: string;
endDate?: string | null;
missedRunPolicy: RecurringTransactionMissedRunPolicy;
isActive?: boolean;
}
export type UpdateRecurringTransactionInput = Partial<Omit<RecurringTransactionInput, "isActive">>;
export interface RecurringTransactionQuery {
isActive?: boolean;
page: number;
limit: number;
}
export interface RecurringTransactionOccurrence {
id: string;
scheduledFor: string;
status: RecurringTransactionOccurrenceStatus;
transactionId: string | null;
failureCode: string | null;
failureMessage: string | null;
attemptCount: number;
createdAt: string;
updatedAt: string;
transaction: {
id: string;
amount: string;
type: TransactionType;
date: string;
} | null;
}
export interface ConvertSubscriptionToRecurringInput {
merchantName: string;
walletId: string;
categoryId: string;
amount: string;
frequency: RecurringTransactionFrequency;
nextExpectedAt: string;
}
export interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface RecurringTransactionResponse<T> {
success: boolean;
message?: string;
data: T;
}
export interface RecurringTransactionListResponse extends RecurringTransactionResponse<RecurringTransactionSchedule[]> {
meta: PaginationMeta;
}
export interface RecurringTransactionHistoryResponse extends RecurringTransactionResponse<RecurringTransactionOccurrence[]> {
meta: PaginationMeta;
}
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