Commit 3bc1d9fe authored by ThinhNC's avatar ThinhNC

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

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