Commit 3bc1d9fe authored by ThinhNC's avatar ThinhNC

feat(debts): sync debt contract status and integrate payment notifications UI

- Align DebtStatus with backend Prisma schema (DRAFT, ACTIVE, OVERDUE, COMPLETED, DEFAULTED, CANCELLED)
- Add DEBT_PAYMENT_DUE to NotificationType and DEBT to NotificationSourceType
- Integrate DEBT_PAYMENT_DUE in NotificationCard surface styling, notification type filter, and i18n locales (vi/en)
- Enhance debt card, modal, and detail views to properly handle overdue status and repayment flows
- Update memory.md with latest UX decisions on modal separation and localization
parent bd119631
...@@ -118,7 +118,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -118,7 +118,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Chuẩn hóa Form và Dữ liệu: Sử dụng React Hook Form kết hợp Zod schema (`createDebtFormSchema`), nhập liệu ngày bằng `LocalizedDateInput`, số tiền gốc và phí phạt được định dạng tự động (`formatAmountInput` / `parseAmountInput`). - Chuẩn hóa Form và Dữ liệu: Sử dụng React Hook Form kết hợp Zod schema (`createDebtFormSchema`), nhập liệu ngày bằng `LocalizedDateInput`, số tiền gốc và phí phạt được định dạng tự động (`formatAmountInput` / `parseAmountInput`).
- Phản hồi trạng thái (Feedback states): Skeleton đất sét chuyên biệt (`DebtSkeleton`), thẻ thông báo lỗi kèm nút thử lại, và thẻ trạng thái rỗng (Empty state) với biểu tượng 3D nổi và nút tạo mới. - Phản hồi trạng thái (Feedback states): Skeleton đất sét chuyên biệt (`DebtSkeleton`), thẻ thông báo lỗi kèm nút thử lại, và thẻ trạng thái rỗng (Empty state) với biểu tượng 3D nổi và nút tạo mới.
- Màn hình chi tiết hợp đồng nợ (`/debts/:id`): Thẻ KPI tiến độ, bảng phân bổ lịch trả nợ (Amortization Schedule) nổi khối, nút thanh toán 1-chạm từng kỳ hạn nguyên tử (`POST /debts/:id/installments/:period/pay`) mở modal thanh toán chọn ví, và nút Tất toán toàn bộ trước hạn (`POST /debts/:id/settle-early`) tự động miễn lãi tương lai. - Màn hình chi tiết hợp đồng nợ (`/debts/:id`): Thẻ KPI tiến độ, bảng phân bổ lịch trả nợ (Amortization Schedule) nổi khối, nút thanh toán 1-chạm từng kỳ hạn nguyên tử (`POST /debts/:id/installments/:period/pay`) mở modal thanh toán chọn ví, và nút Tất toán toàn bộ trước hạn (`POST /debts/:id/settle-early`) tự động miễn lãi tương lai.
- Đầy đủ hỗ trợ đa ngôn ngữ Việt - Anh qua `src/i18n/` và cập nhật tiêu đề trang `DocumentTitle`. - Đầy đủ hỗ trợ đa ngôn ngữ Việt - Anh qua `src/i18n/` và cập nhật tiêu đề trang `DocumentTitle`; toàn bộ schema validation Zod, các thẻ dữ liệu, nút hành động, bảng phân bổ, hộp thoại thanh toán/tất toán, thông báo lỗi/thành công và đơn vị tính (%/năm, %/yr, tháng, months, kỳ, periods) đều được chuẩn hóa qua i18n (`vi.json` và `en.json`), tuyệt đối không còn hardcode chuỗi ký tự thô.
- Tách bạch Modal Tạo Mới và Trình Mô Phỏng: Tại modal "Tạo Khoản Nợ / Cho Vay Mới" (chế độ `create`), giao diện tập trung trực tiếp vào form nhập liệu tinh gọn, ẩn hoàn toàn thanh Tabs và lược bỏ tab "Dự tính lịch trả nợ"; chức năng dự tính lịch trả nợ và live preview API query chỉ kích hoạt khi người dùng mở qua nút "Mô phỏng trả nợ" (chế độ `simulator`).
## Khi cập nhật file này ## Khi cập nhật file này
......
...@@ -1264,6 +1264,7 @@ ...@@ -1264,6 +1264,7 @@
"RECURRING_PAYMENT_DUE": "Payment due", "RECURRING_PAYMENT_DUE": "Payment due",
"UNUSUAL_TRANSACTION": "Unusual transaction", "UNUSUAL_TRANSACTION": "Unusual transaction",
"USER_REMINDER": "Personal reminder", "USER_REMINDER": "Personal reminder",
"DEBT_PAYMENT_DUE": "Debt / Loan due",
"SYSTEM": "System" "SYSTEM": "System"
}, },
"content": { "content": {
...@@ -2558,6 +2559,7 @@ ...@@ -2558,6 +2559,7 @@
"debts": { "debts": {
"title": "Debts & Loans", "title": "Debts & Loans",
"subtitle": "Track liabilities, amortization schedules & net worth", "subtitle": "Track liabilities, amortization schedules & net worth",
"loadFailed": "Failed to load debt contracts list",
"netWorthTitle": "Net Worth", "netWorthTitle": "Net Worth",
"netWorthDesc": "Real Net Worth = Cash in Wallets + Loans Receivable - Debts Payable", "netWorthDesc": "Real Net Worth = Cash in Wallets + Loans Receivable - Debts Payable",
"totalBorrowing": "Total Debts Payable", "totalBorrowing": "Total Debts Payable",
...@@ -2572,15 +2574,21 @@ ...@@ -2572,15 +2574,21 @@
"receivable": "Loans Receivable" "receivable": "Loans Receivable"
}, },
"status": { "status": {
"draft": "Draft",
"active": "Active", "active": "Active",
"completed": "Completed", "completed": "Completed",
"overdue": "Overdue", "overdue": "Overdue",
"settledEarly": "Settled Early" "settledEarly": "Settled Early",
"defaulted": "Defaulted",
"cancelled": "Cancelled"
}, },
"methods": { "methods": {
"reducingBalance": "Reducing Balance", "reducingBalance": "Reducing Balance",
"reducingBalanceShort": "Reducing Balance",
"fixedAnnuity": "Fixed Annuity (Equal Installments)", "fixedAnnuity": "Fixed Annuity (Equal Installments)",
"interestOnly": "Interest Only (Balloon Principal)" "fixedAnnuityShort": "Fixed Annuity",
"interestOnly": "Interest Only (Balloon Principal)",
"interestOnlyShort": "Interest Only"
}, },
"scheduleStatus": { "scheduleStatus": {
"scheduled": "Scheduled", "scheduled": "Scheduled",
...@@ -2595,6 +2603,7 @@ ...@@ -2595,6 +2603,7 @@
"interestRate": "Rate", "interestRate": "Rate",
"term": "Term", "term": "Term",
"months": "months", "months": "months",
"ratePerYear": "{{rate}}%/yr",
"nextDue": "Next Due:", "nextDue": "Next Due:",
"paidPercent": "{{percent}}% Paid" "paidPercent": "{{percent}}% Paid"
}, },
...@@ -2602,64 +2611,107 @@ ...@@ -2602,64 +2611,107 @@
"title": "No debt contracts found", "title": "No debt contracts found",
"desc": "Create a new debt contract or simulate repayment schedules to monitor your financial health." "desc": "Create a new debt contract or simulate repayment schedules to monitor your financial health."
}, },
"validation": {
"nameRequired": "Please enter contract name",
"counterpartyRequired": "Please enter counterparty",
"principalRequired": "Please enter principal amount",
"principalPositive": "Principal amount must be greater than 0",
"interestRateMin": "Interest rate cannot be negative",
"interestRateMax": "Interest rate cannot exceed 100%",
"termMonthsInt": "Term must be an integer",
"termMonthsMin": "Minimum term is 1 month",
"termMonthsMax": "Maximum term is 360 months",
"startDateRequired": "Please select start date",
"notesMax": "Notes cannot exceed 500 characters",
"max100": "Maximum 100 characters"
},
"modal": { "modal": {
"createTitle": "Create New Debt / Loan", "createTitle": "Create New Debt / Loan",
"simulatorTitle": "Debt Amortization Simulator", "simulatorTitle": "Debt Amortization Simulator",
"configTab": "Configuration",
"previewTab": "Preview Schedule",
"name": "Contract Name", "name": "Contract Name",
"namePlaceholder": "e.g. Car Loan, Loan to Friend...", "namePlaceholder": "e.g. Car Loan, Loan to Friend...",
"counterparty": "Counterparty", "counterparty": "Counterparty (Borrower / Lender)",
"counterpartyPlaceholder": "e.g. Techcombank, John Doe...", "counterpartyPlaceholder": "e.g. Techcombank, John Doe...",
"type": "Contract Type", "type": "Contract Type",
"typePayable": "Debts Payable", "typePayable": "Debts Payable",
"typeReceivable": "Loans Receivable", "typeReceivable": "Loans Receivable",
"principal": "Principal Amount (VND)", "principal": "Principal Amount",
"interestRate": "Annual Interest Rate (%/yr)", "interestRate": "Annual Interest Rate (%/yr)",
"rateUnit": "%/yr",
"termMonths": "Term (Months)", "termMonths": "Term (Months)",
"termUnit": "months",
"startDate": "Start Date", "startDate": "Start Date",
"wallet": "Linked Payment Wallet", "wallet": "Linked Payment Wallet",
"selectWallet": "Select a payment wallet...", "selectWallet": "Select a payment wallet...",
"method": "Amortization Method", "method": "Amortization Method",
"notes": "Notes (Optional)", "notes": "Notes (Optional)",
"notesPlaceholder": "Terms, conditions, notes...", "notesPlaceholder": "Terms, conditions, notes...",
"previewTab": "Preview Schedule",
"summary": "Projected Overview", "summary": "Projected Overview",
"totalInterest": "Total Interest", "totalInterest": "Total Projected Interest",
"totalPayment": "Total Payment (Principal + Interest)", "totalPayment": "Total Payment (Principal + Interest)",
"submitBtn": "Save Contract", "submitBtn": "Save Contract",
"submitting": "Creating...", "submitting": "Saving...",
"successToast": "Debt contract and amortization schedule created successfully!" "successToast": "Debt contract and amortization schedule created successfully!",
"createFailed": "Failed to create debt contract",
"calculating": "Calculating schedule...",
"previewEmptyPrompt": "Enter principal and term to preview repayment schedule"
}, },
"detail": { "detail": {
"header": "Debt Contract Details", "header": "Debt Contract Details",
"notFound": "Debt contract information not found",
"loadFailed": "Failed to load debt contract",
"generalInfo": "Contract Overview", "generalInfo": "Contract Overview",
"counterparty": "Counterparty", "counterparty": "Counterparty",
"method": "Method", "method": "Method",
"startDate": "Start Date", "startDate": "Start Date",
"endDate": "End Date", "endDate": "End Date",
"progressTitle": "Repayment Progress", "progressTitle": "Repayment Progress",
"periodsCount": "{{paid}}/{{total}} periods",
"paidPrincipal": "Principal Paid", "paidPrincipal": "Principal Paid",
"paidInterest": "Interest Paid", "paidInterest": "Interest Paid",
"remainingPrincipal": "Remaining Principal", "remainingPrincipal": "Remaining Principal",
"linkedWallet": "Linked Wallet:",
"scheduleTitle": "Amortization Schedule", "scheduleTitle": "Amortization Schedule",
"totalPeriods": "{{count}} periods",
"periodCol": "Period", "periodCol": "Period",
"dueDateCol": "Due Date", "dueDateCol": "Due Date",
"principalCol": "Principal", "principalCol": "Principal",
"interestCol": "Interest", "interestCol": "Interest",
"totalCol": "Total Due", "totalCol": "Total Due",
"remainingCol": "Remaining",
"remainingAfterPeriod": "Balance after period:",
"statusCol": "Status", "statusCol": "Status",
"actionCol": "Action", "actionCol": "Action",
"payInstallmentBtn": "Pay Period", "payNow": "Pay Now",
"paying": "Paying...", "payInstallmentBtn": "Confirm Payment",
"paying": "Processing...",
"paySuccess": "Installment paid successfully!", "paySuccess": "Installment paid successfully!",
"payFailed": "Failed to pay installment",
"payModalTitle": "Pay Period #{{period}}",
"principalDue": "Principal:",
"interestDue": "Interest:",
"totalDue": "Total Due:",
"paymentDate": "Payment Date",
"paymentNotesPlaceholder": "Payment notes...",
"settleEarlyBtn": "Settle Early", "settleEarlyBtn": "Settle Early",
"settleEarlyTitle": "Early Settlement", "settleEarlyTitle": "Early Settlement",
"settleEarlyDesc": "You will settle the full remaining principal and all future interest will be waived.", "settleEarlyDesc": "You will settle the full remaining principal and all future interest will be waived.",
"settlePrincipal": "Principal to settle:",
"settlePenaltyFee": "Prepayment penalty fee:",
"settleTotal": "Total settlement:",
"penaltyFee": "Prepayment Penalty Fee (if any, VND)", "penaltyFee": "Prepayment Penalty Fee (if any, VND)",
"settleDate": "Settlement Date",
"confirmSettleEarly": "Confirm Settlement", "confirmSettleEarly": "Confirm Settlement",
"settleEarlySuccess": "Debt contract settled early successfully!", "settleEarlySuccess": "Debt contract settled early successfully!",
"settleEarlyFailed": "Failed to settle debt early",
"archive": "Archive",
"archiveBtn": "Archive Contract", "archiveBtn": "Archive Contract",
"archiveTitle": "Archive Contract",
"confirmArchive": "Are you sure you want to archive this debt contract?", "confirmArchive": "Are you sure you want to archive this debt contract?",
"archiveSuccess": "Contract archived successfully!" "archiveSuccess": "Contract archived successfully!",
"archiveFailed": "Failed to archive contract"
} }
} }
} }
......
...@@ -1311,6 +1311,7 @@ ...@@ -1311,6 +1311,7 @@
"RECURRING_PAYMENT_DUE": "Thanh toán đến hạn", "RECURRING_PAYMENT_DUE": "Thanh toán đến hạn",
"UNUSUAL_TRANSACTION": "Giao dịch bất thường", "UNUSUAL_TRANSACTION": "Giao dịch bất thường",
"USER_REMINDER": "Nhắc nhở cá nhân", "USER_REMINDER": "Nhắc nhở cá nhân",
"DEBT_PAYMENT_DUE": "Kỳ nợ / Cho vay đến hạn",
"SYSTEM": "Hệ thống" "SYSTEM": "Hệ thống"
}, },
"content": { "content": {
...@@ -2659,6 +2660,7 @@ ...@@ -2659,6 +2660,7 @@
"debts": { "debts": {
"title": "Nợ & Cho Vay", "title": "Nợ & Cho Vay",
"subtitle": "Theo dõi dòng tiền nợ, lịch trả nợ & tài sản ròng", "subtitle": "Theo dõi dòng tiền nợ, lịch trả nợ & tài sản ròng",
"loadFailed": "Không thể tải danh sách hợp đồng nợ",
"netWorthTitle": "Tài Sản Ròng", "netWorthTitle": "Tài Sản Ròng",
"netWorthDesc": "Tổng tài sản thực tế = Ví + Cho vay - Nợ", "netWorthDesc": "Tổng tài sản thực tế = Ví + Cho vay - Nợ",
"totalBorrowing": "Tổng nợ phải trả", "totalBorrowing": "Tổng nợ phải trả",
...@@ -2673,15 +2675,21 @@ ...@@ -2673,15 +2675,21 @@
"receivable": "Cho vay" "receivable": "Cho vay"
}, },
"status": { "status": {
"draft": "Bản nháp",
"active": "Đang hoạt động", "active": "Đang hoạt động",
"completed": "Đã hoàn tất", "completed": "Đã hoàn tất",
"overdue": "Quá hạn", "overdue": "Quá hạn",
"settledEarly": "Tất toán sớm" "settledEarly": "Tất toán sớm",
"defaulted": "Vỡ nợ",
"cancelled": "Đã hủy"
}, },
"methods": { "methods": {
"reducingBalance": "Dư nợ giảm dần", "reducingBalance": "Dư nợ giảm dần",
"reducingBalanceShort": "Dư nợ giảm dần",
"fixedAnnuity": "Niên kim cố định (Trả góp đều)", "fixedAnnuity": "Niên kim cố định (Trả góp đều)",
"interestOnly": "Trả lãi định kỳ, gốc cuối kỳ" "fixedAnnuityShort": "Niên kim cố định",
"interestOnly": "Trả lãi định kỳ, gốc cuối kỳ",
"interestOnlyShort": "Lãi định kỳ"
}, },
"scheduleStatus": { "scheduleStatus": {
"scheduled": "Chờ đến hạn", "scheduled": "Chờ đến hạn",
...@@ -2696,6 +2704,7 @@ ...@@ -2696,6 +2704,7 @@
"interestRate": "Lãi suất", "interestRate": "Lãi suất",
"term": "Kỳ hạn", "term": "Kỳ hạn",
"months": "tháng", "months": "tháng",
"ratePerYear": "{{rate}}%/năm",
"nextDue": "Kỳ tới:", "nextDue": "Kỳ tới:",
"paidPercent": "Đã thanh toán {{percent}}%" "paidPercent": "Đã thanh toán {{percent}}%"
}, },
...@@ -2703,9 +2712,25 @@ ...@@ -2703,9 +2712,25 @@
"title": "Chưa có khoản nợ hay cho vay nào", "title": "Chưa có khoản nợ hay cho vay nào",
"desc": "Tạo khoản nợ mới hoặc mô phỏng lịch trả nợ để theo dõi tài chính minh bạch." "desc": "Tạo khoản nợ mới hoặc mô phỏng lịch trả nợ để theo dõi tài chính minh bạch."
}, },
"validation": {
"nameRequired": "Vui lòng nhập tên khoản nợ",
"counterpartyRequired": "Vui lòng nhập đối tác",
"principalRequired": "Vui lòng nhập số tiền gốc",
"principalPositive": "Số tiền gốc phải lớn hơn 0",
"interestRateMin": "Lãi suất không được âm",
"interestRateMax": "Lãi suất tối đa 100%",
"termMonthsInt": "Thời hạn phải là số nguyên",
"termMonthsMin": "Thời hạn tối thiểu là 1 tháng",
"termMonthsMax": "Thời hạn tối đa 360 tháng",
"startDateRequired": "Vui lòng chọn ngày bắt đầu",
"notesMax": "Ghi chú tối đa 500 ký tự",
"max100": "Tối đa 100 ký tự"
},
"modal": { "modal": {
"createTitle": "Tạo Khoản Nợ / Cho Vay Mới", "createTitle": "Tạo Khoản Nợ / Cho Vay Mới",
"simulatorTitle": "Trình Mô Phỏng Lịch Trả Nợ", "simulatorTitle": "Trình Mô Phỏng Lịch Trả Nợ",
"configTab": "Thiết lập thông số",
"previewTab": "Dự tính lịch trả nợ",
"name": "Tên khoản nợ / cho vay", "name": "Tên khoản nợ / cho vay",
"namePlaceholder": "VD: Vay mua xe, Cho bạn Nam mượn...", "namePlaceholder": "VD: Vay mua xe, Cho bạn Nam mượn...",
"counterparty": "Đối tác (Bên vay / Bên cho vay)", "counterparty": "Đối tác (Bên vay / Bên cho vay)",
...@@ -2713,54 +2738,81 @@ ...@@ -2713,54 +2738,81 @@
"type": "Loại giao dịch", "type": "Loại giao dịch",
"typePayable": "Khoản nợ tôi phải trả", "typePayable": "Khoản nợ tôi phải trả",
"typeReceivable": "Khoản tôi cho vay", "typeReceivable": "Khoản tôi cho vay",
"principal": "Số tiền gốc ban đầu (VNĐ)", "principal": "Số tiền gốc ban đầu",
"interestRate": "Lãi suất (% / năm)", "interestRate": "Lãi suất (% / năm)",
"rateUnit": "%/năm",
"termMonths": "Thời hạn (Số tháng)", "termMonths": "Thời hạn (Số tháng)",
"termUnit": "tháng",
"startDate": "Ngày bắt đầu", "startDate": "Ngày bắt đầu",
"wallet": "Ví thanh toán liên kết", "wallet": "Ví thanh toán liên kết",
"selectWallet": "Chọn ví thanh toán...", "selectWallet": "Chọn ví thanh toán...",
"method": "Phương pháp tính lãi", "method": "Phương pháp tính lãi",
"notes": "Ghi chú thêm (tùy chọn)", "notes": "Ghi chú thêm (tùy chọn)",
"notesPlaceholder": "Điều khoản hợp đồng, thỏa thuận...", "notesPlaceholder": "Điều khoản hợp đồng, thỏa thuận...",
"previewTab": "Bảng dự tính kỳ hạn",
"summary": "Tổng quan dự kiến", "summary": "Tổng quan dự kiến",
"totalInterest": "Tổng lãi dự kiến", "totalInterest": "Tổng lãi dự kiến",
"totalPayment": "Tổng số tiền cả gốc & lãi", "totalPayment": "Tổng số tiền cả gốc & lãi",
"submitBtn": "Lưu Khoản Nợ", "submitBtn": "Lưu Khoản Nợ",
"submitting": "Đang khởi tạo...", "submitting": "Đang lưu...",
"successToast": "Đã tạo hợp đồng nợ và sinh lịch trả nợ thành công!" "successToast": "Đã tạo hợp đồng nợ và sinh lịch trả nợ thành công!",
"createFailed": "Không thể tạo khoản nợ",
"calculating": "Đang tính toán bảng lịch...",
"previewEmptyPrompt": "Nhập số tiền và thời hạn để xem trước bảng trả nợ"
}, },
"detail": { "detail": {
"header": "Chi Tiết Khoản Nợ", "header": "Chi Tiết Khoản Nợ",
"notFound": "Không tìm thấy thông tin hợp đồng nợ",
"loadFailed": "Không thể tải thông tin khoản nợ",
"generalInfo": "Thông tin hợp đồng", "generalInfo": "Thông tin hợp đồng",
"counterparty": "Đối tác", "counterparty": "Đối tác",
"method": "Phương pháp", "method": "Phương pháp",
"startDate": "Ngày bắt đầu", "startDate": "Ngày bắt đầu",
"endDate": "Ngày kết thúc", "endDate": "Ngày kết thúc",
"progressTitle": "Tiến độ thanh toán", "progressTitle": "Tiến độ thanh toán",
"periodsCount": "{{paid}}/{{total}} kỳ",
"paidPrincipal": "Gốc đã thanh toán", "paidPrincipal": "Gốc đã thanh toán",
"paidInterest": "Lãi đã thanh toán", "paidInterest": "Lãi đã thanh toán",
"remainingPrincipal": "Dư nợ gốc còn lại", "remainingPrincipal": "Dư nợ gốc còn lại",
"scheduleTitle": "Bảng Lịch Trả Nợ (Amortization Schedule)", "linkedWallet": "Ví liên kết:",
"scheduleTitle": "Bảng Lịch Trả Nợ",
"totalPeriods": "{{count}} kỳ",
"periodCol": "Kỳ", "periodCol": "Kỳ",
"dueDateCol": "Ngày đến hạn", "dueDateCol": "Ngày đến hạn",
"principalCol": "Gốc", "principalCol": "Gốc",
"interestCol": "Lãi", "interestCol": "Lãi",
"totalCol": "Tổng trả", "totalCol": "Tổng trả",
"remainingCol": "Dư nợ",
"remainingAfterPeriod": "Dư nợ sau kỳ:",
"statusCol": "Trạng thái", "statusCol": "Trạng thái",
"actionCol": "Thao tác", "actionCol": "Thao tác",
"payInstallmentBtn": "Trả kỳ này", "payNow": "Trả kỳ này",
"paying": "Đang trả...", "payInstallmentBtn": "Xác nhận trả kỳ này",
"paying": "Đang xử lý...",
"paySuccess": "Đã thanh toán kỳ nợ thành công!", "paySuccess": "Đã thanh toán kỳ nợ thành công!",
"payFailed": "Không thể thanh toán kỳ nợ",
"payModalTitle": "Thanh toán Kỳ #{{period}}",
"principalDue": "Tiền gốc:",
"interestDue": "Tiền lãi:",
"totalDue": "Tổng thanh toán:",
"paymentDate": "Ngày thực hiện thanh toán",
"paymentNotesPlaceholder": "Ghi chú thanh toán...",
"settleEarlyBtn": "Tất toán trước hạn", "settleEarlyBtn": "Tất toán trước hạn",
"settleEarlyTitle": "Tất Toán Toàn Bộ Khoản Nợ", "settleEarlyTitle": "Tất Toán Toàn Bộ Khoản Nợ",
"settleEarlyDesc": "Hệ thống sẽ thanh toán toàn bộ dư nợ gốc còn lại và miễn toàn bộ lãi tương lai.", "settleEarlyDesc": "Hệ thống sẽ thanh toán toàn bộ dư nợ gốc còn lại và miễn toàn bộ lãi tương lai.",
"settlePrincipal": "Dư nợ gốc cần tất toán:",
"settlePenaltyFee": "Phí phạt tất toán trước hạn:",
"settleTotal": "Tổng tiền tất toán:",
"penaltyFee": "Phí phạt tất toán trước hạn (nếu có, VNĐ)", "penaltyFee": "Phí phạt tất toán trước hạn (nếu có, VNĐ)",
"settleDate": "Ngày tất toán",
"confirmSettleEarly": "Xác nhận tất toán", "confirmSettleEarly": "Xác nhận tất toán",
"settleEarlySuccess": "Đã tất toán toàn bộ khoản nợ thành công!", "settleEarlySuccess": "Đã tất toán toàn bộ khoản nợ thành công!",
"settleEarlyFailed": "Không thể tất toán sớm",
"archive": "Lưu trữ",
"archiveBtn": "Lưu trữ khoản nợ", "archiveBtn": "Lưu trữ khoản nợ",
"archiveTitle": "Lưu Trữ Khoản Nợ",
"confirmArchive": "Bạn có chắc chắn muốn lưu trữ khoản nợ này?", "confirmArchive": "Bạn có chắc chắn muốn lưu trữ khoản nợ này?",
"archiveSuccess": "Đã lưu trữ khoản nợ thành công!" "archiveSuccess": "Đã lưu trữ khoản nợ thành công!",
"archiveFailed": "Không thể lưu trữ khoản nợ"
} }
} }
} }
......
...@@ -21,15 +21,26 @@ export const DebtCard: React.FC<DebtCardProps> = ({ debt }) => { ...@@ -21,15 +21,26 @@ export const DebtCard: React.FC<DebtCardProps> = ({ debt }) => {
// Status mapping to Design System badge types // Status mapping to Design System badge types
const statusBadgeType: Record<DebtStatus, BadgeType> = { const statusBadgeType: Record<DebtStatus, BadgeType> = {
DRAFT: "info",
ACTIVE: "primary", ACTIVE: "primary",
OVERDUE: "warning",
COMPLETED: "income", COMPLETED: "income",
DEFAULTED: "expense", DEFAULTED: "expense",
CANCELLED: "expense",
SETTLED_EARLY: "income", SETTLED_EARLY: "income",
}; };
const statusLabel = t( const statusKeyMap: Record<DebtStatus, string> = {
`debts.status.${debt.status === "SETTLED_EARLY" ? "settledEarly" : debt.status.toLowerCase()}`, DRAFT: "draft",
); ACTIVE: "active",
OVERDUE: "overdue",
COMPLETED: "completed",
DEFAULTED: "defaulted",
CANCELLED: "cancelled",
SETTLED_EARLY: "settledEarly",
};
const statusLabel = t(`debts.status.${statusKeyMap[debt.status] || "active"}`);
// Method label // Method label
const methodLabel = const methodLabel =
...@@ -130,7 +141,9 @@ export const DebtCard: React.FC<DebtCardProps> = ({ debt }) => { ...@@ -130,7 +141,9 @@ export const DebtCard: React.FC<DebtCardProps> = ({ debt }) => {
{/* Footer Info: Rate, Term & Next Due */} {/* Footer Info: Rate, Term & Next Due */}
<div className="flex items-center justify-between pt-2 border-t border-clay-text-muted/10 text-xs text-clay-text-muted"> <div className="flex items-center justify-between pt-2 border-t border-clay-text-muted/10 text-xs text-clay-text-muted">
<div className="font-nunito"> <div className="font-nunito">
<span>{Number(debt.annualInterestRate)}%/năm</span> <span>
{t("debts.card.ratePerYear", { rate: Number(debt.annualInterestRate) })}
</span>
<span className="mx-1.5">•</span> <span className="mx-1.5">•</span>
<span> <span>
{debt.termMonths} {t("debts.card.months")} {debt.termMonths} {t("debts.card.months")}
......
...@@ -17,30 +17,39 @@ import { todayInBusinessTime } from "@/lib/business-time"; ...@@ -17,30 +17,39 @@ import { todayInBusinessTime } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input"; import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
import { TrendingDownIcon, TrendingUpIcon } from "@/components/ui/icons"; import { TrendingDownIcon, TrendingUpIcon } from "@/components/ui/icons";
import { AmortizationMethod, DebtType } from "@/types/debt"; import { AmortizationMethod, DebtType } from "@/types/debt";
import { getErrorMessage } from "@/lib/error-message";
const createDebtFormSchema = (t: TranslationFunction) => const createDebtFormSchema = (t: TranslationFunction) =>
z.object({ z.object({
name: z.string().trim().min(1, "Vui lòng nhập tên khoản nợ").max(100, "Tối đa 100 ký tự"), name: z
counterparty: z.string().trim().min(1, "Vui lòng nhập đối tác").max(100, "Tối đa 100 ký tự"), .string()
.trim()
.min(1, t("debts.validation.nameRequired"))
.max(100, t("debts.validation.max100")),
counterparty: z
.string()
.trim()
.min(1, t("debts.validation.counterpartyRequired"))
.max(100, t("debts.validation.max100")),
type: z.enum(["DEBT_PAYABLE", "LOAN_RECEIVABLE"] as const), type: z.enum(["DEBT_PAYABLE", "LOAN_RECEIVABLE"] as const),
method: z.enum(["REDUCING_BALANCE", "FIXED_ANNUITY", "INTEREST_ONLY"] as const), method: z.enum(["REDUCING_BALANCE", "FIXED_ANNUITY", "INTEREST_ONLY"] as const),
principal: z principal: z
.string() .string()
.trim() .trim()
.min(1, "Vui lòng nhập số tiền gốc") .min(1, t("debts.validation.principalRequired"))
.refine((val) => Number(val) > 0, "Số tiền gốc phải lớn hơn 0"), .refine((val) => Number(val) > 0, t("debts.validation.principalPositive")),
annualInterestRate: z annualInterestRate: z
.number() .number()
.min(0, "Lãi suất không được âm") .min(0, t("debts.validation.interestRateMin"))
.max(100, "Lãi suất tối đa 100%"), .max(100, t("debts.validation.interestRateMax")),
termMonths: z termMonths: z
.number() .number()
.int("Thời hạn phải là số nguyên") .int(t("debts.validation.termMonthsInt"))
.min(1, "Thời hạn tối thiểu là 1 tháng") .min(1, t("debts.validation.termMonthsMin"))
.max(360, "Thời hạn tối đa 360 tháng"), .max(360, t("debts.validation.termMonthsMax")),
startDate: z.string().min(1, "Vui lòng chọn ngày bắt đầu"), startDate: z.string().min(1, t("debts.validation.startDateRequired")),
walletId: z.string().optional(), walletId: z.string().optional(),
notes: z.string().max(500, "Ghi chú tối đa 500 ký tự").optional(), notes: z.string().max(500, t("debts.validation.notesMax")).optional(),
}); });
type DebtFormValues = z.infer<ReturnType<typeof createDebtFormSchema>>; type DebtFormValues = z.infer<ReturnType<typeof createDebtFormSchema>>;
...@@ -59,9 +68,11 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -59,9 +68,11 @@ export const DebtModal: React.FC<DebtModalProps> = ({
const { t, formatCurrency, formatDate, locale } = useI18n(); const { t, formatCurrency, formatDate, locale } = useI18n();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
// Active inner tab: "config" vs "preview" const isSimulator = initialMode === "simulator";
// Active inner tab: "config" vs "preview" (only used in simulator mode)
const [activeTab, setActiveTab] = useState<string>( const [activeTab, setActiveTab] = useState<string>(
initialMode === "simulator" ? "preview" : "config", isSimulator ? "preview" : "config",
); );
const schema = useMemo(() => createDebtFormSchema(t), [t]); const schema = useMemo(() => createDebtFormSchema(t), [t]);
...@@ -92,7 +103,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -92,7 +103,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
// Reset form when modal opens // Reset form when modal opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setActiveTab(initialMode === "simulator" ? "preview" : "config"); setActiveTab(isSimulator ? "preview" : "config");
reset({ reset({
name: "", name: "",
counterparty: "", counterparty: "",
...@@ -106,7 +117,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -106,7 +117,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
notes: "", notes: "",
}); });
} }
}, [isOpen, initialMode, reset]); }, [isOpen, isSimulator, reset]);
// Watched form fields for live preview calculation // Watched form fields for live preview calculation
const watchedType = watch("type"); const watchedType = watch("type");
...@@ -140,7 +151,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -140,7 +151,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
[walletsQuery.data?.data, t], [walletsQuery.data?.data, t],
); );
// Live preview query // Live preview query (only enabled in simulator mode)
const previewQuery = useDebtPreview( const previewQuery = useDebtPreview(
{ {
principal: numericPrincipal, principal: numericPrincipal,
...@@ -149,7 +160,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -149,7 +160,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
startDate: watchedStartDate || todayInBusinessTime(), startDate: watchedStartDate || todayInBusinessTime(),
method: watchedMethod, method: watchedMethod,
}, },
isOpen, isOpen && isSimulator,
); );
const createDebtMutation = useCreateDebt(); const createDebtMutation = useCreateDebt();
...@@ -176,8 +187,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -176,8 +187,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
onClose(); onClose();
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = const errorMessage = getErrorMessage(err, t("debts.modal.createFailed"));
err instanceof Error ? err.message : "Không thể tạo khoản nợ";
openSnackbar({ openSnackbar({
text: errorMessage, text: errorMessage,
type: "error", type: "error",
...@@ -193,7 +203,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -193,7 +203,7 @@ export const DebtModal: React.FC<DebtModalProps> = ({
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={ title={
initialMode === "simulator" isSimulator
? t("debts.modal.simulatorTitle") ? t("debts.modal.simulatorTitle")
: t("debts.modal.createTitle") : t("debts.modal.createTitle")
} }
...@@ -220,17 +230,19 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -220,17 +230,19 @@ export const DebtModal: React.FC<DebtModalProps> = ({
} }
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Navigation Tabs between Configuration & Preview Table */} {/* Navigation Tabs between Configuration & Preview Table (only in simulator mode) */}
{isSimulator && (
<Tabs <Tabs
tabs={[ tabs={[
{ key: "config", label: "Thiết lập thông số" }, { key: "config", label: t("debts.modal.configTab") },
{ key: "preview", label: "Dự tính lịch trả nợ" }, { key: "preview", label: t("debts.modal.previewTab") },
]} ]}
activeTab={activeTab} activeTab={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
/> />
)}
{activeTab === "config" ? ( {!isSimulator || activeTab === "config" ? (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3.5"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3.5">
{/* Type Switcher: Tôi nợ vs Cho vay */} {/* Type Switcher: Tôi nợ vs Cho vay */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
...@@ -328,7 +340,9 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -328,7 +340,9 @@ export const DebtModal: React.FC<DebtModalProps> = ({
onChange={(e) => field.onChange(Number(e.target.value))} onChange={(e) => field.onChange(Number(e.target.value))}
error={errors.annualInterestRate?.message} error={errors.annualInterestRate?.message}
endAdornment={ endAdornment={
<span className="text-xs font-bold text-clay-text-muted">%/năm</span> <span className="text-xs font-bold text-clay-text-muted">
{t("debts.modal.rateUnit")}
</span>
} }
/> />
)} )}
...@@ -350,7 +364,9 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -350,7 +364,9 @@ export const DebtModal: React.FC<DebtModalProps> = ({
onChange={(e) => field.onChange(Number(e.target.value))} onChange={(e) => field.onChange(Number(e.target.value))}
error={errors.termMonths?.message} error={errors.termMonths?.message}
endAdornment={ endAdornment={
<span className="text-xs font-bold text-clay-text-muted">tháng</span> <span className="text-xs font-bold text-clay-text-muted">
{t("debts.modal.termUnit")}
</span>
} }
/> />
)} )}
...@@ -461,10 +477,10 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -461,10 +477,10 @@ export const DebtModal: React.FC<DebtModalProps> = ({
}`} }`}
> >
{m === "REDUCING_BALANCE" {m === "REDUCING_BALANCE"
? "Dư nợ giảm dần" ? t("debts.methods.reducingBalanceShort")
: m === "FIXED_ANNUITY" : m === "FIXED_ANNUITY"
? "Niên kim cố định" ? t("debts.methods.fixedAnnuityShort")
: "Lãi định kỳ"} : t("debts.methods.interestOnlyShort")}
</button> </button>
))} ))}
</div> </div>
...@@ -517,12 +533,12 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -517,12 +533,12 @@ export const DebtModal: React.FC<DebtModalProps> = ({
<table className="w-full text-left text-xs"> <table className="w-full text-left text-xs">
<thead className="bg-clay-bg/90 text-clay-text-muted uppercase text-[10px] font-bold sticky top-0 z-10 border-b border-clay-highlight/30"> <thead className="bg-clay-bg/90 text-clay-text-muted uppercase text-[10px] font-bold sticky top-0 z-10 border-b border-clay-highlight/30">
<tr> <tr>
<th className="py-2.5 px-3">Kỳ</th> <th className="py-2.5 px-3">{t("debts.detail.periodCol")}</th>
<th className="py-2.5 px-2">Hạn trả</th> <th className="py-2.5 px-2">{t("debts.detail.dueDateCol")}</th>
<th className="py-2.5 px-2 text-right">Gốc</th> <th className="py-2.5 px-2 text-right">{t("debts.detail.principalCol")}</th>
<th className="py-2.5 px-2 text-right">Lãi</th> <th className="py-2.5 px-2 text-right">{t("debts.detail.interestCol")}</th>
<th className="py-2.5 px-2 text-right">Tổng trả</th> <th className="py-2.5 px-2 text-right">{t("debts.detail.totalCol")}</th>
<th className="py-2.5 px-3 text-right">Dư nợ</th> <th className="py-2.5 px-3 text-right">{t("debts.detail.remainingCol")}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-clay-text-muted/10 font-nunito"> <tbody className="divide-y divide-clay-text-muted/10 font-nunito">
...@@ -559,8 +575,8 @@ export const DebtModal: React.FC<DebtModalProps> = ({ ...@@ -559,8 +575,8 @@ export const DebtModal: React.FC<DebtModalProps> = ({
) : ( ) : (
<div className="py-8 text-center text-clay-text-muted text-xs"> <div className="py-8 text-center text-clay-text-muted text-xs">
{previewQuery.isLoading {previewQuery.isLoading
? "Đang tính toán bảng lịch..." ? t("debts.modal.calculating")
: "Nhập số tiền và thời hạn để xem trước bảng trả nợ"} : t("debts.modal.previewEmptyPrompt")}
</div> </div>
)} )}
</div> </div>
......
...@@ -31,6 +31,7 @@ import { ...@@ -31,6 +31,7 @@ import {
DebtScheduleStatus, DebtScheduleStatus,
DebtStatus, DebtStatus,
} from "@/types/debt"; } from "@/types/debt";
import { getErrorMessage } from "@/lib/error-message";
const DebtDetailPage: React.FC = () => { const DebtDetailPage: React.FC = () => {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
...@@ -100,7 +101,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -100,7 +101,7 @@ const DebtDetailPage: React.FC = () => {
</div> </div>
<div> <div>
<h2 className="clay-title-h3">{t("common.error")}</h2> <h2 className="clay-title-h3">{t("common.error")}</h2>
<p className="clay-caption mt-1">Không tìm thấy thông tin hợp đồng nợ</p> <p className="clay-caption mt-1">{t("debts.detail.notFound")}</p>
</div> </div>
<Button variant="secondary" onClick={() => navigate("/debts")}> <Button variant="secondary" onClick={() => navigate("/debts")}>
{t("common.backToHome")} {t("common.backToHome")}
...@@ -126,12 +127,25 @@ const DebtDetailPage: React.FC = () => { ...@@ -126,12 +127,25 @@ const DebtDetailPage: React.FC = () => {
// Status mapping // Status mapping
const statusBadgeType: Record<DebtStatus, BadgeType> = { const statusBadgeType: Record<DebtStatus, BadgeType> = {
DRAFT: "info",
ACTIVE: "primary", ACTIVE: "primary",
OVERDUE: "warning",
COMPLETED: "income", COMPLETED: "income",
DEFAULTED: "expense", DEFAULTED: "expense",
CANCELLED: "expense",
SETTLED_EARLY: "income", SETTLED_EARLY: "income",
}; };
const statusKeyMap: Record<DebtStatus, string> = {
DRAFT: "draft",
ACTIVE: "active",
OVERDUE: "overdue",
COMPLETED: "completed",
DEFAULTED: "defaulted",
CANCELLED: "cancelled",
SETTLED_EARLY: "settledEarly",
};
const scheduleBadgeType: Record<DebtScheduleStatus, BadgeType> = { const scheduleBadgeType: Record<DebtScheduleStatus, BadgeType> = {
SCHEDULED: "info", SCHEDULED: "info",
UPCOMING: "warning", UPCOMING: "warning",
...@@ -182,7 +196,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -182,7 +196,7 @@ const DebtDetailPage: React.FC = () => {
}); });
setPayingPeriod(null); setPayingPeriod(null);
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Không thể thanh toán kỳ nợ"; const msg = getErrorMessage(err, t("debts.detail.payFailed"));
openSnackbar({ openSnackbar({
text: msg, text: msg,
type: "error", type: "error",
...@@ -205,7 +219,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -205,7 +219,7 @@ const DebtDetailPage: React.FC = () => {
}); });
setIsSettleEarlyOpen(false); setIsSettleEarlyOpen(false);
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Không thể tất toán sớm"; const msg = getErrorMessage(err, t("debts.detail.settleEarlyFailed"));
openSnackbar({ openSnackbar({
text: msg, text: msg,
type: "error", type: "error",
...@@ -223,7 +237,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -223,7 +237,7 @@ const DebtDetailPage: React.FC = () => {
}); });
navigate("/debts"); navigate("/debts");
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Không thể lưu trữ khoản nợ"; const msg = getErrorMessage(err, t("debts.detail.archiveFailed"));
openSnackbar({ openSnackbar({
text: msg, text: msg,
type: "error", type: "error",
...@@ -258,13 +272,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -258,13 +272,7 @@ const DebtDetailPage: React.FC = () => {
<h1 className="clay-title-h2 mt-0.5">{debt.name}</h1> <h1 className="clay-title-h2 mt-0.5">{debt.name}</h1>
</div> </div>
<Badge type={statusBadgeType[debt.status] || "primary"}> <Badge type={statusBadgeType[debt.status] || "primary"}>
{t( {t(`debts.status.${statusKeyMap[debt.status] || "active"}`)}
`debts.status.${
debt.status === "SETTLED_EARLY"
? "settledEarly"
: debt.status.toLowerCase()
}`,
)}
</Badge> </Badge>
</div> </div>
...@@ -298,8 +306,11 @@ const DebtDetailPage: React.FC = () => { ...@@ -298,8 +306,11 @@ const DebtDetailPage: React.FC = () => {
<div className="flex justify-between text-[11px]"> <div className="flex justify-between text-[11px]">
<span className="text-clay-text-muted"> <span className="text-clay-text-muted">
{t("debts.detail.progressTitle")} ( {t("debts.detail.progressTitle")} (
{debt.metrics?.paidInstallments || 0}/ {t("debts.detail.periodsCount", {
{debt.metrics?.totalInstallments || 0} kỳ) paid: debt.metrics?.paidInstallments || 0,
total: debt.metrics?.totalInstallments || 0,
})}
)
</span> </span>
<span className="font-nunito font-bold text-clay-primary"> <span className="font-nunito font-bold text-clay-primary">
{debt.metrics?.progressPercent || 0}% {debt.metrics?.progressPercent || 0}%
...@@ -328,7 +339,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -328,7 +339,7 @@ const DebtDetailPage: React.FC = () => {
{t("debts.card.interestRate")} & {t("debts.card.term")} {t("debts.card.interestRate")} & {t("debts.card.term")}
</span> </span>
<span className="font-semibold text-clay-text mt-0.5 block font-nunito"> <span className="font-semibold text-clay-text mt-0.5 block font-nunito">
{Number(debt.annualInterestRate)}%/năm • {debt.termMonths}T {t("debts.card.ratePerYear", { rate: Number(debt.annualInterestRate) })} • {debt.termMonths} {t("debts.card.months")}
</span> </span>
</div> </div>
</div> </div>
...@@ -358,7 +369,8 @@ const DebtDetailPage: React.FC = () => { ...@@ -358,7 +369,8 @@ const DebtDetailPage: React.FC = () => {
<div className="flex items-center gap-2 pt-1 border-t border-clay-text-muted/10 text-xs text-clay-text-muted"> <div className="flex items-center gap-2 pt-1 border-t border-clay-text-muted/10 text-xs text-clay-text-muted">
<WalletIcon size={16} /> <WalletIcon size={16} />
<span> <span>
Ví liên kết: <strong className="text-clay-text">{debt.wallet.name}</strong> {t("debts.detail.linkedWallet")}{" "}
<strong className="text-clay-text">{debt.wallet.name}</strong>
</span> </span>
</div> </div>
)} )}
...@@ -399,7 +411,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -399,7 +411,7 @@ const DebtDetailPage: React.FC = () => {
<span>{t("debts.detail.scheduleTitle")}</span> <span>{t("debts.detail.scheduleTitle")}</span>
</h2> </h2>
<span className="text-xs text-clay-text-muted font-nunito font-semibold"> <span className="text-xs text-clay-text-muted font-nunito font-semibold">
{debt.scheduleItems?.length || 0} kỳ {t("debts.detail.totalPeriods", { count: debt.scheduleItems?.length || 0 })}
</span> </span>
</div> </div>
...@@ -453,19 +465,25 @@ const DebtDetailPage: React.FC = () => { ...@@ -453,19 +465,25 @@ const DebtDetailPage: React.FC = () => {
{/* Period Financial Details */} {/* Period Financial Details */}
<div className="grid grid-cols-3 gap-1 pt-1.5 border-t border-clay-text-muted/10 text-xs font-nunito"> <div className="grid grid-cols-3 gap-1 pt-1.5 border-t border-clay-text-muted/10 text-xs font-nunito">
<div> <div>
<span className="text-[10px] text-clay-text-muted block">Gốc</span> <span className="text-[10px] text-clay-text-muted block">
{t("debts.detail.principalCol")}
</span>
<span className="font-bold text-clay-text"> <span className="font-bold text-clay-text">
{formatCurrency(Number(item.principalDue), "VND")} {formatCurrency(Number(item.principalDue), "VND")}
</span> </span>
</div> </div>
<div> <div>
<span className="text-[10px] text-clay-text-muted block">Lãi</span> <span className="text-[10px] text-clay-text-muted block">
{t("debts.detail.interestCol")}
</span>
<span className="font-bold text-clay-expense"> <span className="font-bold text-clay-expense">
{formatCurrency(Number(item.interestDue), "VND")} {formatCurrency(Number(item.interestDue), "VND")}
</span> </span>
</div> </div>
<div className="text-right"> <div className="text-right">
<span className="text-[10px] text-clay-text-muted block">Tổng kỳ</span> <span className="text-[10px] text-clay-text-muted block">
{t("debts.detail.totalCol")}
</span>
<span className="font-extrabold text-clay-primary"> <span className="font-extrabold text-clay-primary">
{formatCurrency(Number(item.totalDue), "VND")} {formatCurrency(Number(item.totalDue), "VND")}
</span> </span>
...@@ -474,7 +492,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -474,7 +492,7 @@ const DebtDetailPage: React.FC = () => {
{/* Remaining Principal after Period */} {/* Remaining Principal after Period */}
<div className="flex justify-between items-center text-[10px] text-clay-text-muted pt-1 border-t border-clay-text-muted/10 font-nunito"> <div className="flex justify-between items-center text-[10px] text-clay-text-muted pt-1 border-t border-clay-text-muted/10 font-nunito">
<span>Dư nợ sau kỳ:</span> <span>{t("debts.detail.remainingAfterPeriod")}</span>
<span className="font-semibold text-clay-text"> <span className="font-semibold text-clay-text">
{formatCurrency(Number(item.remainingPrincipal), "VND")} {formatCurrency(Number(item.remainingPrincipal), "VND")}
</span> </span>
...@@ -491,7 +509,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -491,7 +509,7 @@ const DebtDetailPage: React.FC = () => {
<Modal <Modal
isOpen={payingPeriod !== null} isOpen={payingPeriod !== null}
onClose={() => setPayingPeriod(null)} onClose={() => setPayingPeriod(null)}
title={`Thanh toán Kỳ #${selectedItemForPay.period}`} title={t("debts.detail.payModalTitle", { period: selectedItemForPay.period })}
footer={ footer={
<div className="flex w-full items-center justify-end gap-2 pt-2 border-t border-clay-text-muted/10"> <div className="flex w-full items-center justify-end gap-2 pt-2 border-t border-clay-text-muted/10">
<Button <Button
...@@ -517,19 +535,19 @@ const DebtDetailPage: React.FC = () => { ...@@ -517,19 +535,19 @@ const DebtDetailPage: React.FC = () => {
{/* Amount Summary */} {/* Amount Summary */}
<div className="rounded-clay bg-clay-bg p-3.5 border border-clay-highlight/40 flex flex-col gap-1.5 font-nunito"> <div className="rounded-clay bg-clay-bg p-3.5 border border-clay-highlight/40 flex flex-col gap-1.5 font-nunito">
<div className="flex justify-between text-xs text-clay-text-muted"> <div className="flex justify-between text-xs text-clay-text-muted">
<span>Tiền gốc:</span> <span>{t("debts.detail.principalDue")}</span>
<span className="font-bold text-clay-text"> <span className="font-bold text-clay-text">
{formatCurrency(Number(selectedItemForPay.principalDue), "VND")} {formatCurrency(Number(selectedItemForPay.principalDue), "VND")}
</span> </span>
</div> </div>
<div className="flex justify-between text-xs text-clay-text-muted"> <div className="flex justify-between text-xs text-clay-text-muted">
<span>Tiền lãi:</span> <span>{t("debts.detail.interestDue")}</span>
<span className="font-bold text-clay-expense"> <span className="font-bold text-clay-expense">
{formatCurrency(Number(selectedItemForPay.interestDue), "VND")} {formatCurrency(Number(selectedItemForPay.interestDue), "VND")}
</span> </span>
</div> </div>
<div className="flex justify-between text-sm font-bold pt-1.5 border-t border-clay-text-muted/10"> <div className="flex justify-between text-sm font-bold pt-1.5 border-t border-clay-text-muted/10">
<span className="text-clay-text">Tổng thanh toán:</span> <span className="text-clay-text">{t("debts.detail.totalDue")}</span>
<span className="text-clay-primary font-extrabold text-base"> <span className="text-clay-primary font-extrabold text-base">
{formatCurrency(Number(selectedItemForPay.totalDue), "VND")} {formatCurrency(Number(selectedItemForPay.totalDue), "VND")}
</span> </span>
...@@ -546,7 +564,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -546,7 +564,7 @@ const DebtDetailPage: React.FC = () => {
{/* Localized Payment Date */} {/* Localized Payment Date */}
<LocalizedDateInput <LocalizedDateInput
label="Ngày thực hiện thanh toán" label={t("debts.detail.paymentDate")}
type="date" type="date"
value={payDate} value={payDate}
onChange={(e) => setPayDate(e.target.value)} onChange={(e) => setPayDate(e.target.value)}
...@@ -555,7 +573,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -555,7 +573,7 @@ const DebtDetailPage: React.FC = () => {
{/* Notes */} {/* Notes */}
<Input <Input
label={t("debts.modal.notes")} label={t("debts.modal.notes")}
placeholder="Ghi chú thanh toán..." placeholder={t("debts.detail.paymentNotesPlaceholder")}
value={payNotes} value={payNotes}
onChange={(e) => setPayNotes(e.target.value)} onChange={(e) => setPayNotes(e.target.value)}
/> />
...@@ -596,19 +614,19 @@ const DebtDetailPage: React.FC = () => { ...@@ -596,19 +614,19 @@ const DebtDetailPage: React.FC = () => {
<div className="rounded-clay bg-clay-bg p-3.5 border border-clay-highlight/40 flex flex-col gap-1.5 font-nunito"> <div className="rounded-clay bg-clay-bg p-3.5 border border-clay-highlight/40 flex flex-col gap-1.5 font-nunito">
<div className="flex justify-between text-xs text-clay-text-muted"> <div className="flex justify-between text-xs text-clay-text-muted">
<span>Dư nợ gốc cần tất toán:</span> <span>{t("debts.detail.settlePrincipal")}</span>
<span className="font-bold text-clay-text"> <span className="font-bold text-clay-text">
{formatCurrency(remaining, "VND")} {formatCurrency(remaining, "VND")}
</span> </span>
</div> </div>
<div className="flex justify-between text-xs text-clay-text-muted"> <div className="flex justify-between text-xs text-clay-text-muted">
<span>Phí phạt tất toán trước hạn:</span> <span>{t("debts.detail.settlePenaltyFee")}</span>
<span className="font-bold text-clay-expense"> <span className="font-bold text-clay-expense">
{formatCurrency(numericPenaltyFee, "VND")} {formatCurrency(numericPenaltyFee, "VND")}
</span> </span>
</div> </div>
<div className="flex justify-between text-sm font-bold pt-1.5 border-t border-clay-text-muted/10"> <div className="flex justify-between text-sm font-bold pt-1.5 border-t border-clay-text-muted/10">
<span className="text-clay-text">Tổng tiền tất toán:</span> <span className="text-clay-text">{t("debts.detail.settleTotal")}</span>
<span className="font-extrabold text-clay-primary text-base"> <span className="font-extrabold text-clay-primary text-base">
{formatCurrency(remaining + numericPenaltyFee, "VND")} {formatCurrency(remaining + numericPenaltyFee, "VND")}
</span> </span>
...@@ -636,7 +654,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -636,7 +654,7 @@ const DebtDetailPage: React.FC = () => {
/> />
<LocalizedDateInput <LocalizedDateInput
label="Ngày tất toán" label={t("debts.detail.settleDate")}
type="date" type="date"
value={settleDate} value={settleDate}
onChange={(e) => setSettleDate(e.target.value)} onChange={(e) => setSettleDate(e.target.value)}
...@@ -648,7 +666,7 @@ const DebtDetailPage: React.FC = () => { ...@@ -648,7 +666,7 @@ const DebtDetailPage: React.FC = () => {
<Modal <Modal
isOpen={isArchiveConfirmOpen} isOpen={isArchiveConfirmOpen}
onClose={() => setIsArchiveConfirmOpen(false)} onClose={() => setIsArchiveConfirmOpen(false)}
title={t("debts.detail.archive")} title={t("debts.detail.archiveTitle")}
footer={ footer={
<div className="flex w-full items-center justify-end gap-2 pt-2 border-t border-clay-text-muted/10"> <div className="flex w-full items-center justify-end gap-2 pt-2 border-t border-clay-text-muted/10">
<Button <Button
......
...@@ -38,7 +38,7 @@ const DebtsPage: React.FC = () => { ...@@ -38,7 +38,7 @@ const DebtsPage: React.FC = () => {
limit: 50, limit: 50,
}); });
const debtsList = debtsQuery.data?.data || []; const debtsList = debtsQuery.data?.data || debtsQuery.data?.items || [];
const handleOpenCreate = () => { const handleOpenCreate = () => {
setModalMode("create"); setModalMode("create");
...@@ -117,7 +117,7 @@ const DebtsPage: React.FC = () => { ...@@ -117,7 +117,7 @@ const DebtsPage: React.FC = () => {
<div> <div>
<h2 className="clay-title-h3">{t("common.error")}</h2> <h2 className="clay-title-h3">{t("common.error")}</h2>
<p className="clay-caption mt-1"> <p className="clay-caption mt-1">
{getErrorMessage(debtsQuery.error, "Không thể tải danh sách hợp đồng nợ")} {getErrorMessage(debtsQuery.error, t("debts.loadFailed"))}
</p> </p>
</div> </div>
<Button variant="secondary" className="text-sm" onClick={() => debtsQuery.refetch()}> <Button variant="secondary" className="text-sm" onClick={() => debtsQuery.refetch()}>
......
...@@ -29,6 +29,7 @@ const typeSurface: Record<NotificationType, string> = { ...@@ -29,6 +29,7 @@ const typeSurface: Record<NotificationType, string> = {
RECURRING_PAYMENT_DUE: "bg-clay-info/20", RECURRING_PAYMENT_DUE: "bg-clay-info/20",
UNUSUAL_TRANSACTION: "bg-clay-expense/20", UNUSUAL_TRANSACTION: "bg-clay-expense/20",
USER_REMINDER: "bg-clay-primary/20", USER_REMINDER: "bg-clay-primary/20",
DEBT_PAYMENT_DUE: "bg-clay-warning/20",
SYSTEM: "bg-clay-info/20", SYSTEM: "bg-clay-info/20",
}; };
......
...@@ -168,7 +168,7 @@ const NotificationsPage: React.FC = () => { ...@@ -168,7 +168,7 @@ const NotificationsPage: React.FC = () => {
<Tabs activeTab={tab} onChange={(key) => setTab(key as PageTab)} tabs={[{ key: "INBOX", label: t("notification.tabs.inbox") }, { key: "REMINDERS", label: t("notification.tabs.reminders") }, { key: "SETTINGS", label: t("notification.tabs.settings") }]} /> <Tabs activeTab={tab} onChange={(key) => setTab(key as PageTab)} tabs={[{ key: "INBOX", label: t("notification.tabs.inbox") }, { key: "REMINDERS", label: t("notification.tabs.reminders") }, { key: "SETTINGS", label: t("notification.tabs.settings") }]} />
{tab === "INBOX" && <> {tab === "INBOX" && <>
<Card className="flex flex-col gap-3 p-4"><Tabs activeTab={readFilter} onChange={(key) => setReadFilter(key as ReadFilter)} tabs={[{ key: "ALL", label: t("notification.filter.all") }, { key: "UNREAD", label: t("notification.filter.unread") }, { key: "READ", label: t("notification.filter.read") }]} /><div className="grid grid-cols-1 gap-3 sm:grid-cols-2"><Select label={t("notification.filter.type")} value={typeFilter} onChange={(event) => setTypeFilter(event.target.value as "ALL" | NotificationType)} options={[{ value: "ALL", label: t("notification.filter.allTypes") }, ...(["BUDGET_NEAR_LIMIT", "BUDGET_EXCEEDED", "SAVING_GOAL_NEAR_TARGET", "SAVING_GOAL_ACHIEVED", "SAVING_GOAL_DUE_SOON", "RECURRING_PAYMENT_DUE", "UNUSUAL_TRANSACTION", "USER_REMINDER", "SYSTEM"] as NotificationType[]).map((value) => ({ value, label: t(`notification.type.${value}`) }))]} /><Select label={t("notification.filter.priority")} value={priorityFilter} onChange={(event) => setPriorityFilter(event.target.value as "ALL" | NotificationPriority)} options={[{ value: "ALL", label: t("notification.filter.allPriorities") }, ...(["LOW", "NORMAL", "HIGH", "CRITICAL"] as NotificationPriority[]).map((value) => ({ value, label: t(`notification.priority.${value}`) }))]} /></div>{unreadCount > 0 && <Button variant="ghost" className="self-end px-3 text-sm" disabled={markAllMutation.isPending} onClick={() => markAllMutation.mutate(undefined, { onSuccess: () => openSnackbar({ type: "success", text: t("notification.markAllSuccess") }), onError: (error) => showError(error, "notification.actionFailed") })}>{markAllMutation.isPending ? t("common.processing") : t("notification.markAllRead")}</Button>}</Card> <Card className="flex flex-col gap-3 p-4"><Tabs activeTab={readFilter} onChange={(key) => setReadFilter(key as ReadFilter)} tabs={[{ key: "ALL", label: t("notification.filter.all") }, { key: "UNREAD", label: t("notification.filter.unread") }, { key: "READ", label: t("notification.filter.read") }]} /><div className="grid grid-cols-1 gap-3 sm:grid-cols-2"><Select label={t("notification.filter.type")} value={typeFilter} onChange={(event) => setTypeFilter(event.target.value as "ALL" | NotificationType)} options={[{ value: "ALL", label: t("notification.filter.allTypes") }, ...(["BUDGET_NEAR_LIMIT", "BUDGET_EXCEEDED", "SAVING_GOAL_NEAR_TARGET", "SAVING_GOAL_ACHIEVED", "SAVING_GOAL_DUE_SOON", "RECURRING_PAYMENT_DUE", "UNUSUAL_TRANSACTION", "USER_REMINDER", "DEBT_PAYMENT_DUE", "SYSTEM"] as NotificationType[]).map((value) => ({ value, label: t(`notification.type.${value}`) }))]} /><Select label={t("notification.filter.priority")} value={priorityFilter} onChange={(event) => setPriorityFilter(event.target.value as "ALL" | NotificationPriority)} options={[{ value: "ALL", label: t("notification.filter.allPriorities") }, ...(["LOW", "NORMAL", "HIGH", "CRITICAL"] as NotificationPriority[]).map((value) => ({ value, label: t(`notification.priority.${value}`) }))]} /></div>{unreadCount > 0 && <Button variant="ghost" className="self-end px-3 text-sm" disabled={markAllMutation.isPending} onClick={() => markAllMutation.mutate(undefined, { onSuccess: () => openSnackbar({ type: "success", text: t("notification.markAllSuccess") }), onError: (error) => showError(error, "notification.actionFailed") })}>{markAllMutation.isPending ? t("common.processing") : t("notification.markAllRead")}</Button>}</Card>
<section className="flex flex-col gap-3" aria-labelledby="notification-list-title"><div className="flex items-center justify-between px-1"><div><h2 id="notification-list-title" className="clay-title-h2">{t("notification.list")}</h2><p className="clay-caption">{t("common.results", { count: notificationsQuery.data?.meta.total || 0 })}</p></div>{notificationsQuery.isFetching && !notificationsQuery.isLoading && <span className="clay-caption animate-pulse">{t("notification.syncing")}</span>}</div>{renderState("notification", notificationsQuery.isLoading, notificationsQuery.isError, notifications.length === 0, () => { void notificationsQuery.refetch(); })}{notifications.map((item) => <NotificationCard key={item.id} notification={item} isProcessing={mutationPending} onOpen={() => openNotification(item)} onMarkRead={() => markRead(item.id)} onDelete={() => setDeleteTarget({ kind: "notification", item })} />)}{notificationPages > 1 && <nav className="flex items-center justify-between gap-3 pt-2" aria-label={t("notification.paginationLabel")}><Button variant="secondary" className="px-4 text-sm" disabled={notificationPage <= 1 || notificationsQuery.isFetching} onClick={() => setNotificationPage((value) => value - 1)}>{t("common.previous")}</Button><span className="font-nunito text-sm font-bold text-clay-text">{t("common.pageOf", { page: notificationPage, total: notificationPages })}</span><Button variant="secondary" className="px-4 text-sm" disabled={notificationPage >= notificationPages || notificationsQuery.isFetching} onClick={() => setNotificationPage((value) => value + 1)}>{t("common.next")}</Button></nav>}</section> <section className="flex flex-col gap-3" aria-labelledby="notification-list-title"><div className="flex items-center justify-between px-1"><div><h2 id="notification-list-title" className="clay-title-h2">{t("notification.list")}</h2><p className="clay-caption">{t("common.results", { count: notificationsQuery.data?.meta.total || 0 })}</p></div>{notificationsQuery.isFetching && !notificationsQuery.isLoading && <span className="clay-caption animate-pulse">{t("notification.syncing")}</span>}</div>{renderState("notification", notificationsQuery.isLoading, notificationsQuery.isError, notifications.length === 0, () => { void notificationsQuery.refetch(); })}{notifications.map((item) => <NotificationCard key={item.id} notification={item} isProcessing={mutationPending} onOpen={() => openNotification(item)} onMarkRead={() => markRead(item.id)} onDelete={() => setDeleteTarget({ kind: "notification", item })} />)}{notificationPages > 1 && <nav className="flex items-center justify-between gap-3 pt-2" aria-label={t("notification.paginationLabel")}><Button variant="secondary" className="px-4 text-sm" disabled={notificationPage <= 1 || notificationsQuery.isFetching} onClick={() => setNotificationPage((value) => value - 1)}>{t("common.previous")}</Button><span className="font-nunito text-sm font-bold text-clay-text">{t("common.pageOf", { page: notificationPage, total: notificationPages })}</span><Button variant="secondary" className="px-4 text-sm" disabled={notificationPage >= notificationPages || notificationsQuery.isFetching} onClick={() => setNotificationPage((value) => value + 1)}>{t("common.next")}</Button></nav>}</section>
</>} </>}
......
...@@ -12,6 +12,7 @@ export type NotificationType = ...@@ -12,6 +12,7 @@ export type NotificationType =
| 'RECURRING_PAYMENT_DUE' | 'RECURRING_PAYMENT_DUE'
| 'UNUSUAL_TRANSACTION' | 'UNUSUAL_TRANSACTION'
| 'USER_REMINDER' | 'USER_REMINDER'
| 'DEBT_PAYMENT_DUE'
| 'SYSTEM'; | 'SYSTEM';
export interface NotificationOverviewStats { export interface NotificationOverviewStats {
......
...@@ -34,7 +34,20 @@ export const debtService = { ...@@ -34,7 +34,20 @@ export const debtService = {
const response = await apiClient.get<DebtListResponse>("/debts", { const response = await apiClient.get<DebtListResponse>("/debts", {
params: query, params: query,
}); });
return ensureSuccess(response.data); const resData = response.data;
const items = resData.data ?? resData.items ?? [];
const meta = resData.meta ?? resData.pagination ?? {
total: items.length,
page: query.page || 1,
limit: query.limit || 20,
totalPages: Math.ceil(items.length / (query.limit || 20)),
};
return ensureSuccess({
...resData,
data: items,
items,
meta,
});
}, },
/** /**
......
...@@ -2,7 +2,14 @@ export type DebtType = 'DEBT_PAYABLE' | 'LOAN_RECEIVABLE'; ...@@ -2,7 +2,14 @@ export type DebtType = 'DEBT_PAYABLE' | 'LOAN_RECEIVABLE';
export type AmortizationMethod = 'REDUCING_BALANCE' | 'FIXED_ANNUITY' | 'INTEREST_ONLY'; export type AmortizationMethod = 'REDUCING_BALANCE' | 'FIXED_ANNUITY' | 'INTEREST_ONLY';
export type DebtStatus = 'ACTIVE' | 'COMPLETED' | 'DEFAULTED' | 'SETTLED_EARLY'; export type DebtStatus =
| 'DRAFT'
| 'ACTIVE'
| 'OVERDUE'
| 'COMPLETED'
| 'DEFAULTED'
| 'CANCELLED'
| 'SETTLED_EARLY';
export type DebtScheduleStatus = 'SCHEDULED' | 'UPCOMING' | 'PAID' | 'OVERDUE' | 'WAIVED'; export type DebtScheduleStatus = 'SCHEDULED' | 'UPCOMING' | 'PAID' | 'OVERDUE' | 'WAIVED';
...@@ -74,6 +81,7 @@ export interface DebtListResponse { ...@@ -74,6 +81,7 @@ export interface DebtListResponse {
success: boolean; success: boolean;
message?: string; message?: string;
data: DebtContract[]; data: DebtContract[];
items?: DebtContract[];
summary: DebtSummary; summary: DebtSummary;
meta: { meta: {
total: number; total: number;
...@@ -81,6 +89,12 @@ export interface DebtListResponse { ...@@ -81,6 +89,12 @@ export interface DebtListResponse {
limit: number; limit: number;
totalPages: number; totalPages: number;
}; };
pagination?: {
total: number;
page: number;
limit: number;
totalPages: number;
};
} }
export interface DebtDetailResponse { export interface DebtDetailResponse {
......
...@@ -9,11 +9,12 @@ export type NotificationType = ...@@ -9,11 +9,12 @@ export type NotificationType =
| "RECURRING_PAYMENT_DUE" | "RECURRING_PAYMENT_DUE"
| "UNUSUAL_TRANSACTION" | "UNUSUAL_TRANSACTION"
| "USER_REMINDER" | "USER_REMINDER"
| "DEBT_PAYMENT_DUE"
| "SYSTEM"; | "SYSTEM";
export type NotificationPriority = "LOW" | "NORMAL" | "HIGH" | "CRITICAL"; export type NotificationPriority = "LOW" | "NORMAL" | "HIGH" | "CRITICAL";
export type NotificationChannel = "IN_APP" | "EMAIL" | "ZALO" | "PUSH"; export type NotificationChannel = "IN_APP" | "EMAIL" | "ZALO" | "PUSH";
export type NotificationSourceType = "BUDGET" | "SAVING_GOAL" | "TRANSACTION" | "REMINDER" | "SYSTEM"; export type NotificationSourceType = "BUDGET" | "SAVING_GOAL" | "TRANSACTION" | "REMINDER" | "DEBT" | "SYSTEM";
export type NotificationDeliveryStatus = "PENDING" | "PROCESSING" | "SENT" | "FAILED" | "SKIPPED"; export type NotificationDeliveryStatus = "PENDING" | "PROCESSING" | "SENT" | "FAILED" | "SKIPPED";
export interface NotificationDelivery { export interface NotificationDelivery {
......
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