Commit bd119631 authored by ThinhNC's avatar ThinhNC

feat(debts): standardize debts & loans UI, fix modal scrollbars, and enhance...

feat(debts): standardize debts & loans UI, fix modal scrollbars, and enhance forecast depletion contrast
parent b6e40be2
......@@ -18,9 +18,9 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Sử dụng hai phông chữ: `Baloo 2` (Heading & Số tiền) và `Nunito` (Nội dung chính).
- Bo góc tối thiểu 16px (`rounded-clay-sm`), tiêu chuẩn 24px (`rounded-clay`), và lớn 32px (`rounded-clay-lg`).
- **Vùng an toàn cho bóng button**: `shadow-clay-raised` lan ra ngoài button khoảng 8–16px. Khi đặt button trong modal, vùng `overflow` hoặc nhóm nhiều button, phải chừa padding quanh mép và gap đủ lớn để bóng không bị cắt hoặc chồng lên nhau. Không khai báo lại `shadow-clay-raised` trong `className` khi base `Button` đã cung cấp bóng theo variant; vùng nút cuối nội dung cuộn cần có padding đáy riêng.
- **Hiệu ứng Modal**: Base `Modal` dùng hiệu ứng xuất hiện chung gồm backdrop mờ dần và hộp thoại nổi lên/co giãn nhẹ theo Claymorphism; phải tắt animation qua `motion-reduce` khi thiết bị yêu cầu giảm chuyển động.
- **Hiệu ứng Modal & Cuộn**: Base `Modal` dùng hiệu ứng xuất hiện chung gồm backdrop mờ dần và hộp thoại nổi lên/co giãn nhẹ theo Claymorphism; phải tắt animation qua `motion-reduce` khi thiết bị yêu cầu giảm chuyển động. Base `Modal` đã tích hợp sẵn vùng cuộn `overflow-y-auto max-h-[60vh]`; các component con bên trong `children` TUYỆT ĐỐI không khai báo thêm container có `overflow-y-auto` hoặc `max-h-[...]` để tránh sinh ra 2 thanh cuộn lồng nhau (double scrollbars).
- **Nguồn tài nguyên Fonts**: Load thông qua thẻ `<link>` của Google Fonts trực tiếp trong `index.html` để tối ưu thời gian tải trang.
- **Phong cách Icon**: Tự thiết kế các inline SVG dạng blob dày, tròn trịa, nhiều màu sắc pastel thay vì dùng icon nét mảnh phẳng thông thường.
- **Phong cách Icon**: Tự thiết kế các inline SVG dạng blob dày, tròn trịa, nhiều màu sắc pastel thay vì dùng icon nét mảnh phẳng thông thường. TUYỆT ĐỐI KHÔNG dùng raw emoji dạng ký tự văn bản trên giao diện (như 📊, ⚡, 🗑️, 📅, ⚙️, 📈, 📉); tất cả hành động và trạng thái phải sử dụng inline SVG icon chuẩn Claymorphism trong `src/components/ui/icons.tsx`.
- **Hệ thống Light/Dark Theme**: Màu nền, surface, chữ, border, trạng thái và bóng Claymorphism phải đi qua semantic CSS variables được ánh xạ trong `tailwind.config.js`; không gắn màu light-only trực tiếp trong component. Lựa chọn `light`/`dark` được lưu cục bộ bằng Zustand, áp dụng `data-theme` lên `<html>` và đồng bộ `zaui-theme` lên cả `<html>` lẫn `<body>` vì stylesheet của ZaUI dùng selector `body[zaui-theme]`; đồng thời mọi màn hình dùng toggle chung để chuyển đổi nhất quán.
- **Đa ngôn ngữ frontend**: UI hỗ trợ `vi` và `en` qua `src/i18n/`, lưu lựa chọn bằng khóa `finwise.locale` và dùng `Intl` với `vi-VN`/`en-US` cho tiền tệ, số và ngày. Chỉ dịch text hiển thị; enum, mã tiền tệ, ID và payload API giữ nguyên. Khi thêm locale mới, thêm resource/config locale và translation key tương ứng, không đưa text hiển thị trực tiếp vào component.
- **Nhập liệu số tiền**: Mọi ô nhập số tiền trong ứng dụng (gồm số dư ví, số tiền giao dịch trong form, và số tiền tối thiểu/tối đa trong bộ lọc giao dịch) đều được định dạng số tự động (ngăn cách hàng nghìn cục bộ và tối đa 2 chữ số thập phân), đảm bảo trải nghiệm nhập liệu tài chính thống nhất và đồng bộ.
......@@ -109,8 +109,19 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Cấu trúc chuẩn áp dụng đồng nhất cho toàn bộ các trang (bao gồm cả phân hệ Quản lý dữ liệu `/data-management`, Chuyển giao `/account-handover`, Tích hợp API Key & Webhooks `/admin/integrations`, và toàn bộ các trang thuộc Trung tâm quản trị `/admin/*`):
`<Page className="page">` -> `<Header ... />` -> `<IconGradients />` -> `<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-5 pb-12">` (hoặc `max-w-lg`/`max-w-4xl` tùy đặc thù trang).
- **Khoảng cách hàng nút điều khiển trên Header (`.finwise-header-controls`)**: Nút chuyển đổi ngôn ngữ (Language Switcher), Darkmode và Thông báo được đặt cố định ở góc trên bên phải. Để tránh chạm viền hoặc đè lên cụm nút đóng/tùy chọn mặc định của Zalo (capsule buttons chiếm khoảng 88-96px từ mép phải), `.finwise-header-controls` phải giữ khoảng cách `right: calc(var(--zaui-safe-area-inset-right) + 104px)` trên màn hình di động (<= 480px) và `+ 108px` trên desktop; đồng thời `.zaui-header` phải có `padding-right: calc(var(--zaui-safe-area-inset-right) + 224px)` (hoặc `+ 248px`) để tiêu đề không bị che khuất.
- **Quản lý Nợ, Cho Vay & Tài Sản Ròng (Net Worth & Debt Lifecycle)**:
- Widget Tài sản ròng (`NetWorthWidget`) hiển thị tại Trang chủ (`/`) và đầu trang Quản lý nợ (`/debts`): $\text{Net Worth} = \text{Tổng ví} + \text{Cho vay (Lending)} - \text{Nợ phải trả (Borrowing)}$. Số tiền hiển thị bằng font Baloo lớn nổi bật với màu sắc đất sét ngữ nghĩa (`text-clay-primary` cho dương, `text-clay-expense` cho âm) cùng 3 ô phụ thẻ tiền mặt, cho vay và nợ. Hỗ trợ prop `clickable` linh hoạt.
- Phân hệ Nợ tại `/debts` và `/debts/:id`: Hỗ trợ 2 phân loại chính (`DEBT_PAYABLE` vs `LOAN_RECEIVABLE`) và 3 phương pháp khấu hao (`REDUCING_BALANCE`, `FIXED_ANNUITY`, `INTEREST_ONLY`).
- Chuẩn hóa toàn diện theo `finwise-miniapp-fe/AGENTS.md`:
- Bố cục trang thống nhất: `<Page className="page ...">` -> `<Header ... />` -> `<IconGradients />` -> `<main className="mx-auto mt-2 flex w-full max-w-lg flex-col gap-4 pb-12">`.
- Base UI Components bắt buộc: `Card` (thay div thô), `Button` (với các variant semantic `primary`, `secondary`, `ghost`, `danger` và shape `pill`), `Badge`, `Tabs`, `ProgressBar`, `Modal`.
- 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.
- 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`.
## 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.
- Cập nhật danh sách thư viện hoặc thay đổi lớn trong phương thức kết nối API.
......@@ -61,6 +61,8 @@ const IntegrationsPage = React.lazy(() => import("@/pages/integrations/index"));
const StatementsPage = React.lazy(() => import("@/pages/statements/index"));
const DataManagementPage = React.lazy(() => import("@/pages/data-management/index"));
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 NotFoundPage = React.lazy(() => import("@/pages/not-found/index"));
import { PermissionGate } from "@/components/shared/PermissionGate";
......@@ -191,6 +193,8 @@ export const Layout = () => {
<Route path="/forecast" element={<AuthGuard><PermissionGate permission={PERMISSIONS.FORECAST_READ} fallback={<AccessDenied />}><ForecastPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/simulations" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SIMULATION_READ} fallback={<AccessDenied />}><SimulationsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/anomalies" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ANOMALY_READ} fallback={<AccessDenied />}><AnomaliesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/debts" element={<AuthGuard><DebtsPage /></AuthGuard>}></Route>
<Route path="/debts/:id" element={<AuthGuard><DebtDetailPage /></AuthGuard>}></Route>
<Route path="/subscriptions" element={<SubscriptionsRedirect />}></Route>
<Route path="/recurring-transactions" element={<AuthGuard><PermissionGate permission={PERMISSIONS.RECURRING_TRANSACTION_READ} fallback={<AccessDenied />}><RecurringTransactionsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/query" element={<AuthGuard><PermissionGate permission={PERMISSIONS.QUERY_EXECUTE} fallback={<AccessDenied />}><QueryPage /></PermissionGate></AuthGuard>}></Route>
......
import React, { useMemo } from "react";
import { useNavigate } from "zmp-ui";
import { useDebts } from "@/hooks/use-debts";
import { useWallets } from "@/hooks/use-wallets";
import { useI18n } from "@/i18n";
import { Card } from "@/components/ui/Card";
import { ChevronRightIcon, ScaleIcon } from "@/components/ui/icons";
export interface NetWorthWidgetProps {
clickable?: boolean;
}
export const NetWorthWidget: React.FC<NetWorthWidgetProps> = ({ clickable = true }) => {
const navigate = useNavigate();
const { t, formatCurrency } = useI18n();
// 1. Fetch Wallets balance
const walletsQuery = useWallets({
includeArchived: false,
sortBy: "name",
order: "asc",
page: 1,
limit: 100,
});
const totalWallets = useMemo(() => {
const wallets = walletsQuery.data?.data || [];
return wallets.reduce(
(sum, w) => sum + (w.isArchived ? 0 : Number(w.balance || 0)),
0,
);
}, [walletsQuery.data?.data]);
// 2. Fetch Debts Summary (Lending & Borrowing)
const debtsQuery = useDebts({ page: 1, limit: 1 });
const summary = debtsQuery.data?.summary || { totalBorrowing: 0, totalLending: 0 };
const totalLending = summary.totalLending || 0;
const totalBorrowing = summary.totalBorrowing || 0;
// Net Worth = Wallets + Lending - Borrowing
const netWorth = totalWallets + totalLending - totalBorrowing;
const isLoading = walletsQuery.isLoading || debtsQuery.isLoading;
if (isLoading) {
return (
<div className="w-full rounded-clay-lg bg-clay-surface p-4 sm:p-5 shadow-clay-raised border border-clay-highlight/40 animate-pulse flex flex-col gap-3">
<div className="flex items-center justify-between pb-2 border-b border-clay-text-muted/10">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-clay-primary/15" />
<div className="h-4 w-24 rounded-full bg-clay-primary/20" />
</div>
<div className="h-4 w-12 rounded-full bg-clay-primary/15" />
</div>
<div className="py-2 space-y-2">
<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="h-14 rounded-clay-sm bg-clay-bg/80" />
<div className="h-14 rounded-clay-sm bg-clay-income/10" />
<div className="h-14 rounded-clay-sm bg-clay-expense/10" />
</div>
</div>
);
}
const isPositive = netWorth >= 0;
return (
<Card
hoverable={clickable}
onClick={clickable ? () => navigate("/debts") : undefined}
className="group relative overflow-hidden p-4 sm:p-5 flex flex-col gap-3"
>
{/* Decorative clay ambient circles */}
<div className="absolute -right-8 -top-8 h-28 w-28 rounded-full bg-clay-primary/10 pointer-events-none" />
{/* Header */}
<div className="relative flex items-center justify-between pb-2 border-b border-clay-text-muted/10">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-clay-primary/15 text-clay-primary flex items-center justify-center shadow-clay-pressed">
<ScaleIcon size={18} />
</div>
<div>
<span className="font-baloo font-bold text-xs uppercase tracking-wider text-clay-text-muted">
{t("debts.netWorthTitle")}
</span>
</div>
</div>
{clickable && (
<div className="flex items-center text-xs text-clay-primary font-semibold transition-transform duration-200 group-hover:translate-x-0.5">
<span>{t("common.detail")}</span>
<ChevronRightIcon size={16} />
</div>
)}
</div>
{/* Main Net Worth Amount */}
<div className="relative py-1">
<div
className={`font-baloo font-extrabold text-2xl sm:text-3xl tracking-tight ${
isPositive ? "text-clay-primary" : "text-clay-expense"
}`}
>
{formatCurrency(netWorth, "VND")}
</div>
<p className="text-[11px] font-nunito text-clay-text-muted mt-0.5">
{t("debts.netWorthDesc")}
</p>
</div>
{/* 3 Component Breakdown Cards */}
<div className="relative grid grid-cols-3 gap-2 pt-1">
{/* 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")}
</span>
<span className="font-nunito font-extrabold text-[12px] sm:text-xs text-clay-text mt-1 truncate">
{formatCurrency(totalWallets, "VND")}
</span>
</div>
{/* Lending */}
<div className="rounded-clay-sm bg-clay-income/10 p-2.5 border border-clay-income/25 flex flex-col justify-between">
<span className="text-[10px] text-clay-income font-bold truncate">
+ {t("debts.tabs.receivable")}
</span>
<span className="font-nunito font-extrabold text-[12px] sm:text-xs text-clay-income mt-1 truncate">
{formatCurrency(totalLending, "VND")}
</span>
</div>
{/* Borrowing */}
<div className="rounded-clay-sm bg-clay-expense/10 p-2.5 border border-clay-expense/25 flex flex-col justify-between">
<span className="text-[10px] text-clay-expense font-bold truncate">
- {t("debts.tabs.payable")}
</span>
<span className="font-nunito font-extrabold text-[12px] sm:text-xs text-clay-expense mt-1 truncate">
{formatCurrency(totalBorrowing, "VND")}
</span>
</div>
</div>
</Card>
);
};
......@@ -430,6 +430,164 @@ export const ShareIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
</svg>
);
// Debt & Loan Icon
export const DebtIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#clay-3d-shadow)"
{...props}
>
<rect x="2" y="5" width="20" height="14" rx="2" fill="url(#clay-grad-expense)" stroke="rgb(var(--color-clay-text))" />
<line x1="2" y1="10" x2="22" y2="10" stroke="rgb(var(--color-clay-text))" />
<circle cx="7" cy="15" r="1.5" fill="rgb(var(--color-clay-bg))" stroke="none" />
<path d="M14 15h4" stroke="rgb(var(--color-clay-bg))" />
</svg>
);
// Scale / Net Worth Icon
export const ScaleIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z" />
<path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z" />
<path d="M7 21h10" />
<path d="M12 3v18" />
<path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2" />
</svg>
);
// Handshake / Counterparty Icon
export const HandshakeIcon: 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="m11 17 2 2a1 1 0 0 0 1.4 0l4.6-4.6a1 1 0 0 0 0-1.4l-2-2" />
<path d="m18 10 1-1a2 2 0 0 0 0-2.8l-1.4-1.4a2 2 0 0 0-2.8 0l-1 1" />
<path d="m13 13-3-3a2 2 0 0 0-2.8 0l-1.4 1.4a2 2 0 0 0 0 2.8l3 3" />
<path d="m3 21 3-3" />
</svg>
);
// Calendar Icon
export const CalendarIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#clay-3d-shadow)"
{...props}
>
<rect x="3" y="4" width="18" height="18" rx="4" fill="url(#clay-grad-primary)" fillOpacity="0.25" stroke="rgb(var(--color-clay-primary))" />
<line x1="16" y1="2" x2="16" y2="6" stroke="rgb(var(--color-clay-text))" />
<line x1="8" y1="2" x2="8" y2="6" stroke="rgb(var(--color-clay-text))" />
<line x1="3" y1="10" x2="21" y2="10" stroke="rgb(var(--color-clay-text))" />
</svg>
);
// Trending Down Icon
export const TrendingDownIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<polyline points="23 18 13.5 8.5 8.5 13.5 1 6" />
<polyline points="17 18 23 18 23 12" />
</svg>
);
// Trending Up Icon
export const TrendingUpIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
<polyline points="17 6 23 6 23 12" />
</svg>
);
// Zap / Lightning Icon
export const ZapIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#clay-3d-shadow)"
{...props}
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" fill="url(#clay-grad-warning)" stroke="rgb(var(--color-clay-warning))" />
</svg>
);
// Chart Bar Icon
export const ChartBarIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
);
......
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { debtService } from "@/services/debt.service";
import {
CreateDebtInput,
DebtQuery,
EarlySettlementInput,
PayInstallmentInput,
PreviewAmortizationInput,
UpdateDebtInput,
} from "@/types/debt";
import { walletKeys } from "./use-wallets";
export const debtKeys = {
all: ["debts"] as const,
lists: () => [...debtKeys.all, "list"] as const,
list: (query: DebtQuery) => [...debtKeys.lists(), query] as const,
details: () => [...debtKeys.all, "detail"] as const,
detail: (id: string) => [...debtKeys.details(), id] as const,
preview: (input: PreviewAmortizationInput) => [...debtKeys.all, "preview", input] as const,
};
export function useDebts(query: DebtQuery = {}) {
return useQuery({
queryKey: debtKeys.list(query),
queryFn: () => debtService.getDebts(query),
placeholderData: keepPreviousData,
staleTime: 30_000,
});
}
export function useDebt(id: string) {
return useQuery({
queryKey: debtKeys.detail(id),
queryFn: () => debtService.getDebt(id),
enabled: Boolean(id),
staleTime: 30_000,
});
}
export function useDebtPreview(input: PreviewAmortizationInput, enabled = true) {
return useQuery({
queryKey: debtKeys.preview(input),
queryFn: () => debtService.previewAmortization(input),
enabled: enabled && input.principal > 0 && input.termMonths > 0,
staleTime: 60_000,
});
}
export function useCreateDebt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateDebtInput) => debtService.createDebt(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: debtKeys.all });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useUpdateDebt(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateDebtInput) => debtService.updateDebt(id, input),
onSuccess: async (response) => {
queryClient.setQueryData(debtKeys.detail(id), response);
await queryClient.invalidateQueries({ queryKey: debtKeys.all });
},
});
}
export function useArchiveDebt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => debtService.archiveDebt(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: debtKeys.all });
},
});
}
export function usePayInstallment(debtId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ period, input }: { period: number; input?: PayInstallmentInput }) =>
debtService.payInstallment(debtId, period, input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: debtKeys.detail(debtId) });
await queryClient.invalidateQueries({ queryKey: debtKeys.lists() });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
// Invalidate transactions so dashboard/transaction history updates
await queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
export function useSettleEarly(debtId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input?: EarlySettlementInput) => debtService.settleEarly(debtId, input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: debtKeys.detail(debtId) });
await queryClient.invalidateQueries({ queryKey: debtKeys.lists() });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
await queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
......@@ -68,6 +68,8 @@
"anomalies": "Multi-dimensional Anomaly Detection",
"subscriptions": "Auto Subscriptions & Recurring Bills",
"recurringTransactions": "Automated Recurring Transactions",
"debts": "Debts & Loans Management",
"debtDetail": "Debt Contract Details",
"query": "Smart Natural Language Query DSL",
"notifications": "Notifications & Reminders",
"integrations": "API Keys & Webhook Callbacks",
......@@ -249,6 +251,8 @@
"anomalies": "Anomaly Alerts",
"subscriptions": "Recurring Bills",
"recurringTransactions": "Auto Transactions",
"debts": "Debts & Loans",
"netWorth": "Net Worth",
"coreModules": "Core Financials",
"wallets": "My Wallets",
"transactions": "Transactions",
......@@ -2550,6 +2554,113 @@
"budgets": "Budgets:",
"savingGoals": "Goals:"
}
},
"debts": {
"title": "Debts & Loans",
"subtitle": "Track liabilities, amortization schedules & net worth",
"netWorthTitle": "Net Worth",
"netWorthDesc": "Real Net Worth = Cash in Wallets + Loans Receivable - Debts Payable",
"totalBorrowing": "Total Debts Payable",
"totalLending": "Total Loans Receivable",
"totalWallets": "Cash in Wallets",
"activeDebtsCount": "{{count}} active contracts",
"createDebtBtn": "Add Debt / Loan",
"simulatorBtn": "Amortization Simulator",
"tabs": {
"all": "All",
"payable": "Debts Payable",
"receivable": "Loans Receivable"
},
"status": {
"active": "Active",
"completed": "Completed",
"overdue": "Overdue",
"settledEarly": "Settled Early"
},
"methods": {
"reducingBalance": "Reducing Balance",
"fixedAnnuity": "Fixed Annuity (Equal Installments)",
"interestOnly": "Interest Only (Balloon Principal)"
},
"scheduleStatus": {
"scheduled": "Scheduled",
"upcoming": "Upcoming",
"paid": "Paid",
"overdue": "Overdue",
"waived": "Waived"
},
"card": {
"remaining": "Remaining",
"original": "Principal",
"interestRate": "Rate",
"term": "Term",
"months": "months",
"nextDue": "Next Due:",
"paidPercent": "{{percent}}% Paid"
},
"empty": {
"title": "No debt contracts found",
"desc": "Create a new debt contract or simulate repayment schedules to monitor your financial health."
},
"modal": {
"createTitle": "Create New Debt / Loan",
"simulatorTitle": "Debt Amortization Simulator",
"name": "Contract Name",
"namePlaceholder": "e.g. Car Loan, Loan to Friend...",
"counterparty": "Counterparty",
"counterpartyPlaceholder": "e.g. Techcombank, John Doe...",
"type": "Contract Type",
"typePayable": "Debts Payable",
"typeReceivable": "Loans Receivable",
"principal": "Principal Amount (VND)",
"interestRate": "Annual Interest Rate (%/yr)",
"termMonths": "Term (Months)",
"startDate": "Start Date",
"wallet": "Linked Payment Wallet",
"selectWallet": "Select a payment wallet...",
"method": "Amortization Method",
"notes": "Notes (Optional)",
"notesPlaceholder": "Terms, conditions, notes...",
"previewTab": "Preview Schedule",
"summary": "Projected Overview",
"totalInterest": "Total Interest",
"totalPayment": "Total Payment (Principal + Interest)",
"submitBtn": "Save Contract",
"submitting": "Creating...",
"successToast": "Debt contract and amortization schedule created successfully!"
},
"detail": {
"header": "Debt Contract Details",
"generalInfo": "Contract Overview",
"counterparty": "Counterparty",
"method": "Method",
"startDate": "Start Date",
"endDate": "End Date",
"progressTitle": "Repayment Progress",
"paidPrincipal": "Principal Paid",
"paidInterest": "Interest Paid",
"remainingPrincipal": "Remaining Principal",
"scheduleTitle": "Amortization Schedule",
"periodCol": "Period",
"dueDateCol": "Due Date",
"principalCol": "Principal",
"interestCol": "Interest",
"totalCol": "Total Due",
"statusCol": "Status",
"actionCol": "Action",
"payInstallmentBtn": "Pay Period",
"paying": "Paying...",
"paySuccess": "Installment paid successfully!",
"settleEarlyBtn": "Settle Early",
"settleEarlyTitle": "Early Settlement",
"settleEarlyDesc": "You will settle the full remaining principal and all future interest will be waived.",
"penaltyFee": "Prepayment Penalty Fee (if any, VND)",
"confirmSettleEarly": "Confirm Settlement",
"settleEarlySuccess": "Debt contract settled early successfully!",
"archiveBtn": "Archive Contract",
"confirmArchive": "Are you sure you want to archive this debt contract?",
"archiveSuccess": "Contract archived successfully!"
}
}
}
......@@ -68,6 +68,8 @@
"anomalies": "Phát hiện Chi tiêu Bất thường",
"subscriptions": "Hóa đơn & Dịch vụ Định kỳ",
"recurringTransactions": "Giao dịch Tự động Định kỳ",
"debts": "Quản lý Nợ & Cho vay",
"debtDetail": "Chi tiết Khoản nợ",
"query": "Truy vấn Thông minh DSL",
"notifications": "Thông báo & nhắc nhở",
"integrations": "API Key & Webhook Callback",
......@@ -249,6 +251,8 @@
"anomalies": "Cảnh báo Bất thường",
"subscriptions": "Hóa đơn Định kỳ",
"recurringTransactions": "Giao dịch Tự động",
"debts": "Nợ & Cho vay",
"netWorth": "Tài sản ròng",
"coreModules": "Danh mục Nghiệp vụ",
"wallets": "Ví của tôi",
"transactions": "Giao dịch",
......@@ -2651,6 +2655,113 @@
"budgets": "Ngân sách:",
"savingGoals": "Mục tiêu:"
}
},
"debts": {
"title": "Nợ & Cho Vay",
"subtitle": "Theo dõi dòng tiền nợ, lịch trả nợ & 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ợ",
"totalBorrowing": "Tổng nợ phải trả",
"totalLending": "Tổng cho vay",
"totalWallets": "Tổng tiền trong ví",
"activeDebtsCount": "{{count}} khoản đang hoạt động",
"createDebtBtn": "Thêm khoản nợ / cho vay",
"simulatorBtn": "Mô phỏng trả nợ",
"tabs": {
"all": "Tất cả",
"payable": "Tôi nợ",
"receivable": "Cho vay"
},
"status": {
"active": "Đang hoạt động",
"completed": "Đã hoàn tất",
"overdue": "Quá hạn",
"settledEarly": "Tất toán sớm"
},
"methods": {
"reducingBalance": "Dư nợ giảm dần",
"fixedAnnuity": "Niên kim cố định (Trả góp đều)",
"interestOnly": "Trả lãi định kỳ, gốc cuối kỳ"
},
"scheduleStatus": {
"scheduled": "Chờ đến hạn",
"upcoming": "Sắp đến hạn",
"paid": "Đã trả",
"overdue": "Quá hạn",
"waived": "Miễn lãi"
},
"card": {
"remaining": "Dư nợ còn lại",
"original": "Gốc ban đầu",
"interestRate": "Lãi suất",
"term": "Kỳ hạn",
"months": "tháng",
"nextDue": "Kỳ tới:",
"paidPercent": "Đã thanh toán {{percent}}%"
},
"empty": {
"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."
},
"modal": {
"createTitle": "Tạo Khoản Nợ / Cho Vay Mới",
"simulatorTitle": "Trình Mô Phỏng Lịch Trả Nợ",
"name": "Tên khoản nợ / cho vay",
"namePlaceholder": "VD: Vay mua xe, Cho bạn Nam mượn...",
"counterparty": "Đối tác (Bên vay / Bên cho vay)",
"counterpartyPlaceholder": "VD: Techcombank, Anh Tuấn...",
"type": "Loại giao dịch",
"typePayable": "Khoản nợ tôi phải trả",
"typeReceivable": "Khoản tôi cho vay",
"principal": "Số tiền gốc ban đầu (VNĐ)",
"interestRate": "Lãi suất (% / năm)",
"termMonths": "Thời hạn (Số tháng)",
"startDate": "Ngày bắt đầu",
"wallet": "Ví thanh toán liên kết",
"selectWallet": "Chọn ví thanh toán...",
"method": "Phương pháp tính lãi",
"notes": "Ghi chú thêm (tùy chọ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",
"totalInterest": "Tổng lãi dự kiến",
"totalPayment": "Tổng số tiền cả gốc & lãi",
"submitBtn": "Lưu Khoản Nợ",
"submitting": "Đang khởi tạo...",
"successToast": "Đã tạo hợp đồng nợ và sinh lịch trả nợ thành công!"
},
"detail": {
"header": "Chi Tiết Khoản Nợ",
"generalInfo": "Thông tin hợp đồng",
"counterparty": "Đối tác",
"method": "Phương pháp",
"startDate": "Ngày bắt đầu",
"endDate": "Ngày kết thúc",
"progressTitle": "Tiến độ thanh toán",
"paidPrincipal": "Gốc đã thanh toán",
"paidInterest": "Lãi đã thanh toán",
"remainingPrincipal": "Dư nợ gốc còn lại",
"scheduleTitle": "Bảng Lịch Trả Nợ (Amortization Schedule)",
"periodCol": "Kỳ",
"dueDateCol": "Ngày đến hạn",
"principalCol": "Gốc",
"interestCol": "Lãi",
"totalCol": "Tổng trả",
"statusCol": "Trạng thái",
"actionCol": "Thao tác",
"payInstallmentBtn": "Trả kỳ này",
"paying": "Đang trả...",
"paySuccess": "Đã thanh toán kỳ nợ thành công!",
"settleEarlyBtn": "Tất toán trước hạ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.",
"penaltyFee": "Phí phạt tất toán trước hạn (nếu có, VNĐ)",
"confirmSettleEarly": "Xác nhận tất toán",
"settleEarlySuccess": "Đã tất toán toàn bộ khoản nợ thành công!",
"archiveBtn": "Lưu trữ khoản nợ",
"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!"
}
}
}
import React from "react";
import { useNavigate } from "zmp-ui";
import { DebtContract, DebtStatus } from "@/types/debt";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { ChevronRightIcon, DebtIcon, HandshakeIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
export interface DebtCardProps {
debt: DebtContract;
}
export const DebtCard: React.FC<DebtCardProps> = ({ debt }) => {
const navigate = useNavigate();
const { t, formatCurrency, formatDate } = useI18n();
const isPayable = debt.type === "DEBT_PAYABLE";
const remaining = Number(debt.remainingPrincipal || 0);
const principal = Number(debt.principal || 0);
// Status mapping to Design System badge types
const statusBadgeType: Record<DebtStatus, BadgeType> = {
ACTIVE: "primary",
COMPLETED: "income",
DEFAULTED: "expense",
SETTLED_EARLY: "income",
};
const statusLabel = t(
`debts.status.${debt.status === "SETTLED_EARLY" ? "settledEarly" : debt.status.toLowerCase()}`,
);
// Method label
const methodLabel =
debt.method === "REDUCING_BALANCE"
? t("debts.methods.reducingBalance")
: debt.method === "FIXED_ANNUITY"
? t("debts.methods.fixedAnnuity")
: t("debts.methods.interestOnly");
// Progress
const progressPercent =
debt.metrics?.progressPercent ??
(principal > 0
? Math.max(0, Math.min(100, Math.round(((principal - remaining) / principal) * 100)))
: 0);
return (
<Card
hoverable
onClick={() => navigate(`/debts/${debt.id}`)}
className="group flex flex-col gap-3.5 p-4 sm:p-5"
>
{/* Header: Name, Counterparty & Status */}
<div className="flex items-start justify-between gap-2.5">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center shadow-clay-pressed shrink-0 ${
isPayable
? "bg-clay-expense/15 text-clay-expense"
: "bg-clay-income/15 text-clay-income"
}`}
>
<DebtIcon size={20} />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-baloo font-bold text-sm sm:text-base text-clay-text truncate">
{debt.name}
</h3>
<div className="flex items-center gap-1.5 text-xs text-clay-text-muted mt-0.5">
<HandshakeIcon size={13} className="shrink-0" />
<span className="truncate">{debt.counterparty}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Badge type={statusBadgeType[debt.status] || "primary"}>
{statusLabel}
</Badge>
<ChevronRightIcon
size={18}
className="text-clay-text-muted transition-transform duration-200 group-hover:translate-x-0.5"
/>
</div>
</div>
{/* Financial Numbers Banner */}
<div className="grid grid-cols-2 gap-2.5 bg-clay-bg/75 p-3 rounded-clay-sm border border-clay-highlight/40">
<div>
<span className="text-[11px] font-bold text-clay-text-muted block">
{t("debts.card.remaining")}
</span>
<span
className={`font-nunito font-extrabold text-sm sm:text-base ${
isPayable ? "text-clay-expense" : "text-clay-income"
}`}
>
{formatCurrency(remaining, "VND")}
</span>
</div>
<div>
<span className="text-[11px] font-bold text-clay-text-muted block">
{t("debts.card.original")}
</span>
<span className="font-nunito font-bold text-xs sm:text-sm text-clay-text">
{formatCurrency(principal, "VND")}
</span>
</div>
</div>
{/* Progress & Method Info */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-clay-text-muted text-[11px] font-semibold truncate max-w-[65%]">
{methodLabel}
</span>
<span className="font-nunito font-bold text-clay-primary text-[11px]">
{t("debts.card.paidPercent", { percent: progressPercent })}
</span>
</div>
<ProgressBar
value={progressPercent}
type={isPayable ? "primary" : "income"}
height="h-2.5"
/>
</div>
{/* 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="font-nunito">
<span>{Number(debt.annualInterestRate)}%/năm</span>
<span className="mx-1.5">•</span>
<span>
{debt.termMonths} {t("debts.card.months")}
</span>
</div>
{debt.metrics?.nextDueDate && (
<div className="font-semibold text-clay-text">
<span>{t("debts.card.nextDue")} </span>
<span className="text-clay-primary font-nunito">{formatDate(debt.metrics.nextDueDate)}</span>
</div>
)}
</div>
</Card>
);
};
This diff is collapsed.
import React from "react";
export const DebtSkeleton: 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">
<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>
<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-24 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>
<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>
<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.
import React, { useState } from "react";
import { Header, Page, useNavigate } from "zmp-ui";
import { useDebts } from "@/hooks/use-debts";
import { useI18n } from "@/i18n";
import { DebtCard } from "./components/DebtCard";
import { DebtModal } from "./components/DebtModal";
import { DebtSkeleton } from "./components/DebtSkeleton";
import { NetWorthWidget } from "@/components/shared/NetWorthWidget";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Tabs } from "@/components/ui/Tabs";
import {
AlertTriangleIcon,
ChartBarIcon,
DebtIcon,
IconGradients,
PlusIcon,
} from "@/components/ui/icons";
import { getErrorMessage } from "@/lib/error-message";
import { DebtType } from "@/types/debt";
const DebtsPage: React.FC = () => {
const navigate = useNavigate();
const { t } = useI18n();
// Tab filter: "all" | "DEBT_PAYABLE" | "LOAN_RECEIVABLE"
const [activeTab, setActiveTab] = useState<string>("all");
// Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "simulator">("create");
// Debts Query
const debtsQuery = useDebts({
type: activeTab === "all" ? undefined : (activeTab as DebtType),
isArchived: false,
page: 1,
limit: 50,
});
const debtsList = debtsQuery.data?.data || [];
const handleOpenCreate = () => {
setModalMode("create");
setIsModalOpen(true);
};
const handleOpenSimulator = () => {
setModalMode("simulator");
setIsModalOpen(true);
};
return (
<Page className="page flex flex-col justify-start overflow-y-auto">
<Header
title={t("debts.title")}
showBackIcon={true}
onBackClick={() => navigate("/")}
/>
<IconGradients />
<main className="mx-auto mt-2 flex w-full max-w-lg flex-col gap-4 pb-12">
{/* Executive Summary Widget */}
<NetWorthWidget clickable={false} />
{/* Action Header & Buttons */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-0.5">
<div>
<h1 className="clay-title-h2">{t("debts.title")}</h1>
<p className="clay-caption">{t("debts.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="primary"
shape="pill"
onClick={handleOpenCreate}
className="gap-1.5 px-3.5 py-2 text-xs sm:text-sm shadow-clay-raised"
>
<PlusIcon size={16} /> {t("debts.createDebtBtn")}
</Button>
<Button
variant="secondary"
shape="pill"
onClick={handleOpenSimulator}
className="gap-1.5 px-3.5 py-2 text-xs sm:text-sm bg-clay-surface"
>
<ChartBarIcon size={16} /> {t("debts.simulatorBtn")}
</Button>
</div>
</div>
{/* Filter Tabs Card */}
<Card className="p-2.5">
<Tabs
tabs={[
{ key: "all", label: t("debts.tabs.all") },
{ key: "DEBT_PAYABLE", label: t("debts.tabs.payable") },
{ key: "LOAN_RECEIVABLE", label: t("debts.tabs.receivable") },
]}
activeTab={activeTab}
onChange={setActiveTab}
/>
</Card>
{/* Debts List & States */}
{debtsQuery.isLoading ? (
<div className="flex flex-col gap-3.5" aria-label="Loading debts">
{[1, 2, 3].map((n) => (
<DebtSkeleton key={n} />
))}
</div>
) : debtsQuery.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">
<AlertTriangleIcon size={28} />
</div>
<div>
<h2 className="clay-title-h3">{t("common.error")}</h2>
<p className="clay-caption mt-1">
{getErrorMessage(debtsQuery.error, "Không thể tải danh sách hợp đồng nợ")}
</p>
</div>
<Button variant="secondary" className="text-sm" onClick={() => debtsQuery.refetch()}>
{t("common.retry")}
</Button>
</Card>
) : debtsList.length === 0 ? (
<Card className="flex flex-col items-center gap-3.5 py-9 text-center">
<div className="w-16 h-16 rounded-full bg-clay-primary/10 text-clay-primary flex items-center justify-center shadow-clay-pressed">
<DebtIcon size={32} />
</div>
<div>
<h2 className="clay-title-h3">{t("debts.empty.title")}</h2>
<p className="clay-caption mt-1 max-w-xs">{t("debts.empty.desc")}</p>
</div>
<Button
variant="primary"
shape="pill"
onClick={handleOpenCreate}
className="mt-2 text-xs sm:text-sm gap-1.5"
>
<PlusIcon size={16} /> {t("debts.createDebtBtn")}
</Button>
</Card>
) : (
<div className="flex flex-col gap-3.5">
{debtsList.map((debt) => (
<DebtCard key={debt.id} debt={debt} />
))}
</div>
)}
</main>
{/* Create / Simulator Modal */}
<DebtModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
initialMode={modalMode}
/>
</Page>
);
};
export default DebtsPage;
......@@ -5,6 +5,7 @@ import { ProgressBar } from "@/components/ui/ProgressBar";
import { ForecastRunway } from "@/types/forecast";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { AlertTriangleIcon } from "@/components/ui/icons";
interface ForecastRunwayCardProps {
data: ForecastRunway;
......@@ -100,17 +101,22 @@ export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data })
{/* Projected Depletion Date */}
{data.metrics.depletionDate && (
<div className="bg-clay-expense-soft border border-clay-expense/30 p-3.5 rounded-clay-sm">
<span className="text-[11px] text-clay-expense font-bold block">
{t("forecast.depletionAlert")}
</span>
<p className="font-bold text-clay-expense text-sm mt-0.5">
{formatBusinessDate(data.metrics.depletionDate, intlLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
<div className="rounded-clay-sm border border-clay-expense/30 bg-clay-expense/10 p-3.5 flex items-center justify-between gap-3 shadow-clay-pressed">
<div>
<span className="block text-[11px] font-bold text-clay-text-muted">
{t("forecast.depletionAlert")}
</span>
<p className="mt-0.5 font-baloo text-base sm:text-lg font-bold text-clay-expense">
{formatBusinessDate(data.metrics.depletionDate, intlLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
</div>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-clay-expense/15 text-clay-expense shadow-clay-pressed">
<AlertTriangleIcon size={18} />
</div>
</div>
)}
......
......@@ -49,7 +49,7 @@ const ForecastPage: React.FC = () => {
{/* Error State */}
{isError && !isLoading && (
<Card className="p-5 text-center space-y-3 border border-clay-expense/30 bg-clay-expense-soft">
<Card className="p-5 text-center space-y-3 border border-clay-expense/30 bg-clay-expense/10">
<p className="text-clay-expense font-bold text-sm">{t("common.error")}</p>
<p className="text-xs text-clay-text-muted">{t("forecast.fetchError")}</p>
<Button
......
......@@ -212,6 +212,9 @@ function HomePage() {
{t("home.wallets")}
</Button>
</PermissionGate>
<Button variant="secondary" fullWidth onClick={() => navigate("/debts")} className="gap-2">
{t("home.debts")}
</Button>
<PermissionGate permission={PERMISSIONS.TRANSACTION_READ}>
<Button variant="secondary" fullWidth onClick={() => navigate("/transactions")}>
{t("home.transactions")}
......
import { apiClient } from "@/lib/api-client";
import {
AmortizationPreviewResponse,
CreateDebtInput,
DebtDetailResponse,
DebtListResponse,
DebtQuery,
EarlySettlementInput,
PayInstallmentInput,
PreviewAmortizationInput,
UpdateDebtInput,
} from "@/types/debt";
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T): T {
if (!response.success) {
throw new Error(response.message || "Yêu cầu xử lý khoản nợ thất bại");
}
return response;
}
export const debtService = {
/**
* Tính toán và trả về bảng lịch trả nợ dự kiến (không lưu database)
*/
async previewAmortization(input: PreviewAmortizationInput): Promise<AmortizationPreviewResponse> {
const response = await apiClient.post<AmortizationPreviewResponse>("/debts/preview", input);
return ensureSuccess(response.data);
},
/**
* Lấy danh sách hợp đồng nợ kèm tổng quan tài chính
*/
async getDebts(query: DebtQuery): Promise<DebtListResponse> {
const response = await apiClient.get<DebtListResponse>("/debts", {
params: query,
});
return ensureSuccess(response.data);
},
/**
* Xem chi tiết một hợp đồng nợ kèm tiến độ và toàn bộ bảng lịch trả nợ
*/
async getDebt(id: string): Promise<DebtDetailResponse> {
const response = await apiClient.get<DebtDetailResponse>(`/debts/${id}`);
return ensureSuccess(response.data);
},
/**
* Tạo hợp đồng nợ / cho vay mới và tự động sinh toàn bộ kỳ hạn
*/
async createDebt(input: CreateDebtInput): Promise<DebtDetailResponse> {
const response = await apiClient.post<DebtDetailResponse>("/debts", input);
return ensureSuccess(response.data);
},
/**
* Cập nhật thông tin mô tả, đối tác hoặc ví thanh toán
*/
async updateDebt(id: string, input: UpdateDebtInput): Promise<DebtDetailResponse> {
const response = await apiClient.put<DebtDetailResponse>(`/debts/${id}`, input);
return ensureSuccess(response.data);
},
/**
* Lưu trữ (Soft-delete) khoản nợ
*/
async archiveDebt(id: string): Promise<{ success: boolean; message: string }> {
const response = await apiClient.delete<{ success: boolean; message: string }>(`/debts/${id}`);
return ensureSuccess(response.data);
},
/**
* Thanh toán nguyên tử một kỳ hạn (Trừ ví, giảm nợ gốc, hạch toán lãi P&L)
*/
async payInstallment(
id: string,
period: number,
input: PayInstallmentInput = {},
): Promise<{ success: boolean; message: string; data: Record<string, unknown> }> {
const response = await apiClient.post<{ success: boolean; message: string; data: Record<string, unknown> }>(
`/debts/${id}/installments/${period}/pay`,
input,
);
return ensureSuccess(response.data);
},
/**
* Tất toán hợp đồng trước hạn (Miễn lãi tương lai, hoàn tất hợp đồng)
*/
async settleEarly(
id: string,
input: EarlySettlementInput = {},
): Promise<{ success: boolean; message: string; data: Record<string, unknown> }> {
const response = await apiClient.post<{ success: boolean; message: string; data: Record<string, unknown> }>(
`/debts/${id}/settle-early`,
input,
);
return ensureSuccess(response.data);
},
};
export type DebtType = 'DEBT_PAYABLE' | 'LOAN_RECEIVABLE';
export type AmortizationMethod = 'REDUCING_BALANCE' | 'FIXED_ANNUITY' | 'INTEREST_ONLY';
export type DebtStatus = 'ACTIVE' | 'COMPLETED' | 'DEFAULTED' | 'SETTLED_EARLY';
export type DebtScheduleStatus = 'SCHEDULED' | 'UPCOMING' | 'PAID' | 'OVERDUE' | 'WAIVED';
export interface DebtScheduleItem {
id: string;
debtContractId: string;
userId: string;
period: number;
dueDate: string;
principalDue: number | string;
interestDue: number | string;
totalDue: number | string;
principalPaid: number | string;
interestPaid: number | string;
remainingPrincipal: number | string;
status: DebtScheduleStatus;
paidDate?: string | null;
notes?: string | null;
createdAt: string;
updatedAt: string;
}
export interface DebtMetrics {
totalInstallments: number;
paidInstallments: number;
progressPercent: number;
totalPaidPrincipal: number;
totalPaidInterest: number;
remainingPrincipal: number;
nextDueDate: string | null;
nextDueAmount: number;
}
export interface DebtContract {
id: string;
userId: string;
walletId?: string | null;
wallet?: {
id: string;
name: string;
currency: string;
balance?: number | string;
} | null;
name: string;
counterparty: string;
type: DebtType;
method: AmortizationMethod;
status: DebtStatus;
principal: number | string;
remainingPrincipal: number | string;
annualInterestRate: number | string;
termMonths: number;
startDate: string;
endDate?: string | null;
notes?: string | null;
isArchived: boolean;
createdAt: string;
updatedAt: string;
scheduleItems?: DebtScheduleItem[];
metrics?: DebtMetrics;
}
export interface DebtSummary {
totalBorrowing: number;
totalLending: number;
}
export interface DebtListResponse {
success: boolean;
message?: string;
data: DebtContract[];
summary: DebtSummary;
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export interface DebtDetailResponse {
success: boolean;
message?: string;
data: DebtContract & {
metrics: DebtMetrics;
scheduleItems: DebtScheduleItem[];
};
}
export interface AmortizationPreviewInstallment {
period: number;
dueDate: string;
principalDue: number;
interestDue: number;
totalDue: number;
remainingPrincipal: number;
}
export interface AmortizationPreviewSummary {
principal: number;
totalInterest: number;
totalPayment: number;
termMonths: number;
}
export interface AmortizationPreviewData {
method: AmortizationMethod;
summary: AmortizationPreviewSummary;
installments: AmortizationPreviewInstallment[];
}
export interface AmortizationPreviewResponse {
success: boolean;
message?: string;
data: AmortizationPreviewData;
}
export interface PreviewAmortizationInput {
principal: number;
annualInterestRate: number;
termMonths: number;
startDate: string;
method: AmortizationMethod;
}
export interface CreateDebtInput {
name: string;
counterparty: string;
type: DebtType;
method: AmortizationMethod;
principal: number;
annualInterestRate: number;
termMonths: number;
startDate: string;
walletId?: string | null;
notes?: string | null;
}
export interface UpdateDebtInput {
name?: string;
counterparty?: string;
walletId?: string | null;
notes?: string | null;
}
export interface PayInstallmentInput {
walletId?: string;
paidDate?: string;
notes?: string;
}
export interface EarlySettlementInput {
walletId?: string;
paidDate?: string;
penaltyFee?: number;
notes?: string;
}
export interface DebtQuery {
type?: DebtType;
status?: DebtStatus;
isArchived?: boolean;
page?: number;
limit?: number;
}
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