Commit 92621450 authored by ThinhNC's avatar ThinhNC

feat: add savings form and settlement modals for term deposits

parent 01f97f3f
Pipeline #53729 passed with stage
in 31 seconds
......@@ -1531,6 +1531,36 @@ export function handleGuestRequest(config: InternalAxiosRequestConfig): AxiosRes
responseData = { success: true, data: GUEST_AI_CHAT };
} else if (url.includes("/zalo-bot/link-status")) {
responseData = { success: true, data: { linked: false, zaloBotChatId: null } };
} else if (url.includes("/preview-early-withdrawal")) {
const id = url.split("/savings/")[1]?.split("/preview-early-withdrawal")[0];
const deposit = GUEST_SAVINGS.find((s) => s.id === id) || GUEST_SAVINGS[0];
const principal = Number(deposit.principalAmount) || 50000000;
const termRate = Number(deposit.interestRate) || 4.8;
const nonTermRate = 0.2;
const sDate = new Date(deposit.startDate);
const nowTime = new Date();
const actualDays = Math.max(1, Math.round((nowTime.getTime() - sDate.getTime()) / (1000 * 60 * 60 * 24)));
const fullTermEst = Math.round((principal * (termRate / 100) * (deposit.termMonths * 30)) / 365);
const actualInterestReceived = Math.round((principal * (nonTermRate / 100) * actualDays) / 365);
const interestLost = Math.max(0, fullTermEst - actualInterestReceived);
responseData = {
success: true,
data: {
principalAmount: String(principal),
startDate: deposit.startDate,
withdrawalDate: todayStr,
actualDays,
nonTermRate: "0.20",
estimatedTermInterest: String(fullTermEst),
actualInterestReceived: String(actualInterestReceived),
interestLost: String(interestLost),
totalReceived: String(principal + actualInterestReceived),
},
};
} else if (url.match(/\/savings\/[^/?]+$/)) {
const id = url.split("/savings/")[1]?.split("?")[0];
const deposit = GUEST_SAVINGS.find((s) => s.id === id) || GUEST_SAVINGS[0];
responseData = { success: true, data: deposit };
} else if (url.includes("/savings")) {
responseData = {
success: true,
......@@ -1675,16 +1705,22 @@ export function handleGuestRequest(config: InternalAxiosRequestConfig): AxiosRes
const principal = Number(body.principalAmount) || 50000000;
const rate = Number(body.interestRate) || 4.8;
const term = Number(body.termMonths) || 6;
const estInterest = Math.round((principal * (rate / 100) * term * 30) / 365);
const startDateStr = (body.startDate as string) || todayStr;
const sDate = new Date(startDateStr);
const mDate = new Date(sDate);
mDate.setMonth(mDate.getMonth() + term);
const actualDays = Math.max(1, Math.round((mDate.getTime() - sDate.getTime()) / (1000 * 60 * 60 * 24)));
const estInterest = Math.round((principal * (rate / 100) * actualDays) / 365);
const maturityDateStr = mDate.toISOString().slice(0, 10);
responseData = {
success: true,
data: {
principalAmount: String(principal),
interestRate: String(rate),
termMonths: term,
startDate: (body.startDate as string) || todayStr,
maturityDate: `${currentYear}-12-31`,
actualDays: term * 30,
startDate: startDateStr,
maturityDate: maturityDateStr,
actualDays,
estimatedInterest: String(estInterest),
totalAtMaturity: String(principal + estInterest),
},
......
......@@ -14,7 +14,7 @@ import { useCreateSaving, useSavingsPreview, useUpdateSaving } from '@/hooks/use
import { useWallets } from '@/hooks/use-wallets';
import { TranslationFunction, useI18n } from '@/i18n';
import { formatMoneyInput, parseMoneyInput } from '@/lib/money-input';
import { TermDeposit, TermDepositRolloverMethod } from '@/types/savings';
import { SavingsCalculationPreview, TermDeposit, TermDepositRolloverMethod } from '@/types/savings';
export const BANK_PRESETS = [
{ name: 'Khác', color: '#6B7280' },
......@@ -264,7 +264,41 @@ export const SavingsFormModal: React.FC<SavingsFormModalProps> = ({
isOpen,
);
const preview = previewQuery.data;
const rawPreview = previewQuery.data as any;
const apiPreview: SavingsCalculationPreview | undefined = useMemo(() => {
if (!rawPreview) return undefined;
if (rawPreview.data && typeof rawPreview.data === 'object' && 'estimatedInterest' in rawPreview.data) {
return rawPreview.data;
}
if ('estimatedInterest' in rawPreview) {
return rawPreview;
}
return undefined;
}, [rawPreview]);
// Client-side instant calculation fallback (Thông tư 14/2017/TT-NHNN)
const fallbackPreview: SavingsCalculationPreview = useMemo(() => {
const principal = Number(watchedPrincipal) || 0;
const rate = Number(watchedRate) || 0;
const months = Number(watchedMonths) || 1;
const start = watchedStartDate ? new Date(watchedStartDate) : new Date();
const maturity = new Date(start);
maturity.setMonth(maturity.getMonth() + months);
const actualDays = Math.max(1, Math.round((maturity.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
const estimatedInterest = Math.round((principal * (rate / 100) * actualDays) / 365);
return {
principalAmount: String(principal),
interestRate: String(rate),
termMonths: months,
startDate: watchedStartDate || defaultStartDate,
maturityDate: maturity.toISOString().slice(0, 10),
actualDays,
estimatedInterest: String(estimatedInterest),
totalAtMaturity: String(principal + estimatedInterest),
};
}, [watchedPrincipal, watchedRate, watchedMonths, watchedStartDate, defaultStartDate]);
const preview = apiPreview || fallbackPreview;
const handleBankDropdownChange = (selectedKey: string) => {
setSelectedBankKey(selectedKey);
......@@ -426,7 +460,7 @@ export const SavingsFormModal: React.FC<SavingsFormModalProps> = ({
{t('savings.interestExpected')}
</span>
<span className="font-baloo font-extrabold text-xs text-clay-income truncate block">
+{formatCurrency(Number(preview.estimatedInterest), 'VND')}
+{formatCurrency(Number(preview.estimatedInterest) || 0, 'VND')}
</span>
</div>
)}
......@@ -606,7 +640,7 @@ export const SavingsFormModal: React.FC<SavingsFormModalProps> = ({
{t('savings.interestExpected')}
</span>
<span className="font-baloo font-extrabold text-xs text-clay-income truncate mt-0.5">
+{formatCurrency(Number(preview.estimatedInterest), 'VND')}
+{formatCurrency(Number(preview.estimatedInterest) || 0, 'VND')}
</span>
</div>
<div className="flex flex-col p-2 rounded-clay-sm bg-clay-bg/70 shadow-clay-pressed">
......@@ -614,7 +648,7 @@ export const SavingsFormModal: React.FC<SavingsFormModalProps> = ({
{t('savings.totalReturn')}
</span>
<span className="font-baloo font-extrabold text-xs text-clay-primary truncate mt-0.5">
{formatCurrency(Number(preview.totalAtMaturity), 'VND')}
{formatCurrency(Number(preview.totalAtMaturity) || 0, 'VND')}
</span>
</div>
</div>
......
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { useSnackbar } from 'zmp-ui';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
......@@ -8,7 +8,7 @@ import { BankIcon, PiggyBankIcon } from '@/components/ui/icons';
import { useEarlyWithdrawalPreview, useSettleSaving } from '@/hooks/use-savings';
import { useWallets } from '@/hooks/use-wallets';
import { useI18n } from '@/i18n';
import { TermDeposit } from '@/types/savings';
import { EarlyWithdrawalCalculation, TermDeposit } from '@/types/savings';
export interface SavingsSettlementModalProps {
isOpen: boolean;
......@@ -89,7 +89,42 @@ export const SavingsSettlementModal: React.FC<SavingsSettlementModalProps> = ({
}
};
const earlyCalc = earlyPreviewQuery.data;
const rawEarlyCalc = earlyPreviewQuery.data as any;
const apiEarlyCalc: EarlyWithdrawalCalculation | undefined = useMemo(() => {
if (!rawEarlyCalc) return undefined;
if (rawEarlyCalc.data && typeof rawEarlyCalc.data === 'object' && 'actualInterestReceived' in rawEarlyCalc.data) {
return rawEarlyCalc.data;
}
if ('actualInterestReceived' in rawEarlyCalc) {
return rawEarlyCalc;
}
return undefined;
}, [rawEarlyCalc]);
const fallbackEarlyCalc: EarlyWithdrawalCalculation | undefined = useMemo(() => {
if (!deposit) return undefined;
const principal = Number(deposit.principalAmount) || 0;
const nonTerm = Number(deposit.nonTermRate) || 0.2;
const sDate = new Date(deposit.startDate);
const nowTime = new Date();
const actualDays = Math.max(1, Math.round((nowTime.getTime() - sDate.getTime()) / (1000 * 60 * 60 * 24)));
const fullTermEst = Number(deposit.estimatedInterest) || 0;
const actualInterestReceived = Math.round((principal * (nonTerm / 100) * actualDays) / 365);
const interestLost = Math.max(0, fullTermEst - actualInterestReceived);
return {
principalAmount: deposit.principalAmount,
startDate: deposit.startDate,
withdrawalDate: todayStr,
actualDays,
nonTermRate: deposit.nonTermRate || '0.20',
estimatedTermInterest: deposit.estimatedInterest,
actualInterestReceived: String(actualInterestReceived),
interestLost: String(interestLost),
totalReceived: String(principal + actualInterestReceived),
};
}, [deposit, todayStr]);
const earlyCalc = apiEarlyCalc || fallbackEarlyCalc;
return (
<Modal
......@@ -145,12 +180,12 @@ export const SavingsSettlementModal: React.FC<SavingsSettlementModalProps> = ({
<div className="flex justify-between items-center text-rose-600">
<span>{t('savings.actualReceivedInterest')}:</span>
<span className="font-bold">
+{formatCurrency(Number(earlyCalc?.actualInterestReceived || 0), 'VND')}
+{formatCurrency(Number(earlyCalc?.actualInterestReceived) || 0, 'VND')}
</span>
</div>
<div className="flex justify-between items-center text-rose-500 pt-1 border-t border-rose-500/15 font-bold">
<span>{t('savings.interestPenaltyLoss')}:</span>
<span>-{formatCurrency(Number(earlyCalc?.interestLost || 0), 'VND')}</span>
<span>-{formatCurrency(Number(earlyCalc?.interestLost) || 0, 'VND')}</span>
</div>
</div>
......@@ -158,7 +193,7 @@ export const SavingsSettlementModal: React.FC<SavingsSettlementModalProps> = ({
<div className="flex justify-between items-center pt-1 font-baloo">
<span className="text-xs font-bold text-clay-text">{t('savings.totalPayableToYou')}:</span>
<span className="text-lg font-extrabold text-clay-primary">
{formatCurrency(Number(earlyCalc?.totalReceived || deposit.principalAmount), 'VND')}
{formatCurrency(Number(earlyCalc?.totalReceived) || Number(deposit.principalAmount) || 0, 'VND')}
</span>
</div>
</div>
......
......@@ -21,42 +21,62 @@ export const savingsService = {
page?: number;
limit?: number;
}): Promise<TermDepositListResponse> {
const response = await apiClient.get<TermDepositListResponse>('/savings', {
const response = await apiClient.get<any>('/savings', {
params,
});
return response.data;
const resData = response.data;
if (resData && typeof resData === 'object' && resData.data && resData.summary) {
return resData as TermDepositListResponse;
}
return resData as TermDepositListResponse;
},
/**
* Xem chi tiết một sổ tiết kiệm
*/
async getSaving(id: string): Promise<TermDeposit> {
const response = await apiClient.get<TermDeposit>(`/savings/${id}`);
return response.data;
const response = await apiClient.get<any>(`/savings/${id}`);
const resData = response.data;
if (resData && typeof resData === 'object' && resData.data && typeof resData.data === 'object' && 'id' in resData.data) {
return resData.data as TermDeposit;
}
return resData as TermDeposit;
},
/**
* Mở sổ tiết kiệm mới
*/
async createSaving(input: CreateTermDepositInput): Promise<TermDeposit> {
const response = await apiClient.post<TermDeposit>('/savings', input);
return response.data;
const response = await apiClient.post<any>('/savings', input);
const resData = response.data;
if (resData && typeof resData === 'object' && resData.data && typeof resData.data === 'object' && 'id' in resData.data) {
return resData.data as TermDeposit;
}
return resData as TermDeposit;
},
/**
* Cập nhật thông số sổ tiết kiệm
*/
async updateSaving(id: string, input: UpdateTermDepositInput): Promise<TermDeposit> {
const response = await apiClient.put<TermDeposit>(`/savings/${id}`, input);
return response.data;
const response = await apiClient.put<any>(`/savings/${id}`, input);
const resData = response.data;
if (resData && typeof resData === 'object' && resData.data && typeof resData.data === 'object' && 'id' in resData.data) {
return resData.data as TermDeposit;
}
return resData as TermDeposit;
},
/**
* Tất toán hoặc rút trước hạn
*/
async settleSaving(id: string, input: SettleTermDepositInput): Promise<TermDeposit> {
const response = await apiClient.post<TermDeposit>(`/savings/${id}/settle`, input);
return response.data;
const response = await apiClient.post<any>(`/savings/${id}/settle`, input);
const resData = response.data;
if (resData && typeof resData === 'object' && resData.data && typeof resData.data === 'object' && 'id' in resData.data) {
return resData.data as TermDeposit;
}
return resData as TermDeposit;
},
/**
......@@ -68,18 +88,26 @@ export const savingsService = {
termMonths: number;
startDate?: string;
}): Promise<SavingsCalculationPreview> {
const response = await apiClient.post<SavingsCalculationPreview>('/savings/preview', input);
return response.data;
const response = await apiClient.post<any>('/savings/preview', input);
const resData = response.data;
if (resData && typeof resData === 'object' && resData.data && typeof resData.data === 'object' && 'estimatedInterest' in resData.data) {
return resData.data as SavingsCalculationPreview;
}
return resData as SavingsCalculationPreview;
},
/**
* Ước tính trước hậu quả rút trước hạn (lãi mất, lãi KKH nhận được)
*/
async previewEarlyWithdrawal(id: string, date?: string): Promise<EarlyWithdrawalCalculation> {
const response = await apiClient.get<EarlyWithdrawalCalculation>(
const response = await apiClient.get<any>(
`/savings/${id}/preview-early-withdrawal`,
{ params: { date } },
);
return response.data;
const resData = response.data;
if (resData && typeof resData === 'object' && resData.data && typeof resData.data === 'object' && 'actualInterestReceived' in resData.data) {
return resData.data as EarlyWithdrawalCalculation;
}
return resData as EarlyWithdrawalCalculation;
},
};
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