Commit c0bf74fe authored by ThinhNC's avatar ThinhNC

feat(savings): implement term deposits and savings management UI

- Add savings portfolio overview, active deposits list, and maturity progress
- Implement open and edit term deposit modal with bank presets, tenor chips, and live interest preview (TT 14/2017)
- Implement settlement modal with early withdrawal penalty breakdown (TT 04/2022)
- Integrate savings account wallets into NetWorthWidget, Wallets page, and home navigation
- Add bilingual i18n support (English/Vietnamese) and guest mode demo mock data
- Adhere 100% to Claymorphism design system tokens
parent a9756283
......@@ -133,6 +133,26 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
2. Bổ sung mock data phản hồi cho endpoint GET mới vào `src/lib/guest-mock.ts`.
3. Đọc dữ liệu từ query cache luôn dùng optional chaining `?.` để tránh lỗi null/undefined crash.
- **Quản lý Sổ Tiết kiệm & Tích lũy (Savings & Term Deposits - Phase 3.2)**:
- Tách độc lập thành Tab riêng trên Trang chủ (phía trên "Mục tiêu tiết kiệm") và trang quản lý chuyên biệt `/savings`.
- Toàn bộ trang `/savings` và các components tuân thủ 100% token Claymorphism từ `tailwind.config.js` (loại bỏ hoàn toàn các màu xanh lá đậm / gradient teal-emerald tùy tiện):
- Thẻ tổng quan danh mục (Executive Portfolio Banner) dùng `bg-clay-surface` bo góc `rounded-clay-lg` với viền `border-clay-highlight/40` và bóng đổ `shadow-clay-raised`, hài hòa với màu nền app `bg-clay-bg`.
- Số dư gốc và các thông số hiển thị bằng `text-clay-primary` (tím periwinkle) và các ô thẻ lún `bg-clay-bg/70 shadow-clay-pressed`.
- Các nút hành động chính (Mở sổ mới, Tất toán) sử dụng base `<Button variant="primary">` chuẩn Claymorphism (`bg-clay-primary`, `shadow-clay-raised`, `active:shadow-clay-pressed`).
- Chuẩn hóa modal "Mở Sổ Tiết Kiệm Mới" / "Chỉnh sửa Sổ Tiết Kiệm" (`SavingsFormModal.tsx`) theo chuẩn Claymorphism:
- Bố cục Modal chuẩn với `footer` prop cố định (sticky), kết nối `form={formId}` chống che nút trên mobile.
- Executive Artwork preview banner động hiển thị màu thương hiệu ngân hàng, kỳ hạn, lãi suất và tiền lãi dự kiến.
- Bộ chọn ngân hàng dạng dropdown (`Select`) đưa lựa chọn "Khác (Nhập tên ngân hàng...)" lên vị trí đầu tiên trên cùng, tiếp theo là danh sách các ngân hàng thương mại phổ biến (Vietcombank, Techcombank, MBBank, BIDV, VietinBank, ACB, VPBank, TPBank, HDBank, Sacombank, VIB, SHB, MSB, SeABank, OCB, LPBank). Khi chọn "Khác", form tự động mở rộng ô `<Input>` có giới hạn thực tế tối đa 50 ký tự (`maxLength={50}`).
- Giới hạn độ dài các trường nhập liệu được tinh chỉnh thực tế và an toàn: Tên ngân hàng (`bankName`) tối đa 50 ký tự, Tên sổ tiết kiệm (`name`) tối đa 50 ký tự, Số tài khoản / Mã sổ (`accountNumber`) tối đa 30 ký tự, Ghi chú (`notes`) tối đa 200 ký tự.
- Cơ chế validation đa ngôn ngữ sử dụng schema factory `createSavingsFormSchema(t)` theo đúng chuẩn dự án, kết hợp `trigger()` phản ứng khi `t` thay đổi giúp thông báo lỗi validation chuyển đổi ngôn ngữ Anh - Việt lập tức theo thời gian thực.
- Tự động sinh tên sổ tiết kiệm theo mẫu i18n chuẩn hóa (`savings.defaultDepositName`), ví dụ "Sổ Tiết Kiệm Vietcombank 6T" (VI) hoặc "Vietcombank 6M Term Deposit" (EN).
- Bộ chọn kỳ hạn nhanh (1T, 3T, 6T, 12T, 24T) và tùy biến số tháng/lãi suất thủ công.
- Khung ước tính tiền lãi động theo Thông tư 14/2017/TT-NHNN (cơ sở 365 ngày, số ngày thực tế, tổng thu nhập khi đáo hạn).
- Nhập số tiền gốc chuẩn hóa phân cách hàng nghìn (`formatMoneyInput`, `parseMoneyInput`) kèm tích hợp Máy tính tài chính (`CalculatorModal`).
- Chọn ngày gửi qua `LocalizedDateInput` (`DD/MM/YYYY`).
- Thẻ radio chọn phương thức tái tục / đáo hạn (`ROLLOVER_ALL`, `ROLLOVER_PRINCIPAL`, `SETTLE_TO_WALLET`) hiệu ứng đất sét nổi/chìm.
- Tùy chọn nguồn trích tiền mở sổ (Trích từ ví thanh toán khả dụng hoặc khai báo sổ có sẵn).
## Khi cập nhật file này
- Ghi lại các quyết định lâu dài mới về UI/UX hoặc cấu trúc frontend.
......
......@@ -63,6 +63,7 @@ const DataManagementPage = React.lazy(() => import("@/pages/data-management/inde
const AccountHandoverPage = React.lazy(() => import("@/pages/account-handover/index"));
const DebtsPage = React.lazy(() => import("@/pages/debts/index"));
const DebtDetailPage = React.lazy(() => import("@/pages/debts/detail"));
const SavingsPage = React.lazy(() => import("@/pages/savings/index"));
const NotFoundPage = React.lazy(() => import("@/pages/not-found/index"));
import { PermissionGate } from "@/components/shared/PermissionGate";
......@@ -191,6 +192,7 @@ export const Layout = () => {
<Route path="/transfers" element={<AuthGuard><PermissionGate permission={PERMISSIONS.TRANSFER_READ} fallback={<AccessDenied />}><TransfersPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/budgets" element={<AuthGuard><PermissionGate permission={PERMISSIONS.BUDGET_READ} fallback={<AccessDenied />}><BudgetsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/budgets/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.BUDGET_READ} fallback={<AccessDenied />}><BudgetDetailPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/savings" element={<AuthGuard><SavingsPage /></AuthGuard>}></Route>
<Route path="/saving-goals" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ} fallback={<AccessDenied />}><SavingGoalsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/saving-goals/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ} fallback={<AccessDenied />}><SavingGoalDetailPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/reports" element={<AuthGuard><PermissionGate permission={PERMISSIONS.REPORT_READ} fallback={<AccessDenied />}><ReportsPage /></PermissionGate></AuthGuard>}></Route>
......
......@@ -14,7 +14,7 @@ export const NetWorthWidget: React.FC<NetWorthWidgetProps> = ({ clickable = true
const navigate = useNavigate();
const { t, formatCurrency } = useI18n();
// 1. Fetch Wallets balance
// 1. Fetch Wallets balance (separating Liquid from Term Deposits)
const walletsQuery = useWallets({
includeArchived: false,
sortBy: "name",
......@@ -22,12 +22,21 @@ export const NetWorthWidget: React.FC<NetWorthWidgetProps> = ({ clickable = true
page: 1,
limit: 100,
});
const totalWallets = useMemo(() => {
const { liquidWalletsTotal, savingsTotal } = useMemo(() => {
const wallets = walletsQuery.data?.data || [];
return wallets.reduce(
(sum, w) => sum + (w.isArchived ? 0 : Number(w.balance || 0)),
0,
);
let liquid = 0;
let savings = 0;
for (const w of wallets) {
if (w.isArchived) continue;
const bal = Number(w.balance || 0);
if (w.type === "SAVINGS_ACCOUNT") {
savings += bal;
} else {
liquid += bal;
}
}
return { liquidWalletsTotal: liquid, savingsTotal: savings };
}, [walletsQuery.data?.data]);
// 2. Fetch Debts Summary (Lending & Borrowing)
......@@ -36,8 +45,8 @@ export const NetWorthWidget: React.FC<NetWorthWidgetProps> = ({ clickable = true
const totalLending = summary.totalLending || 0;
const totalBorrowing = summary.totalBorrowing || 0;
// Net Worth = Wallets + Lending - Borrowing
const netWorth = totalWallets + totalLending - totalBorrowing;
// Net Worth = Liquid Wallets + Savings Deposits + Lending - Borrowing
const netWorth = liquidWalletsTotal + savingsTotal + totalLending - totalBorrowing;
const isLoading = walletsQuery.isLoading || debtsQuery.isLoading;
......@@ -55,8 +64,9 @@ export const NetWorthWidget: React.FC<NetWorthWidgetProps> = ({ clickable = true
<div className="h-8 w-44 rounded-full bg-clay-primary/25" />
<div className="h-3 w-3/4 rounded-full bg-clay-primary/10" />
</div>
<div className="grid grid-cols-3 gap-2 pt-1">
<div className="grid grid-cols-4 gap-2 pt-1">
<div className="h-14 rounded-clay-sm bg-clay-bg/80" />
<div className="h-14 rounded-clay-sm bg-teal-500/10" />
<div className="h-14 rounded-clay-sm bg-clay-income/10" />
<div className="h-14 rounded-clay-sm bg-clay-expense/10" />
</div>
......@@ -110,15 +120,25 @@ export const NetWorthWidget: React.FC<NetWorthWidgetProps> = ({ clickable = true
</p>
</div>
{/* 3 Component Breakdown Cards */}
<div className="relative grid grid-cols-3 gap-2 pt-1">
{/* Wallets */}
{/* 4 Component Breakdown Cards */}
<div className="relative grid grid-cols-2 sm:grid-cols-4 gap-2 pt-1">
{/* Liquid Wallets */}
<div className="rounded-clay-sm bg-clay-bg/85 p-2.5 border border-clay-highlight/40 flex flex-col justify-between">
<span className="text-[10px] text-clay-text-muted font-bold truncate">
{t("debts.totalWallets")}
{t("debts.liquidWallets")}
</span>
<span className="font-nunito font-extrabold text-[12px] sm:text-xs text-clay-text mt-1 truncate">
{formatCurrency(totalWallets, "VND")}
{formatCurrency(liquidWalletsTotal, "VND")}
</span>
</div>
{/* Term Deposits */}
<div className="rounded-clay-sm bg-teal-500/10 p-2.5 border border-teal-500/25 flex flex-col justify-between">
<span className="text-[10px] text-teal-700 font-bold truncate">
{t("debts.savingsDeposits")}
</span>
<span className="font-nunito font-extrabold text-[12px] sm:text-xs text-teal-700 mt-1 truncate">
{formatCurrency(savingsTotal, "VND")}
</span>
</div>
......
......@@ -648,6 +648,50 @@ export const InfoIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
</svg>
);
// Bank Icon (Ngân hàng)
export const BankIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<line x1="3" y1="21" x2="21" y2="21" />
<line x1="3" y1="10" x2="21" y2="10" />
<polyline points="5 6 12 3 19 6" />
<line x1="4" y1="10" x2="4" y2="21" />
<line x1="20" y1="10" x2="20" y2="21" />
<line x1="8" y1="14" x2="8" y2="17" />
<line x1="12" y1="14" x2="12" y2="17" />
<line x1="16" y1="14" x2="16" y2="17" />
</svg>
);
// PiggyBank / Savings Icon
export const PiggyBankIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M19 5c-1.5 0-2.8 1.4-3 2-3.5-1.5-11-.3-11 5 0 1.8 0 3 2 4.5V20h4v-2h3v2h4v-4c1-.5 1.7-1 2-2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2h-1c-.2-.7-.5-1.4-1-2z" />
<path d="M16 9h.01" />
<path d="M2 10h4" />
</svg>
);
......
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { savingsService } from "@/services/savings.service";
import {
CreateTermDepositInput,
SettleTermDepositInput,
UpdateTermDepositInput,
} from "@/types/savings";
import { walletKeys } from "./use-wallets";
export const savingsKeys = {
all: ["savings"] as const,
lists: () => [...savingsKeys.all, "list"] as const,
list: (params?: Record<string, unknown>) => [...savingsKeys.lists(), params] as const,
details: () => [...savingsKeys.all, "detail"] as const,
detail: (id: string) => [...savingsKeys.details(), id] as const,
preview: (input: unknown) => [...savingsKeys.all, "preview", input] as const,
earlyWithdrawal: (id: string, date?: string) => [...savingsKeys.all, "earlyWithdrawal", id, date] as const,
};
export function useSavings(params?: {
status?: string;
search?: string;
sortBy?: string;
order?: string;
page?: number;
limit?: number;
}) {
return useQuery({
queryKey: savingsKeys.list(params),
queryFn: () => savingsService.getSavings(params),
placeholderData: keepPreviousData,
staleTime: 30_000,
});
}
export function useSavingDetail(id: string) {
return useQuery({
queryKey: savingsKeys.detail(id),
queryFn: () => savingsService.getSaving(id),
enabled: Boolean(id),
staleTime: 30_000,
});
}
export function useSavingsPreview(
input: {
principalAmount: string;
interestRate: string;
termMonths: number;
startDate?: string;
},
enabled = true,
) {
return useQuery({
queryKey: savingsKeys.preview(input),
queryFn: () => savingsService.previewSaving(input),
enabled:
enabled &&
Number(input.principalAmount) > 0 &&
Number(input.interestRate) > 0 &&
input.termMonths > 0,
staleTime: 60_000,
});
}
export function useEarlyWithdrawalPreview(id: string, date?: string, enabled = true) {
return useQuery({
queryKey: savingsKeys.earlyWithdrawal(id, date),
queryFn: () => savingsService.previewEarlyWithdrawal(id, date),
enabled: enabled && Boolean(id),
staleTime: 30_000,
});
}
export function useCreateSaving() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateTermDepositInput) => savingsService.createSaving(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: savingsKeys.all });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useUpdateSaving(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateTermDepositInput) => savingsService.updateSaving(id, input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: savingsKeys.all });
await queryClient.invalidateQueries({ queryKey: savingsKeys.detail(id) });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useSettleSaving(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: SettleTermDepositInput) => savingsService.settleSaving(id, input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: savingsKeys.all });
await queryClient.invalidateQueries({ queryKey: savingsKeys.detail(id) });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
......@@ -27,7 +27,10 @@
"viewDetails": "View details",
"confirm": "Confirm",
"create": "Create",
"pagination": "Pagination"
"pagination": "Pagination",
"notes": "Notes",
"loadFailed": "Failed to load data",
"connectionFailed": "Please check your network connection and try again."
},
"language": {
"label": "Language",
......@@ -151,7 +154,20 @@
"contributionAmountInvalid": "Contribution must be greater than zero with at most 2 decimal places",
"contributionDateRequired": "Please select the contribution time",
"contributionDateFuture": "Contribution time cannot be in the future",
"contributionNoteMax": "Note cannot exceed 500 characters"
"contributionNoteMax": "Note cannot exceed 500 characters",
"savingsNameRequired": "Please enter deposit name",
"savingsNameMax": "Deposit name must be at most 50 characters",
"savingsBankRequired": "Please select or enter a bank",
"savingsBankMax": "Bank name must be at most 50 characters",
"savingsAmountRequired": "Please enter deposit amount",
"savingsAmountInvalid": "Deposit amount must be greater than 0",
"savingsRateRequired": "Please enter interest rate",
"savingsRateInvalid": "Interest rate must be between 0% and 100%/yr",
"savingsTermMin": "Minimum term is 1 month",
"savingsTermMax": "Maximum term is 120 months",
"savingsDateRequired": "Please select deposit date",
"savingsAccountMax": "Account/passbook number must be at most 30 characters",
"savingsNotesMax": "Notes must be at most 200 characters"
},
"auth": {
"email": "Email",
......@@ -280,6 +296,7 @@
"transactions": "Transactions",
"transfers": "Transfers",
"budgets": "Budgets",
"savings": "Savings & Term Deposits",
"savingGoals": "Saving Goals",
"categories": "Categories",
"reports": "Financial Reports",
......@@ -2766,9 +2783,100 @@
"editNotice": "Financial parameters (Principal, Interest Rate, Term, and Method) are fixed to preserve the integrity of the amortization schedule and wallet balances.",
"editSuccess": "Debt information updated successfully!",
"editFailed": "Failed to update debt information",
"saveChanges": "Save Changes",
"saving": "Saving...",
"fixedFinancialTerms": "Immutable Financial Terms"
},
"liquidWallets": "Liquid Wallets",
"savingsDeposits": "Term Deposits"
},
"savings": {
"title": "Savings & Term Deposits",
"tabAll": "All",
"tabActive": "Active",
"tabDueSoon": "Due Soon",
"tabSettled": "Settled",
"tabWallets": "Liquid Wallets",
"tabSavings": "Term Deposits",
"summaryTitle": "Term Deposits & Savings Portfolio",
"totalPrincipal": "Total Principal Deposited",
"totalAccrued": "Accrued Interest Today",
"totalEstimated": "Estimated Interest at Maturity",
"activeCount": "{{count}} active deposits",
"dueSoonCount": "{{count}} due soon",
"createTitle": "Open New Term Deposit",
"editTitle": "Edit Term Deposit",
"settlementTitle": "Settle Term Deposit",
"earlyWithdrawalTitle": "Early Term Withdrawal",
"selectBank": "Deposit Bank",
"otherBank": "Bank Name",
"otherBankPlaceholder": "Enter bank name (e.g. Shinhan Bank, HSBC, Standard Chartered...)",
"bankPresetOther": "Other (Enter bank name...)",
"depositName": "Deposit Name",
"defaultDepositName": "{{bank}} {{term}}M Term Deposit",
"accountNumber": "Account / Passbook Number",
"principalAmount": "Principal Amount",
"termTenor": "Deposit Term",
"termMonths": "Term (Months)",
"interestRate": "Annual Interest Rate (%/yr)",
"startDate": "Deposit Date",
"selectRollover": "Maturity Rollover Action",
"rollover": {
"all": "Auto-renew Principal + Interest",
"allDesc": "Add interest into principal, renew for new term (compound interest)",
"principal": "Renew Principal, Payout Interest",
"principalDesc": "Transfer interest to spending wallet, renew principal for new term",
"settle": "Settle Full to Main Wallet",
"settleDesc": "Transfer both principal and interest to your primary wallet at maturity"
},
"fundingSource": "Funding Source",
"fundingTransfer": "Deduct from Liquid Wallet",
"fundingInitial": "Pre-existing Bank Passbook",
"targetWallet": "Payout Wallet (at maturity / interest)",
"selectWalletLater": "Select payout wallet later",
"selectReceivingWallet": "Select destination wallet for settlement",
"selectWalletRequired": "Please select a wallet to receive funds",
"settleTargetSameAsSavings": "Payout wallet cannot be the savings deposit itself",
"calculatorPreview": "Projected by SBV Standard (Circular 14/2017)",
"regulationNotice": "365-day statutory basis",
"actualDays": "Actual Days",
"days": "days",
"interestExpected": "Projected Interest",
"totalReturn": "Total at Maturity",
"maturityDateOn": "Maturity Date",
"earlyWarningTitle": "Early Withdrawal Notice (Circular 04/2022)",
"earlyWarningNotice": "According to State Bank of Vietnam regulations, early withdrawal forfeits the term interest rate and applies the non-term demand rate ({{rate}}%/yr) for the actual days held.",
"fullTermInterest": "Full Term Expected Interest",
"actualReceivedInterest": "Actual Interest at Non-term Rate",
"interestPenaltyLoss": "Interest Penalty / Opportunity Loss",
"totalPayableToYou": "Net Total Paid to Your Wallet",
"matureFullTitle": "Standard Maturity Settlement — 100% Principal & Interest",
"confirmEarlyWithdrawal": "Confirm Early Withdrawal",
"confirmSettlement": "Confirm Settlement",
"createSuccess": "Term deposit opened successfully!",
"updateSuccess": "Term deposit updated successfully!",
"settleSuccess": "Term deposit settled successfully!",
"dueInDays": "Matures in {{days}} days!",
"daysLeft": "days left",
"matured": "Matured",
"accruedToday": "Accrued Interest Today",
"maturityInterest": "Interest at Maturity",
"rolloverMethod": "Maturity Action",
"settleAction": "Settle / Withdraw",
"months": "months",
"year": "year",
"dueOn": "Maturity",
"empty": "No term deposits found",
"emptyHint": "Create a term deposit to track bank savings, interest earnings, and receive maturity reminders!",
"presetHint": "Quick presets for term & reference rates",
"notesPlaceholder": "Additional notes about this deposit...",
"earlyNotePlaceholder": "Reason for early withdrawal...",
"settleNotePlaceholder": "Settlement notes...",
"cycle": "Cycle #{{cycle}}",
"openNew": "Open Term Deposit",
"status": {
"active": "Active",
"matured": "Matured",
"settled": "Settled",
"earlyWithdrawn": "Early Withdrawn"
}
}
}
......
......@@ -27,7 +27,10 @@
"viewDetails": "Xem chi tiết",
"confirm": "Xác nhận",
"create": "Tạo",
"pagination": "Phân trang"
"pagination": "Phân trang",
"notes": "Ghi chú",
"loadFailed": "Không thể tải dữ liệu",
"connectionFailed": "Vui lòng kiểm tra lại kết nối mạng."
},
"language": {
"label": "Ngôn ngữ",
......@@ -151,7 +154,20 @@
"contributionAmountInvalid": "Số tiền đóng góp phải lớn hơn 0 và có tối đa 2 chữ số thập phân",
"contributionDateRequired": "Vui lòng chọn thời gian đóng góp",
"contributionDateFuture": "Thời gian đóng góp không được nằm trong tương lai",
"contributionNoteMax": "Ghi chú tối đa 500 ký tự"
"contributionNoteMax": "Ghi chú tối đa 500 ký tự",
"savingsNameRequired": "Vui lòng nhập tên sổ tiết kiệm",
"savingsNameMax": "Tên sổ tiết kiệm tối đa 50 ký tự",
"savingsBankRequired": "Vui lòng chọn hoặc nhập ngân hàng",
"savingsBankMax": "Tên ngân hàng tối đa 50 ký tự",
"savingsAmountRequired": "Vui lòng nhập số tiền gửi",
"savingsAmountInvalid": "Số tiền gửi phải lớn hơn 0",
"savingsRateRequired": "Vui lòng nhập lãi suất",
"savingsRateInvalid": "Lãi suất phải từ 0% đến 100%/năm",
"savingsTermMin": "Kỳ hạn tối thiểu 1 tháng",
"savingsTermMax": "Kỳ hạn tối đa 120 tháng",
"savingsDateRequired": "Vui lòng chọn ngày gửi",
"savingsAccountMax": "Số tài khoản / mã sổ tối đa 30 ký tự",
"savingsNotesMax": "Ghi chú tối đa 200 ký tự"
},
"auth": {
"email": "Email",
......@@ -280,6 +296,7 @@
"transactions": "Giao dịch",
"transfers": "Chuyển tiền",
"budgets": "Ngân sách",
"savings": "Sổ Tiết Kiệm & Tích Lũy",
"savingGoals": "Mục tiêu Tiết kiệm",
"categories": "Danh mục Chi tiêu",
"reports": "Báo cáo",
......@@ -2870,6 +2887,99 @@
"saveChanges": "Lưu Thay Đổi",
"saving": "Đang lưu...",
"fixedFinancialTerms": "Thông số tài chính cố định"
},
"liquidWallets": "Ví khả dụng",
"savingsDeposits": "Sổ tiết kiệm"
},
"savings": {
"title": "Sổ Tiết Kiệm & Tích Lũy",
"tabAll": "Tất cả",
"tabActive": "Đang gửi",
"tabDueSoon": "Sắp đến hạn",
"tabSettled": "Đã tất toán",
"tabWallets": "Ví chi tiêu",
"tabSavings": "Sổ tiết kiệm",
"summaryTitle": "Danh Mục Tiết Kiệm Có Kỳ Hạn",
"totalPrincipal": "Tổng tiền gửi gốc",
"totalAccrued": "Lãi tích lũy hôm nay",
"totalEstimated": "Lãi dự kiến khi đáo hạn",
"activeCount": "{{count}} sổ đang gửi",
"dueSoonCount": "{{count}} sổ sắp đáo hạn",
"createTitle": "Mở Sổ Tiết Kiệm Mới",
"editTitle": "Chỉnh Sửa Sổ Tiết Kiệm",
"settlementTitle": "Tất Toán Sổ Tiết Kiệm",
"earlyWithdrawalTitle": "Tất Toán Trước Hạn",
"selectBank": "Ngân hàng gửi tiết kiệm",
"otherBank": "Tên ngân hàng",
"otherBankPlaceholder": "Nhập tên ngân hàng (Ví dụ: Shinhan Bank, HSBC, VIB...)",
"bankPresetOther": "Khác (Nhập tên ngân hàng...)",
"depositName": "Tên sổ tiết kiệm",
"defaultDepositName": "Sổ Tiết Kiệm {{bank}} {{term}}T",
"accountNumber": "Số tài khoản / Mã sổ",
"principalAmount": "Số tiền gửi",
"termTenor": "Kỳ hạn gửi",
"termMonths": "Kỳ hạn (tháng)",
"interestRate": "Lãi suất (%/năm)",
"startDate": "Ngày gửi",
"selectRollover": "Phương thức xử lý khi đáo hạn",
"rollover": {
"all": "Tự động tái tục gốc + lãi",
"allDesc": "Nhập lãi vào gốc, bắt đầu kỳ hạn mới (lãi kép)",
"principal": "Tái tục gốc, nhận lãi",
"principalDesc": "Chuyển lãi về ví chi tiêu, tiếp tục kỳ hạn mới cho gốc",
"settle": "Tất toán về ví chính",
"settleDesc": "Chuyển toàn bộ gốc và lãi về ví thanh toán khi đến hạn"
},
"fundingSource": "Nguồn tiền mở sổ",
"fundingTransfer": "Trích từ Ví khả dụng",
"fundingInitial": "Khai báo sổ đã có sẵn",
"targetWallet": "Ví nhận tiền (khi đáo hạn / nhận lãi)",
"selectWalletLater": "Chọn ví nhận tiền sau",
"selectReceivingWallet": "Chọn ví nhận tiền tất toán",
"selectWalletRequired": "Vui lòng chọn ví nhận tiền tất toán",
"settleTargetSameAsSavings": "Ví nhận tiền tất toán không được là chính ví sổ tiết kiệm",
"calculatorPreview": "Ước tính theo chuẩn NHNN (Thông tư 14/2017)",
"regulationNotice": "Cơ sở năm 365 ngày",
"actualDays": "Số ngày thực tế",
"days": "ngày",
"interestExpected": "Lãi dự kiến",
"totalReturn": "Tổng tiền nhận về",
"maturityDateOn": "Ngày đáo hạn",
"earlyWarningTitle": "Cảnh báo rút trước hạn (Thông tư 04/2022)",
"earlyWarningNotice": "Theo quy định của Ngân hàng Nhà nước, khi tất toán trước hạn, toàn bộ số tiền sẽ chỉ được tính theo lãi suất tiền gửi không kỳ hạn ({{rate}}%/năm) cho số ngày thực gửi.",
"fullTermInterest": "Lãi kỳ hạn đúng hạn",
"actualReceivedInterest": "Lãi thực nhận (lãi suất không kỳ hạn)",
"interestPenaltyLoss": "Thiệt hại tiền lãi do rút sớm",
"totalPayableToYou": "Tổng tiền thực nhận về ví",
"matureFullTitle": "Tất toán đúng hạn — Nhận đủ 100% gốc và lãi kỳ hạn",
"confirmEarlyWithdrawal": "Xác nhận Rút trước hạn",
"confirmSettlement": "Xác nhận Tất toán",
"createSuccess": "Mở sổ tiết kiệm thành công!",
"updateSuccess": "Cập nhật sổ tiết kiệm thành công!",
"settleSuccess": "Tất toán sổ tiết kiệm thành công!",
"dueInDays": "Đáo hạn sau {{days}} ngày!",
"daysLeft": "ngày nữa",
"matured": "Đã đáo hạn",
"accruedToday": "Lãi tạm tính hôm nay",
"maturityInterest": "Lãi khi đáo hạn",
"rolloverMethod": "Phương thức đáo hạn",
"settleAction": "Tất toán / Rút sổ",
"months": "tháng",
"year": "năm",
"dueOn": "Đáo hạn",
"empty": "Chưa có sổ tiết kiệm nào",
"emptyHint": "Tạo sổ tiết kiệm để theo dõi tiền gửi ngân hàng, lãi suất và nhận cảnh báo trước ngày đáo hạn!",
"presetHint": "Chọn nhanh kỳ hạn & lãi suất tham chiếu",
"notesPlaceholder": "Ghi chú thêm về sổ tiết kiệm...",
"earlyNotePlaceholder": "Lý do rút trước hạn...",
"settleNotePlaceholder": "Ghi chú tất toán...",
"cycle": "Chu kỳ #{{cycle}}",
"openNew": "Mở Sổ Tiết Kiệm",
"status": {
"active": "Đang gửi",
"matured": "Đã đến hạn",
"settled": "Đã tất toán",
"earlyWithdrawn": "Rút trước hạn"
}
}
}
......
......@@ -1203,6 +1203,69 @@ const GUEST_AI_CHAT = {
],
};
const GUEST_SAVINGS = [
{
id: "savings-guest-1",
userId: "guest-user-session",
walletId: "wallet-guest-savings-1",
walletName: "Sổ Tiết Kiệm VCB 6T",
bankName: "Vietcombank",
accountNumber: "STK-987211",
principalAmount: "50000000",
interestRate: "4.8",
termMonths: 6,
startDate: `${currentYear}-01-15`,
maturityDate: `${currentYear}-07-15`,
estimatedInterest: "1200000",
rolloverMethod: "ROLLOVER_ALL",
targetWalletId: "wallet-guest-2",
status: "ACTIVE",
isDueSoon: false,
accrued: {
accruedInterest: "650000",
progressPercentage: 54,
elapsedDays: 98,
totalDays: 181,
remainingDays: 83,
},
rolloverCount: 0,
color: "#0F766E",
notes: "Tiết kiệm tích lũy định kỳ",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "savings-guest-2",
userId: "guest-user-session",
walletId: "wallet-guest-savings-2",
walletName: "Sổ Tiết Kiệm TCB 12T",
bankName: "Techcombank",
accountNumber: "STK-112345",
principalAmount: "100000000",
interestRate: "5.5",
termMonths: 12,
startDate: `${currentYear - 1}-10-20`,
maturityDate: `${currentYear}-10-20`,
estimatedInterest: "5500000",
rolloverMethod: "ROLLOVER_PRINCIPAL",
targetWalletId: "wallet-guest-2",
status: "ACTIVE",
isDueSoon: true,
accrued: {
accruedInterest: "5420000",
progressPercentage: 98,
elapsedDays: 360,
totalDays: 365,
remainingDays: 5,
},
rolloverCount: 1,
color: "#DC2626",
notes: "Sổ tiết kiệm dự phòng",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
function makeMeta(total: number, page = 1, limit = 100) {
return {
total,
......@@ -1468,6 +1531,20 @@ export function handleGuestRequest(config: InternalAxiosRequestConfig): AxiosRes
responseData = { success: true, data: GUEST_AI_CHAT };
} else if (url.includes("/zalo-bot/link-status")) {
responseData = { success: true, data: { linked: false, zaloBotChatId: null } };
} else if (url.includes("/savings")) {
responseData = {
success: true,
data: GUEST_SAVINGS,
summary: {
totalPrincipal: "150000000",
totalEstimatedInterest: "6700000",
totalAccruedInterest: "6070000",
activeCount: 2,
dueSoonCount: 1,
settledCount: 0,
},
meta: makeMeta(GUEST_SAVINGS.length, 1, 50),
};
} else if (url.includes("/auth/sessions")) {
responseData = { success: true, data: [], items: [], meta: makeMeta(0, 1, 20) };
} else {
......@@ -1588,6 +1665,36 @@ export function handleGuestRequest(config: InternalAxiosRequestConfig): AxiosRes
})),
},
};
} else if (url.includes("/savings/preview")) {
let body: Record<string, unknown> = {};
try {
body = typeof config.data === "string" ? JSON.parse(config.data || "{}") : (config.data || {});
} catch {
body = {};
}
const principal = Number(body.principalAmount) || 50000000;
const rate = Number(body.interestRate) || 4.8;
const term = Number(body.termMonths) || 6;
const estInterest = Math.round((principal * (rate / 100) * term * 30) / 365);
responseData = {
success: true,
data: {
principalAmount: String(principal),
interestRate: String(rate),
termMonths: term,
startDate: (body.startDate as string) || todayStr,
maturityDate: `${currentYear}-12-31`,
actualDays: term * 30,
estimatedInterest: String(estInterest),
totalAtMaturity: String(principal + estInterest),
},
};
} else if (url.includes("/savings")) {
responseData = {
success: true,
data: GUEST_SAVINGS[0],
message: "Thao tác sổ tiết kiệm thành công trong chế độ dùng thử",
};
} else {
responseData = {
success: true,
......
......@@ -21,6 +21,7 @@ import { useBudgets } from "@/hooks/use-budgets";
import { useRecurringTransactions } from "@/hooks/use-recurring-transactions";
import { useDiscoveredSubscriptions } from "@/hooks/use-subscriptions";
import { useUnreadNotificationCount } from "@/hooks/use-notifications";
import { useSavings } from "@/hooks/use-savings";
import { todayInBusinessTime, addCalendarDays } from "@/lib/business-time";
import { GuestModeBanner } from "@/components/shared/GuestModeBanner";
......@@ -100,6 +101,10 @@ function HomePage() {
const unreadNotificationsQuery = useUnreadNotificationCount();
const unreadCount = unreadNotificationsQuery.data?.data?.count || 0;
// Savings due soon alert count
const savingsQuery = useSavings({ page: 1, limit: 1 });
const savingsDueSoonCount = savingsQuery.data?.summary?.dueSoonCount || 0;
return (
<Page className="page flex flex-col justify-start overflow-y-auto">
<Header title={t("home.header")} showBackIcon={false} />
......@@ -266,6 +271,21 @@ function HomePage() {
)}
</Button>
</PermissionGate>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/savings")}
className="relative gap-2"
>
<span>{t("home.savings")}</span>
{savingsDueSoonCount > 0 && (
<span
className="absolute right-4 top-1/2 -translate-y-1/2 flex min-h-5 min-w-5 items-center justify-center rounded-full px-1.5 font-nunito text-xs font-extrabold shadow-clay-raised bg-amber-500 text-white animate-pulse"
>
{savingsDueSoonCount > 99 ? "99+" : savingsDueSoonCount}
</span>
)}
</Button>
<PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ}>
<Button variant="secondary" fullWidth onClick={() => navigate("/saving-goals")}>
{t("home.savingGoals")}
......
import React from 'react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { BankIcon, PiggyBankIcon } from '@/components/ui/icons';
import { useI18n } from '@/i18n';
import { TermDeposit } from '@/types/savings';
export interface SavingsCardProps {
deposit: TermDeposit;
onSettle: (deposit: TermDeposit) => void;
onEdit: (deposit: TermDeposit) => void;
}
export const SavingsCard: React.FC<SavingsCardProps> = ({ deposit, onSettle, onEdit }) => {
const { t, formatCurrency } = useI18n();
const isMaturedOrSettled = deposit.status === 'SETTLED' || deposit.status === 'EARLY_WITHDRAWN';
const isActive = deposit.status === 'ACTIVE';
const progressPercent = Math.min(100, Math.max(0, deposit.accrued?.progressPercentage || 0));
const getRolloverLabel = () => {
switch (deposit.rolloverMethod) {
case 'ROLLOVER_ALL':
return t('savings.rollover.all');
case 'ROLLOVER_PRINCIPAL':
return t('savings.rollover.principal');
case 'SETTLE_TO_WALLET':
return t('savings.rollover.settle');
default:
return deposit.rolloverMethod;
}
};
return (
<Card className="group relative overflow-hidden p-4 sm:p-5 flex flex-col gap-3.5 transition-all duration-200">
{/* Decorative background glow */}
<div
className="absolute -right-8 -top-8 h-28 w-28 rounded-full pointer-events-none opacity-20"
style={{ backgroundColor: deposit.color || '#10B981' }}
/>
{/* Header: Bank & Status */}
<div className="relative flex items-center justify-between pb-2.5 border-b border-clay-text-muted/15">
<div className="flex items-center gap-2.5">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white shadow-clay-pressed"
style={{ backgroundColor: deposit.color || '#10B981' }}
>
<BankIcon size={20} />
</div>
<div>
<h3 className="font-baloo font-bold text-sm text-clay-text leading-tight flex items-center gap-1.5">
<span>{deposit.walletName}</span>
</h3>
<div className="flex items-center gap-1.5 text-[11px] font-nunito text-clay-text-muted">
<span className="font-semibold text-clay-primary">{deposit.bankName}</span>
{deposit.accountNumber && (
<>
<span>•</span>
<span>{deposit.accountNumber}</span>
</>
)}
</div>
</div>
</div>
{/* Due soon or status badge */}
<div className="flex flex-col items-end gap-1">
{deposit.isDueSoon && isActive ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-extrabold bg-amber-500/15 text-amber-600 border border-amber-500/30 animate-pulse">
🔔 {t('savings.dueInDays', { days: deposit.accrued.remainingDays })}
</span>
) : isActive ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-clay-income/15 text-clay-income border border-clay-income/25">
{t('savings.status.active')}
</span>
) : deposit.status === 'SETTLED' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-500/15 text-slate-500 border border-slate-500/25">
{t('savings.status.settled')}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/15 text-rose-500 border border-rose-500/25">
{t('savings.status.earlyWithdrawn')}
</span>
)}
{deposit.rolloverCount > 0 && (
<span className="text-[10px] text-clay-text-muted font-medium">
{t('savings.cycle', { cycle: deposit.rolloverCount + 1 })}
</span>
)}
</div>
</div>
{/* Main Principal Amount */}
<div className="relative flex flex-col gap-1">
<div className="flex items-baseline justify-between">
<span className="text-[11px] font-nunito font-semibold text-clay-text-muted">
{t('savings.principalAmount')}
</span>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-clay-sm text-[11px] font-bold bg-clay-bg border border-clay-highlight/30 text-clay-text">
{deposit.termMonths} {t('savings.months')}
</span>
<span className="px-2 py-0.5 rounded-clay-sm text-[11px] font-extrabold bg-clay-primary/10 text-clay-primary border border-clay-primary/20">
{deposit.interestRate}%/{t('savings.year')}
</span>
</div>
</div>
<div className="font-baloo font-extrabold text-2xl text-clay-text tracking-tight">
{formatCurrency(Number(deposit.principalAmount), 'VND')}
</div>
</div>
{/* Tenor Timeline & Progress Bar */}
<div className="relative flex flex-col gap-1.5 py-1">
<div className="flex justify-between items-center text-[10px] font-nunito text-clay-text-muted">
<span>{deposit.startDate}</span>
<span className="font-bold text-clay-text">
{isActive ? `${deposit.accrued.remainingDays} ${t('savings.daysLeft')}` : t('savings.matured')}
</span>
<span>{deposit.maturityDate}</span>
</div>
<div className="w-full h-2 rounded-full bg-clay-bg shadow-clay-pressed overflow-hidden p-0.5 border border-clay-highlight/20">
<div
className="h-full rounded-full transition-all duration-500 bg-clay-primary"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* Financial Breakdown: Accrued today vs Maturity interest */}
<div className="relative grid grid-cols-2 gap-2 pt-0.5">
<div className="rounded-clay-sm bg-clay-income/10 p-2.5 border border-clay-income/20 flex flex-col justify-between">
<div className="flex items-center gap-1 text-[10px] font-bold text-clay-income">
<PiggyBankIcon size={14} />
<span>{t('savings.accruedToday')}</span>
</div>
<span className="font-baloo font-extrabold text-sm sm:text-base text-clay-income mt-1 truncate">
+{formatCurrency(Number(deposit.accrued?.accruedInterest || 0), 'VND')}
</span>
</div>
<div className="rounded-clay-sm bg-clay-primary/10 p-2.5 border border-clay-primary/20 flex flex-col justify-between">
<span className="text-[10px] font-bold text-clay-primary">
{t('savings.maturityInterest')}
</span>
<span className="font-baloo font-extrabold text-sm sm:text-base text-clay-primary mt-1 truncate">
+{formatCurrency(Number(deposit.estimatedInterest), 'VND')}
</span>
</div>
</div>
{/* Rollover method & Target Wallet */}
<div className="relative flex items-center justify-between text-[11px] font-nunito bg-clay-bg/70 px-3 py-2 rounded-clay-sm border border-clay-highlight/30">
<span className="text-clay-text-muted">{t('savings.rolloverMethod')}:</span>
<span className="font-semibold text-clay-text text-right truncate max-w-[60%]">
{getRolloverLabel()}
</span>
</div>
{/* Action Buttons */}
{isActive && (
<div className="relative flex items-center gap-2 pt-1 border-t border-clay-text-muted/10">
<Button
variant="ghost"
onClick={() => onEdit(deposit)}
className="flex-1 text-xs px-3 py-1.5"
>
{t('common.edit')}
</Button>
<Button
variant="primary"
onClick={() => onSettle(deposit)}
className="flex-1 text-xs px-3 py-1.5 shadow-clay-raised"
>
{t('savings.settleAction')}
</Button>
</div>
)}
</Card>
);
};
This diff is collapsed.
import React, { useState } from 'react';
import { useSnackbar } from 'zmp-ui';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import { Input } from '@/components/ui/Input';
import { BankIcon, PiggyBankIcon } from '@/components/ui/icons';
import { useEarlyWithdrawalPreview, useSettleSaving } from '@/hooks/use-savings';
import { useWallets } from '@/hooks/use-wallets';
import { useI18n } from '@/i18n';
import { TermDeposit } from '@/types/savings';
export interface SavingsSettlementModalProps {
isOpen: boolean;
deposit: TermDeposit | null;
onClose: () => void;
onSuccess?: () => void;
}
export const SavingsSettlementModal: React.FC<SavingsSettlementModalProps> = ({
isOpen,
deposit,
onClose,
onSuccess,
}) => {
const { t, formatCurrency } = useI18n();
const { openSnackbar } = useSnackbar();
const [selectedWalletId, setSelectedWalletId] = useState<string>('');
const [settlementNote, setSettlementNote] = useState<string>('');
const walletsQuery = useWallets({
includeArchived: false,
type: 'STANDARD',
sortBy: 'name',
order: 'asc',
page: 1,
limit: 50,
});
const liquidWallets = walletsQuery.data?.data || [];
const validReceivingWallets = liquidWallets.filter((w) => w.id !== deposit?.walletId);
const defaultWalletId =
deposit?.targetWalletId && deposit.targetWalletId !== deposit.walletId
? deposit.targetWalletId
: validReceivingWallets[0]?.id || '';
const effectiveWalletId = selectedWalletId || defaultWalletId;
const todayStr = new Date().toISOString().slice(0, 10);
const isEarly = deposit ? todayStr < deposit.maturityDate : false;
const earlyPreviewQuery = useEarlyWithdrawalPreview(
deposit?.id || '',
todayStr,
isOpen && isEarly,
);
const settleMutation = useSettleSaving(deposit?.id || '');
if (!deposit) return null;
const handleConfirm = async () => {
if (!effectiveWalletId) {
openSnackbar({ text: t('savings.selectWalletRequired'), type: 'error' });
return;
}
if (effectiveWalletId === deposit.walletId) {
openSnackbar({
text: t('savings.settleTargetSameAsSavings') || 'Ví nhận tiền không được trùng với sổ tiết kiệm',
type: 'error',
});
return;
}
try {
await settleMutation.mutateAsync({
targetWalletId: effectiveWalletId,
isEarlyWithdrawal: isEarly,
notes: settlementNote || (isEarly ? 'Tất toán trước hạn' : 'Tất toán đúng hạn'),
});
openSnackbar({ text: t('savings.settleSuccess'), type: 'success' });
onSuccess?.();
onClose();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : t('common.error');
openSnackbar({ text: msg, type: 'error' });
}
};
const earlyCalc = earlyPreviewQuery.data;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEarly ? t('savings.earlyWithdrawalTitle') : t('savings.settlementTitle')}
>
<div className="space-y-4 pt-1">
{/* Deposit Summary Header */}
<div className="rounded-clay-sm bg-clay-bg/80 p-3 border border-clay-highlight/30 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-white shadow-clay-pressed"
style={{ backgroundColor: deposit.color || '#10B981' }}
>
<BankIcon size={18} />
</div>
<div>
<div className="font-bold text-xs text-clay-text">{deposit.walletName}</div>
<div className="text-[11px] text-clay-text-muted">
{deposit.bankName} • {deposit.termMonths}T ({deposit.interestRate}%)
</div>
</div>
</div>
<div className="text-right">
<div className="font-baloo font-extrabold text-sm text-clay-text">
{formatCurrency(Number(deposit.principalAmount), 'VND')}
</div>
<div className="text-[10px] text-clay-text-muted">
{t('savings.dueOn')}: {deposit.maturityDate}
</div>
</div>
</div>
{/* Early Withdrawal Penalty Warning (Circular 04/2022/TT-NHNN) */}
{isEarly ? (
<div className="rounded-clay-md bg-rose-500/10 p-3.5 border border-rose-500/30 flex flex-col gap-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-rose-600">
<span>⚠️ {t('savings.earlyWarningTitle')}</span>
</div>
<p className="text-[11px] font-nunito text-clay-text leading-snug">
{t('savings.earlyWarningNotice', { rate: deposit.nonTermRate || '0.2' })}
</p>
{/* Comparison Delta */}
<div className="rounded-clay-sm bg-clay-surface p-2.5 border border-rose-500/20 flex flex-col gap-1.5 text-xs font-nunito">
<div className="flex justify-between items-center text-clay-text-muted">
<span>{t('savings.fullTermInterest')}:</span>
<span className="font-semibold text-clay-text">
+{formatCurrency(Number(deposit.estimatedInterest), 'VND')}
</span>
</div>
<div className="flex justify-between items-center text-rose-600">
<span>{t('savings.actualReceivedInterest')}:</span>
<span className="font-bold">
+{formatCurrency(Number(earlyCalc?.actualInterestReceived || 0), 'VND')}
</span>
</div>
<div className="flex justify-between items-center text-rose-500 pt-1 border-t border-rose-500/15 font-bold">
<span>{t('savings.interestPenaltyLoss')}:</span>
<span>-{formatCurrency(Number(earlyCalc?.interestLost || 0), 'VND')}</span>
</div>
</div>
{/* Total Return Amount */}
<div className="flex justify-between items-center pt-1 font-baloo">
<span className="text-xs font-bold text-clay-text">{t('savings.totalPayableToYou')}:</span>
<span className="text-lg font-extrabold text-clay-primary">
{formatCurrency(Number(earlyCalc?.totalReceived || deposit.principalAmount), 'VND')}
</span>
</div>
</div>
) : (
/* Normal Maturity Settlement */
<div className="rounded-clay-md bg-clay-surface p-3.5 border border-clay-highlight/40 shadow-clay-pressed flex flex-col gap-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-clay-primary">
<PiggyBankIcon size={16} />
<span>{t('savings.matureFullTitle')}</span>
</div>
<div className="rounded-clay-sm bg-clay-bg/70 p-2.5 border border-clay-highlight/20 flex flex-col gap-1.5 text-xs font-nunito">
<div className="flex justify-between items-center text-clay-text-muted">
<span>{t('savings.principalAmount')}:</span>
<span className="font-semibold text-clay-text">
{formatCurrency(Number(deposit.principalAmount), 'VND')}
</span>
</div>
<div className="flex justify-between items-center text-clay-income font-bold">
<span>{t('savings.maturityInterest')}:</span>
<span>+{formatCurrency(Number(deposit.estimatedInterest), 'VND')}</span>
</div>
<div className="flex justify-between items-center pt-1 border-t border-clay-text-muted/15 font-baloo">
<span className="text-xs font-bold text-clay-text">{t('savings.totalReturn')}:</span>
<span className="text-lg font-extrabold text-clay-primary">
{formatCurrency(
Number(deposit.principalAmount) + Number(deposit.estimatedInterest),
'VND',
)}
</span>
</div>
</div>
</div>
)}
{/* Target Destination Wallet */}
<div>
<label className="block text-xs font-bold text-clay-text mb-1">
{t('savings.selectReceivingWallet')}
</label>
<Select
options={validReceivingWallets.map((w) => ({
value: w.id,
label: `${w.name} (${formatCurrency(Number(w.balance), 'VND')})`,
}))}
value={effectiveWalletId}
onChange={(e) => setSelectedWalletId(e.target.value)}
/>
</div>
{/* Optional Notes */}
<Input
label={t('common.notes')}
placeholder={isEarly ? t('savings.earlyNotePlaceholder') : t('savings.settleNotePlaceholder')}
value={settlementNote}
onChange={(e) => setSettlementNote(e.target.value)}
/>
{/* Action Buttons */}
<div className="flex gap-2.5 pt-2">
<Button
type="button"
variant="ghost"
onClick={onClose}
className="flex-1"
>
{t('common.cancel')}
</Button>
<Button
type="button"
variant={isEarly ? 'danger' : 'primary'}
disabled={settleMutation.isPending}
onClick={handleConfirm}
className="flex-1 font-bold"
>
{isEarly ? t('savings.confirmEarlyWithdrawal') : t('savings.confirmSettlement')}
</Button>
</div>
</div>
</Modal>
);
};
import React from 'react';
export const SavingsSkeleton: React.FC = () => (
<div className="animate-pulse rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-4 sm:p-5 shadow-clay-raised flex flex-col gap-3.5">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5 flex-1">
<div className="h-10 w-10 rounded-full bg-clay-primary/15 shrink-0" />
<div className="space-y-1.5 flex-1">
<div className="h-4 w-3/5 rounded-full bg-clay-primary/20" />
<div className="h-3 w-2/5 rounded-full bg-clay-primary/10" />
</div>
</div>
<div className="h-6 w-20 rounded-full bg-clay-primary/15" />
</div>
{/* Financial Amount Box */}
<div className="rounded-clay-sm bg-clay-bg/70 p-3 border border-clay-highlight/30 grid grid-cols-2 gap-2">
<div className="space-y-1">
<div className="h-2.5 w-16 rounded-full bg-clay-primary/10" />
<div className="h-5 w-28 rounded-full bg-clay-primary/20" />
</div>
<div className="space-y-1">
<div className="h-2.5 w-16 rounded-full bg-clay-primary/10" />
<div className="h-4 w-20 rounded-full bg-clay-primary/15" />
</div>
</div>
{/* Progress Bar Skeleton */}
<div className="space-y-1.5">
<div className="flex justify-between">
<div className="h-3 w-24 rounded-full bg-clay-primary/10" />
<div className="h-3 w-10 rounded-full bg-clay-primary/15" />
</div>
<div className="h-2.5 w-full rounded-full bg-clay-primary/15" />
</div>
{/* Footer info */}
<div className="pt-2 border-t border-clay-text-muted/10 flex justify-between">
<div className="h-3 w-28 rounded-full bg-clay-primary/10" />
<div className="h-3 w-20 rounded-full bg-clay-primary/15" />
</div>
</div>
);
This diff is collapsed.
......@@ -21,13 +21,16 @@ const WalletsPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { openSnackbar } = useSnackbar();
const [search, setSearch] = useState("");
const { intlLocale, t } = useI18n();
// Liquid Wallets State
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim().toLocaleLowerCase(intlLocale));
const [includeArchived, setIncludeArchived] = useState(false);
const [sort, setSort] = useState("createdAt:desc");
const [page, setPage] = useState(1);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const sortOptions = useMemo(() => [
{ value: "createdAt:desc", label: t("wallet.sort.createdDesc") },
{ value: "updatedAt:desc", label: t("wallet.sort.updatedDesc") },
......@@ -40,6 +43,7 @@ const WalletsPage: React.FC = () => {
const [sortBy, order] = sort.split(":") as [WalletSortField, SortOrder];
const query = useMemo<WalletQuery>(() => ({
includeArchived,
type: "STANDARD",
sortBy,
order,
page,
......@@ -48,6 +52,7 @@ const WalletsPage: React.FC = () => {
const summaryQuery = useMemo<WalletQuery>(() => ({
includeArchived: false,
type: "STANDARD",
sortBy: "createdAt",
order: "desc",
page: 1,
......@@ -56,7 +61,7 @@ const WalletsPage: React.FC = () => {
const paginatedWalletsQuery = useWallets(query);
const searchedWalletsQuery = useWalletSearch(
{ includeArchived, sortBy, order },
{ includeArchived, sortBy, order, type: "STANDARD" },
Boolean(deferredSearch),
);
const walletsQuery = deferredSearch ? searchedWalletsQuery : paginatedWalletsQuery;
......@@ -117,10 +122,15 @@ const WalletsPage: React.FC = () => {
return (
<Page className="page">
<Header title={t("wallet.header")} showBackIcon onBackClick={() => isFromReports(location.state) ? navigate(-1) : navigate("/")} />
<Header
title={t("wallet.header")}
showBackIcon
onBackClick={() => isFromReports(location.state) ? navigate(-1) : navigate("/")}
/>
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
<main className="mx-auto mt-3 flex w-full max-w-lg flex-col gap-4 pb-12">
{/* Executive Summary Card */}
<Card className="relative overflow-hidden !bg-clay-primary-dark p-5 !text-clay-on-primary">
<div className="absolute -right-6 -top-7 h-28 w-28 rounded-full bg-clay-on-primary/10" />
<div className="absolute -bottom-9 right-16 h-24 w-24 rounded-full bg-clay-primary-dark/25" />
......@@ -146,6 +156,7 @@ const WalletsPage: React.FC = () => {
</div>
</Card>
{/* Action Header */}
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="clay-title-h2">{t("wallet.list")}</h1>
......@@ -156,6 +167,7 @@ const WalletsPage: React.FC = () => {
</Button>
</div>
{/* Search & Sort Filters */}
<Card className="flex flex-col gap-3 p-4">
<div className="relative">
<span className="pointer-events-none absolute left-4 top-1/2 z-10 -translate-y-1/2 text-clay-text-muted">
......@@ -178,12 +190,14 @@ const WalletsPage: React.FC = () => {
</div>
</Card>
{/* Loading State */}
{walletsQuery.isLoading && (
<div className="flex flex-col gap-4" aria-label={t("wallet.loadingList")}>
{Array.from({ length: 3 }, (_, index) => <WalletSkeleton key={index} />)}
</div>
)}
{/* Error State */}
{walletsQuery.isError && (
<Card className="flex flex-col items-center gap-3 py-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-clay bg-clay-expense/15 text-clay-expense shadow-clay-pressed">!</div>
......@@ -195,6 +209,7 @@ const WalletsPage: React.FC = () => {
</Card>
)}
{/* Empty State */}
{!walletsQuery.isLoading && !walletsQuery.isError && visibleWallets.length === 0 && (
<Card className="flex flex-col items-center gap-3 py-9 text-center">
<div className="rounded-clay-lg bg-clay-info/15 p-4 shadow-clay-pressed"><WalletIcon size={42} /></div>
......@@ -206,6 +221,7 @@ const WalletsPage: React.FC = () => {
</Card>
)}
{/* Wallets List */}
{!walletsQuery.isLoading && !walletsQuery.isError && visibleWallets.length > 0 && (
<div className={`flex flex-col gap-4 transition-opacity duration-200 ease-in-out ${walletsQuery.isFetching ? "opacity-65" : "opacity-100"}`}>
{visibleWallets.map((wallet) => (
......@@ -220,6 +236,7 @@ const WalletsPage: React.FC = () => {
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<nav className="flex items-center justify-between gap-3" aria-label={t("wallet.paginationLabel")}>
<Button variant="secondary" className="px-4 text-sm" disabled={page <= 1 || walletsQuery.isFetching} onClick={() => setPage((current) => current - 1)}>{t("common.previous")}</Button>
......@@ -229,6 +246,7 @@ const WalletsPage: React.FC = () => {
)}
</main>
{/* Modal tạo / sửa ví thường */}
<WalletFormModal
isOpen={isCreateOpen}
isSubmitting={createMutation.isPending}
......
import { apiClient } from '@/lib/api-client';
import {
CreateTermDepositInput,
EarlyWithdrawalCalculation,
SavingsCalculationPreview,
SettleTermDepositInput,
TermDeposit,
TermDepositListResponse,
UpdateTermDepositInput,
} from '@/types/savings';
export const savingsService = {
/**
* Lấy danh sách sổ tiết kiệm kèm thống kê tổng
*/
async getSavings(params?: {
status?: string;
search?: string;
sortBy?: string;
order?: string;
page?: number;
limit?: number;
}): Promise<TermDepositListResponse> {
const response = await apiClient.get<TermDepositListResponse>('/savings', {
params,
});
return response.data;
},
/**
* Xem chi tiết một sổ tiết kiệm
*/
async getSaving(id: string): Promise<TermDeposit> {
const response = await apiClient.get<TermDeposit>(`/savings/${id}`);
return response.data;
},
/**
* Mở sổ tiết kiệm mới
*/
async createSaving(input: CreateTermDepositInput): Promise<TermDeposit> {
const response = await apiClient.post<TermDeposit>('/savings', input);
return response.data;
},
/**
* Cập nhật thông số sổ tiết kiệm
*/
async updateSaving(id: string, input: UpdateTermDepositInput): Promise<TermDeposit> {
const response = await apiClient.put<TermDeposit>(`/savings/${id}`, input);
return response.data;
},
/**
* Tất toán hoặc rút trước hạn
*/
async settleSaving(id: string, input: SettleTermDepositInput): Promise<TermDeposit> {
const response = await apiClient.post<TermDeposit>(`/savings/${id}/settle`, input);
return response.data;
},
/**
* Tính toán live trước khi mở sổ (không lưu database)
*/
async previewSaving(input: {
principalAmount: string;
interestRate: string;
termMonths: number;
startDate?: string;
}): Promise<SavingsCalculationPreview> {
const response = await apiClient.post<SavingsCalculationPreview>('/savings/preview', input);
return response.data;
},
/**
* Ước tính trước hậu quả rút trước hạn (lãi mất, lãi KKH nhận được)
*/
async previewEarlyWithdrawal(id: string, date?: string): Promise<EarlyWithdrawalCalculation> {
const response = await apiClient.get<EarlyWithdrawalCalculation>(
`/savings/${id}/preview-early-withdrawal`,
{ params: { date } },
);
return response.data;
},
};
export type TermDepositStatus = 'ACTIVE' | 'MATURED' | 'SETTLED' | 'EARLY_WITHDRAWN';
export type TermDepositRolloverMethod =
| 'ROLLOVER_ALL'
| 'ROLLOVER_PRINCIPAL'
| 'SETTLE_TO_WALLET';
export interface AccruedInterestCalculation {
elapsedDays: number;
totalDays: number;
progressPercentage: number;
accruedInterest: string;
remainingDays: number;
}
export interface TermDepositRolloverLog {
id: string;
cycleNumber: number;
startDate: string;
endDate: string;
principal: string;
interestEarned: string;
actionTaken: TermDepositRolloverMethod;
createdAt: string;
}
export interface TermDeposit {
id: string;
userId: string;
walletId: string;
walletName: string;
bankName: string;
accountNumber: string | null;
principalAmount: string;
interestRate: string;
termMonths: number;
startDate: string;
maturityDate: string;
rolloverMethod: TermDepositRolloverMethod;
targetWalletId: string | null;
targetWalletName: string | null;
status: TermDepositStatus;
estimatedInterest: string;
actualInterest: string | null;
nonTermRate: string;
settledAt: string | null;
rolloverCount: number;
lastRolloverAt: string | null;
isReminderSentT3: boolean;
isReminderSentT0: boolean;
notes: string | null;
color: string | null;
icon: string | null;
createdAt: string;
updatedAt: string;
accrued: AccruedInterestCalculation;
isDueSoon: boolean;
rolloverLogs?: TermDepositRolloverLog[];
}
export interface SavingsSummary {
totalPrincipal: string;
totalAccruedInterest: string;
totalEstimatedInterest: string;
activeCount: number;
maturedCount: number;
settledCount: number;
dueSoonCount: number;
}
export interface TermDepositListResponse {
data: TermDeposit[];
summary: SavingsSummary;
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface CreateTermDepositInput {
name: string;
bankName: string;
accountNumber?: string;
principalAmount: string;
interestRate: string;
termMonths: number;
startDate?: string;
rolloverMethod?: TermDepositRolloverMethod;
targetWalletId?: string;
sourceWalletId?: string;
nonTermRate?: string;
notes?: string;
color?: string;
icon?: string;
}
export interface UpdateTermDepositInput {
name?: string;
bankName?: string;
accountNumber?: string | null;
interestRate?: string;
rolloverMethod?: TermDepositRolloverMethod;
targetWalletId?: string | null;
nonTermRate?: string;
notes?: string | null;
color?: string;
icon?: string;
}
export interface SettleTermDepositInput {
targetWalletId: string;
settlementDate?: string;
isEarlyWithdrawal?: boolean;
notes?: string;
}
export interface SavingsCalculationPreview {
principalAmount: string;
interestRate: string;
termMonths: number;
startDate: string;
maturityDate: string;
actualDays: number;
estimatedInterest: string;
totalAtMaturity: string;
}
export interface EarlyWithdrawalCalculation {
principalAmount: string;
startDate: string;
withdrawalDate: string;
actualDays: number;
nonTermRate: string;
estimatedTermInterest: string;
actualInterestReceived: string;
interestLost: string;
totalReceived: string;
}
......@@ -2,34 +2,40 @@ export type WalletSortField = "name" | "balance" | "createdAt" | "updatedAt";
export type SortOrder = "asc" | "desc";
export type WalletType = "STANDARD" | "SAVINGS_ACCOUNT";
export interface Wallet {
id: string;
name: string;
balance: string;
currency: string;
type?: WalletType;
icon: string | null;
color: string | null;
description: string | null;
isDefault: boolean;
isArchived: boolean;
termDepositId?: string | null;
createdAt: string;
updatedAt: string;
}
export interface WalletQuery {
includeArchived: boolean;
type?: WalletType;
sortBy: WalletSortField;
order: SortOrder;
page: number;
limit: number;
}
export type WalletSearchQuery = Pick<WalletQuery, "includeArchived" | "sortBy" | "order">;
export type WalletSearchQuery = Pick<WalletQuery, "includeArchived" | "sortBy" | "order" | "type">;
export interface WalletInput {
name: string;
balance?: string;
currency?: string;
type?: WalletType;
icon?: string | null;
color?: string | null;
description?: string | null;
......
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