Commit 7b2032de authored by ThinhNC's avatar ThinhNC

feat: add debt management pages, localization files, and UI components

parent 3bc1d9fe
......@@ -120,6 +120,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- 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`; toàn bộ schema validation Zod, các thẻ dữ liệu, nút hành động, bảng phân bổ, hộp thoại thanh toán/tất toán, thông báo lỗi/thành công và đơn vị tính (%/năm, %/yr, tháng, months, kỳ, periods) đều được chuẩn hóa qua i18n (`vi.json` và `en.json`), tuyệt đối không còn hardcode chuỗi ký tự thô.
- Tách bạch Modal Tạo Mới và Trình Mô Phỏng: Tại modal "Tạo Khoản Nợ / Cho Vay Mới" (chế độ `create`), giao diện tập trung trực tiếp vào form nhập liệu tinh gọn, ẩn hoàn toàn thanh Tabs và lược bỏ tab "Dự tính lịch trả nợ"; chức năng dự tính lịch trả nợ và live preview API query chỉ kích hoạt khi người dùng mở qua nút "Mô phỏng trả nợ" (chế độ `simulator`).
- Chức năng Chỉnh sửa Hợp đồng Nợ (`EditDebtModal`): Cho phép cập nhật các thông tin phi tài chính (Tên khoản nợ, Đối tác, Ví liên kết và Ghi chú) qua `PUT /api/v1/debts/:id`. Các thông số tài chính cốt lõi (Gốc, Lãi suất, Kỳ hạn, Phương thức) được cố định bất biến (Immutable Invariants) kèm thông báo giải thích rõ ràng nhằm bảo toàn tính toàn vẹn của động cơ lịch trả nợ và hạch toán số dư ví. Nút "Chỉnh sửa" được bố trí thuận tiện tại cả phần đầu thẻ (quick action) và hàng nút hành động.
## Khi cập nhật file này
......
......@@ -267,7 +267,6 @@ export const CheckIcon: React.FC<IconProps> = ({ size = 16, ...props }) => (
</svg>
);
// Edit
export const EditIcon: 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}>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
......@@ -275,6 +274,8 @@ export const EditIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
</svg>
);
export const PencilIcon = EditIcon;
// Settings
export const SettingsIcon: 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}>
......@@ -592,3 +593,5 @@ export const ChartBarIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
......@@ -2709,9 +2709,16 @@
"archive": "Archive",
"archiveBtn": "Archive Contract",
"archiveTitle": "Archive Contract",
"confirmArchive": "Are you sure you want to archive this debt contract?",
"archiveSuccess": "Contract archived successfully!",
"archiveFailed": "Failed to archive contract"
"archiveFailed": "Failed to archive contract",
"editBtn": "Edit",
"editTitle": "Edit Debt Information",
"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"
}
}
}
......
......@@ -2810,9 +2810,16 @@
"archive": "Lưu trữ",
"archiveBtn": "Lưu trữ khoản nợ",
"archiveTitle": "Lưu Trữ Khoản Nợ",
"confirmArchive": "Bạn có chắc chắn muốn lưu trữ khoản nợ này?",
"archiveSuccess": "Đã lưu trữ khoản nợ thành công!",
"archiveFailed": "Không thể lưu trữ khoản nợ"
"archiveFailed": "Không thể lưu trữ khoản nợ",
"editBtn": "Chỉnh sửa",
"editTitle": "Chỉnh Sửa Thông Tin Khoản Nợ",
"editNotice": "Các thông số tài chính (Số tiền gốc, Lãi suất, Kỳ hạn, Phương thức tính) được cố định để bảo toàn tính toàn vẹn của bảng lịch trả nợ và số dư ví.",
"editSuccess": "Đã cập nhật thông tin khoản nợ thành công!",
"editFailed": "Không thể cập nhật thông tin khoản nợ",
"saveChanges": "Lưu Thay Đổi",
"saving": "Đang lưu...",
"fixedFinancialTerms": "Thông số tài chính cố định"
}
}
}
......
import React, { useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { useSnackbar } from "zmp-ui";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { AlertTriangleIcon } from "@/components/ui/icons";
import { useUpdateDebt } from "@/hooks/use-debts";
import { useWallets } from "@/hooks/use-wallets";
import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { DebtContract } from "@/types/debt";
const createEditDebtSchema = (t: TranslationFunction) =>
z.object({
name: z
.string()
.trim()
.min(1, t("debts.validation.nameRequired"))
.max(100, t("debts.validation.max100")),
counterparty: z
.string()
.trim()
.min(1, t("debts.validation.counterpartyRequired"))
.max(100, t("debts.validation.max100")),
walletId: z.string().optional(),
notes: z.string().max(500, t("debts.validation.notesMax")).optional(),
});
type EditDebtFormValues = z.infer<ReturnType<typeof createEditDebtSchema>>;
export interface EditDebtModalProps {
isOpen: boolean;
onClose: () => void;
debt: DebtContract;
}
export const EditDebtModal: React.FC<EditDebtModalProps> = ({
isOpen,
onClose,
debt,
}) => {
const { t, formatCurrency } = useI18n();
const { openSnackbar } = useSnackbar();
const updateDebtMutation = useUpdateDebt(debt.id);
const walletsQuery = useWallets({
includeArchived: false,
sortBy: "name",
order: "asc",
page: 1,
limit: 100,
});
const schema = useMemo(() => createEditDebtSchema(t), [t]);
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<EditDebtFormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: debt.name || "",
counterparty: debt.counterparty || "",
walletId: debt.walletId || "",
notes: debt.notes || "",
},
});
// Populate form with latest debt info when opened
useEffect(() => {
if (isOpen) {
reset({
name: debt.name || "",
counterparty: debt.counterparty || "",
walletId: debt.walletId || "",
notes: debt.notes || "",
});
}
}, [isOpen, debt, reset]);
// Wallet options for select
const walletOptions = useMemo(() => {
const list = walletsQuery.data?.data || [];
return [
{ value: "", label: t("debts.modal.selectWallet") },
...list.map((w) => ({
value: w.id,
label: `${w.name} (${formatCurrency(Number(w.balance || 0), "VND")})`,
})),
];
}, [walletsQuery.data?.data, t, formatCurrency]);
const methodLabel =
debt.method === "REDUCING_BALANCE"
? t("debts.methods.reducingBalance")
: debt.method === "FIXED_ANNUITY"
? t("debts.methods.fixedAnnuity")
: t("debts.methods.interestOnly");
const onSubmit = async (data: EditDebtFormValues) => {
try {
await updateDebtMutation.mutateAsync({
name: data.name.trim(),
counterparty: data.counterparty.trim(),
walletId: data.walletId || null,
notes: data.notes?.trim() || null,
});
openSnackbar({
text: t("debts.detail.editSuccess"),
type: "success",
});
onClose();
} catch (err: unknown) {
const msg = getErrorMessage(err, t("debts.detail.editFailed"));
openSnackbar({
text: msg,
type: "error",
});
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t("debts.detail.editTitle")}
footer={
<div className="flex w-full items-center justify-end gap-2 pt-2 border-t border-clay-text-muted/10">
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={updateDebtMutation.isPending}
className="text-xs sm:text-sm"
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="primary"
shape="pill"
onClick={handleSubmit(onSubmit)}
disabled={updateDebtMutation.isPending}
className="text-xs sm:text-sm px-5"
>
{updateDebtMutation.isPending
? t("debts.detail.saving")
: t("debts.detail.saveChanges")}
</Button>
</div>
}
>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
{/* Notice of Fixed Financial Invariants */}
<div className="flex items-start gap-2.5 rounded-clay bg-clay-primary/10 border border-clay-primary/20 p-3 text-xs text-clay-text leading-relaxed">
<AlertTriangleIcon
size={18}
className="text-clay-primary shrink-0 mt-0.5"
/>
<p className="clay-caption text-clay-text font-medium">
{t("debts.detail.editNotice")}
</p>
</div>
{/* Read-only Financial Invariants Banner */}
<div className="grid grid-cols-2 gap-2 bg-clay-bg/75 p-3 rounded-clay border border-clay-highlight/30 text-xs font-nunito">
<div>
<span className="text-[10px] uppercase font-bold text-clay-text-muted block">
{t("debts.card.original")}
</span>
<span className="font-extrabold text-clay-text">
{formatCurrency(Number(debt.principal || 0), "VND")}
</span>
</div>
<div>
<span className="text-[10px] uppercase font-bold text-clay-text-muted block">
{t("debts.card.interestRate")}
</span>
<span className="font-extrabold text-clay-text">
{t("debts.card.ratePerYear", { rate: Number(debt.annualInterestRate) })}
</span>
</div>
<div>
<span className="text-[10px] uppercase font-bold text-clay-text-muted block">
{t("debts.card.term")}
</span>
<span className="font-bold text-clay-text">
{debt.termMonths} {t("debts.card.months")}
</span>
</div>
<div>
<span className="text-[10px] uppercase font-bold text-clay-text-muted block">
{t("debts.detail.method")}
</span>
<span className="font-bold text-clay-text truncate block" title={methodLabel}>
{methodLabel}
</span>
</div>
</div>
{/* Editable Fields */}
<div className="flex flex-col gap-3.5">
{/* Name */}
<Controller
name="name"
control={control}
render={({ field }) => (
<Input
label={t("debts.modal.name")}
placeholder={t("debts.modal.namePlaceholder")}
value={field.value}
onChange={field.onChange}
error={errors.name?.message}
/>
)}
/>
{/* Counterparty */}
<Controller
name="counterparty"
control={control}
render={({ field }) => (
<Input
label={t("debts.modal.counterparty")}
placeholder={t("debts.modal.counterpartyPlaceholder")}
value={field.value}
onChange={field.onChange}
error={errors.counterparty?.message}
/>
)}
/>
{/* Wallet Selection */}
<Controller
name="walletId"
control={control}
render={({ field }) => (
<Select
label={t("debts.modal.wallet")}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value)}
options={walletOptions}
/>
)}
/>
{/* Notes */}
<Controller
name="notes"
control={control}
render={({ field }) => (
<Input
label={t("debts.modal.notes")}
placeholder={t("debts.modal.notesPlaceholder")}
value={field.value || ""}
onChange={field.onChange}
error={errors.notes?.message}
/>
)}
/>
</div>
</form>
</Modal>
);
};
......@@ -20,12 +20,14 @@ import { ProgressBar } from "@/components/ui/ProgressBar";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import {
CalendarIcon,
EditIcon,
HandshakeIcon,
IconGradients,
TrashIcon,
WalletIcon,
ZapIcon,
} from "@/components/ui/icons";
import { EditDebtModal } from "./components/EditDebtModal";
import {
DebtScheduleItem,
DebtScheduleStatus,
......@@ -70,6 +72,9 @@ const DebtDetailPage: React.FC = () => {
// Archive Confirm State
const [isArchiveConfirmOpen, setIsArchiveConfirmOpen] = useState(false);
// Edit Debt Modal State
const [isEditOpen, setIsEditOpen] = useState(false);
if (debtQuery.isLoading) {
return (
<Page className="page flex flex-col justify-start">
......@@ -271,9 +276,20 @@ const DebtDetailPage: React.FC = () => {
</div>
<h1 className="clay-title-h2 mt-0.5">{debt.name}</h1>
</div>
<Badge type={statusBadgeType[debt.status] || "primary"}>
{t(`debts.status.${statusKeyMap[debt.status] || "active"}`)}
</Badge>
<div className="flex items-center gap-2">
<Badge type={statusBadgeType[debt.status] || "primary"}>
{t(`debts.status.${statusKeyMap[debt.status] || "active"}`)}
</Badge>
<button
type="button"
onClick={() => setIsEditOpen(true)}
className="w-8 h-8 rounded-full bg-clay-surface shadow-clay-raised hover:shadow-clay-pressed flex items-center justify-center text-clay-text-muted hover:text-clay-primary transition-all duration-200"
title={t("debts.detail.editBtn")}
aria-label={t("debts.detail.editBtn")}
>
<EditIcon size={15} />
</button>
</div>
</div>
{/* Key Figures Banner */}
......@@ -375,9 +391,17 @@ const DebtDetailPage: React.FC = () => {
</div>
)}
{/* Early Settlement & Archive Buttons */}
{debt.status === "ACTIVE" && (
<div className="pt-2 flex items-center gap-2.5">
{/* Action Buttons: Edit, Settle Early, Archive */}
<div className="pt-2 flex items-center gap-2">
<Button
variant="secondary"
shape="pill"
onClick={() => setIsEditOpen(true)}
className="py-2 px-3.5 text-xs sm:text-sm bg-clay-surface text-clay-text gap-1.5 shadow-clay-raised"
>
<EditIcon size={15} /> {t("debts.detail.editBtn")}
</Button>
{debt.status === "ACTIVE" && (
<Button
variant="secondary"
shape="pill"
......@@ -390,17 +414,17 @@ const DebtDetailPage: React.FC = () => {
>
<ZapIcon size={16} /> {t("debts.detail.settleEarlyBtn")}
</Button>
<Button
variant="ghost"
shape="pill"
onClick={() => setIsArchiveConfirmOpen(true)}
className="px-3 py-2 text-clay-text-muted hover:text-clay-expense text-xs sm:text-sm gap-1.5"
title={t("debts.detail.archive")}
>
<TrashIcon size={16} /> {t("common.delete")}
</Button>
</div>
)}
)}
<Button
variant="ghost"
shape="pill"
onClick={() => setIsArchiveConfirmOpen(true)}
className="px-3 py-2 text-clay-text-muted hover:text-clay-expense text-xs sm:text-sm gap-1.5"
title={t("debts.detail.archive")}
>
<TrashIcon size={16} /> {t("common.delete")}
</Button>
</div>
</Card>
{/* Amortization Schedule Section */}
......@@ -690,6 +714,13 @@ const DebtDetailPage: React.FC = () => {
{t("debts.detail.confirmArchive")}
</p>
</Modal>
{/* Edit Debt Modal */}
<EditDebtModal
isOpen={isEditOpen}
onClose={() => setIsEditOpen(false)}
debt={debt}
/>
</Page>
);
};
......
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