Commit 7c9f5452 authored by ThinhNC's avatar ThinhNC

fix(recurring): remove VND decimals and display notification status badge

parent 2981f315
......@@ -76,14 +76,21 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children
new Intl.NumberFormat(intlLocale, options).format(value), [intlLocale]);
const formatCurrency = useCallback((value: number, currency: string) => {
const normalizedCurrency = (currency || "VND").trim().toUpperCase();
const isVnd = normalizedCurrency === "VND";
const finalValue = isVnd ? Math.round(value) : value;
try {
return new Intl.NumberFormat(intlLocale, {
style: "currency",
currency,
maximumFractionDigits: currency === "VND" ? 0 : 2,
}).format(value);
currency: normalizedCurrency,
minimumFractionDigits: isVnd ? 0 : undefined,
maximumFractionDigits: isVnd ? 0 : 2,
}).format(finalValue);
} catch {
return `${new Intl.NumberFormat(intlLocale, { maximumFractionDigits: 2 }).format(value)} ${currency}`;
return `${new Intl.NumberFormat(intlLocale, {
minimumFractionDigits: isVnd ? 0 : undefined,
maximumFractionDigits: isVnd ? 0 : 2,
}).format(finalValue)} ${normalizedCurrency}`;
}
}, [intlLocale]);
......
......@@ -1266,7 +1266,15 @@
"save": "Save preferences", "saveSuccess": "Notification preferences saved.", "saveFailed": "Could not save notification preferences."
},
"channel": { "IN_APP": "In app", "EMAIL": "Email", "ZALO": "Zalo", "PUSH": "Push" },
"channelHint": { "IN_APP": "Inbox and badge", "EMAIL": "Send to account email", "ZALO": "Connection coming soon", "PUSH": "Connection coming soon" }
"channelHint": { "IN_APP": "Inbox and badge", "EMAIL": "Send to account email", "ZALO": "Personal Zalo Bot message", "PUSH": "Connection coming soon" },
"zaloBotLink": {
"title": "Link Zalo Bot",
"hint": "Send any message to Bot FinWise on Zalo, then enter the Chat ID you receive below.",
"placeholder": "e.g. 6ede9afa66b88fe6d6a9",
"linked": "Zalo Bot linked",
"unlink": "Unlink",
"saveChat": "Save Chat ID"
}
},
"reminder": {
"createShort": "New reminder",
......@@ -1662,6 +1670,7 @@
"attempts": "{{count}} attempt(s)",
"reminderBadge": "Remind {{days}}d before",
"reminderBadgeSameDay": "Remind same day",
"reminderOff": "No reminder",
"reminder": "Reminder",
"frequency": {
"DAILY": "Daily",
......
......@@ -1328,8 +1328,16 @@
"channelHint": {
"IN_APP": "Hộp thư và badge",
"EMAIL": "Gửi tới email tài khoản",
"ZALO": "Kết nối sắp tới",
"ZALO": "Tin nhắn qua Zalo Bot cá nhân",
"PUSH": "Kết nối sắp tới"
},
"zaloBotLink": {
"title": "Liên kết Zalo Bot",
"hint": "Nhắn tin bất kỳ cho Bot FinWise trên Zalo, sau đó nhập Chat ID nhận được vào ô bên dưới.",
"placeholder": "Ví dụ: 6ede9afa66b88fe6d6a9",
"linked": "Đã liên kết Zalo Bot",
"unlink": "Hủy liên kết",
"saveChat": "Lưu Chat ID"
}
},
"reminder": {
......@@ -1763,6 +1771,7 @@
"attempts": "{{count}} lần thử",
"reminderBadge": "Nhắc trước {{days}} ngày",
"reminderBadgeSameDay": "Nhắc cùng ngày",
"reminderOff": "Tắt thông báo",
"reminder": "Nhắc nhở",
"frequency": {
"DAILY": "Hàng ngày",
......
......@@ -10,21 +10,46 @@ export function getNumberSeparators(locale: string): { group: string; decimal: s
};
}
export function formatMoneyInput(value: string, locale: string): string {
export function normalizeAmountInput(amount?: string, currency?: string): string {
if (!amount) return "";
const trimmed = amount.trim();
const isVnd = currency?.trim()?.toUpperCase() === "VND";
if (isVnd || trimmed.endsWith(".00")) {
const num = Number(trimmed);
if (Number.isFinite(num) && (isVnd || num % 1 === 0)) {
return String(Math.trunc(num));
}
}
return trimmed;
}
export function formatMoneyInput(value: string, locale: string, currency?: string): string {
if (!value) return value;
const isVnd = currency?.trim()?.toUpperCase() === "VND";
const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
if (isVnd) {
return groupedInteger;
}
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
}
export const formatAmountInput = formatMoneyInput;
export function parseMoneyInput(value: string, locale: string): string {
export function parseMoneyInput(value: string, locale: string, currency?: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) return "";
const isVnd = currency?.trim()?.toUpperCase() === "VND";
const { group, decimal } = getNumberSeparators(locale);
if (isVnd) {
const integerDigits = trimmedValue.split(group).join("").replace(/\D/g, "").slice(0, 16);
const normalizedInteger = integerDigits.replace(/^0+(?=\d)/, "") || (integerDigits.length > 0 ? "0" : "");
return normalizedInteger;
}
let integerDisplay = trimmedValue;
let decimalDisplay: string | undefined;
......
......@@ -16,15 +16,21 @@ export function formatWalletBalance(balance: string, currency: string, locale: s
return `${balance} ${currency}`;
}
const isVnd = currency === "VND";
const normalizedCurrency = (currency || "VND").trim().toUpperCase();
const isVnd = normalizedCurrency === "VND";
const finalBalance = isVnd ? Math.round(numericBalance) : numericBalance;
try {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
currency: normalizedCurrency,
minimumFractionDigits: isVnd ? 0 : undefined,
maximumFractionDigits: isVnd ? 0 : 2,
}).format(numericBalance);
}).format(finalBalance);
} catch {
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: isVnd ? 0 : 2 }).format(numericBalance)} ${currency}`;
return `${new Intl.NumberFormat(locale, {
minimumFractionDigits: isVnd ? 0 : undefined,
maximumFractionDigits: isVnd ? 0 : 2,
}).format(finalBalance)} ${normalizedCurrency}`;
}
}
......
......@@ -16,7 +16,7 @@ import { getCategoryDisplayName } from "@/lib/category-format";
import { Budget, BudgetPeriod, BudgetType, CreateBudgetInput } from "@/types/budget";
import { CategoryTreeNode } from "@/types/category";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
import { formatAmountInput, parseAmountInput, normalizeAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
......@@ -84,10 +84,12 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
const { intlLocale, t } = useI18n();
const categoriesQuery = useCategoryTree({ type: "EXPENSE", source: "ALL", includeArchived: false });
const schema = useMemo(() => createSchema(t), [t]);
const defaultValues = useMemo<BudgetFormValues>(() => ({
const defaultValues = useMemo<BudgetFormValues>(() => {
const currency = budget?.currency || "VND";
return {
name: budget?.name || "",
amount: budget?.amount || "",
currency: budget?.currency || "VND",
amount: normalizeAmountInput(budget?.amount, currency),
currency,
type: budget?.type || "CATEGORY",
period: budget?.period || "MONTHLY",
categoryId: budget?.categoryId || "",
......@@ -96,7 +98,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
alertThreshold: Number(budget?.alertThreshold || 80),
isRecurring: budget ? Boolean(budget.isRecurring) : true,
rolloverMode: budget?.rolloverMode || "RESET",
}), [budget]);
};
}, [budget]);
const {
control,
......@@ -108,6 +111,21 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
formState: { errors },
} = useForm<BudgetFormValues>({ resolver: zodResolver(schema), defaultValues });
const watchedCurrency = watch("currency") || "VND";
const isVnd = watchedCurrency.trim().toUpperCase() === "VND";
useEffect(() => {
if (isVnd) {
const current = watch("amount");
if (current && current.includes(".")) {
const normalized = normalizeAmountInput(current, "VND");
if (normalized !== current) {
setValue("amount", normalized, { shouldValidate: true, shouldDirty: true });
}
}
}
}, [isVnd, setValue, watch]);
useEffect(() => {
if (isOpen) reset(defaultValues);
}, [defaultValues, isOpen, reset]);
......@@ -196,11 +214,11 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
render={({ field }) => (
<Input
label={t("budget.form.amount")}
inputMode="decimal"
inputMode={isVnd ? "numeric" : "decimal"}
placeholder={t("budget.form.amountPlaceholder")}
value={formatAmountInput(field.value, intlLocale)}
value={formatAmountInput(field.value, intlLocale, watchedCurrency)}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale, watchedCurrency))}
error={errors.amount?.message}
/>
)}
......
......@@ -15,8 +15,12 @@ const channelOptions: NotificationChannel[] = ["IN_APP", "EMAIL", "ZALO", "PUSH"
export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ setting, isSaving, onSave }) => {
const { t } = useI18n();
const [draft, setDraft] = useState(setting);
const [chatIdInput, setChatIdInput] = useState(setting.zaloBotChatId ?? "");
useEffect(() => setDraft(setting), [setting]);
useEffect(() => {
setDraft(setting);
setChatIdInput(setting.zaloBotChatId ?? "");
}, [setting]);
const toggleChannel = (channel: NotificationChannel) => {
setDraft((current) => {
......@@ -26,7 +30,18 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ sett
});
};
const toggles: Array<{ key: keyof Omit<NotificationSetting, "channels">; title: string; hint: string }> = [
const handleSave = () => {
onSave({
...draft,
// Chỉ gửi zaloBotChatId khi kênh ZALO được bật
zaloBotChatId: draft.channels.includes("ZALO") ? (chatIdInput.trim() || null) : draft.zaloBotChatId,
});
};
const isZaloEnabled = draft.channels.includes("ZALO");
const isZaloLinked = !!draft.zaloBotChatId;
const toggles: Array<{ key: keyof Omit<NotificationSetting, "channels" | "zaloBotChatId">; title: string; hint: string }> = [
{ key: "budgetAlertsEnabled", title: t("notification.settings.budget"), hint: t("notification.settings.budgetHint") },
{ key: "savingGoalAlertsEnabled", title: t("notification.settings.savingGoal"), hint: t("notification.settings.savingGoalHint") },
{ key: "reminderAlertsEnabled", title: t("notification.settings.reminder"), hint: t("notification.settings.reminderHint") },
......@@ -60,7 +75,52 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ sett
<p className="clay-caption mt-3">{t("notification.settings.channelRequired")}</p>
</Card>
<Button fullWidth disabled={isSaving} onClick={() => onSave(draft)}>{isSaving ? t("common.saving") : t("notification.settings.save")}</Button>
{/* Section liên kết Zalo Bot — hiển thị khi kênh ZALO được bật */}
{isZaloEnabled && (
<Card className="p-5">
<div className="flex items-start gap-3">
<span className="text-2xl">💬</span>
<div className="flex-1">
<h2 className="clay-title-h3">{t("notification.zaloBotLink.title")}</h2>
{isZaloLinked ? (
<p className="clay-caption mt-1 font-semibold text-green-600">{t("notification.zaloBotLink.linked")}</p>
) : (
<p className="clay-caption mt-1">{t("notification.zaloBotLink.hint")}</p>
)}
</div>
</div>
<div className="mt-4 flex flex-col gap-3">
<input
id="zalo-bot-chat-id"
type="text"
value={chatIdInput}
onChange={(e) => setChatIdInput(e.target.value)}
placeholder={t("notification.zaloBotLink.placeholder")}
disabled={isSaving}
maxLength={100}
className="w-full rounded-clay-sm border border-clay-border bg-clay-bg px-3 py-2 font-nunito text-sm text-clay-text shadow-clay-pressed placeholder:text-clay-text/40 focus:border-clay-primary focus:outline-none disabled:opacity-50"
/>
<div className="flex gap-2">
{isZaloLinked && (
<button
type="button"
disabled={isSaving}
onClick={() => {
setChatIdInput("");
setDraft((prev) => ({ ...prev, zaloBotChatId: null }));
}}
className="rounded-clay-sm border border-red-300 bg-red-50 px-3 py-1.5 font-nunito text-xs font-bold text-red-600 shadow-clay-pressed transition-all duration-200 hover:bg-red-100 disabled:opacity-50"
>
{t("notification.zaloBotLink.unlink")}
</button>
)}
</div>
</div>
</Card>
)}
<Button fullWidth disabled={isSaving} onClick={handleSave}>{isSaving ? t("common.saving") : t("notification.settings.save")}</Button>
</div>
);
};
......
......@@ -30,7 +30,7 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on
<span className="text-clay-text-muted">{t("recurringTransactions.cycle")}:</span>
<span className="font-bold text-clay-text">{t(`recurringTransactions.frequency.${schedule.frequency}`)} × {schedule.repeatInterval}</span>
</div>
{schedule.remindDaysBefore !== null && schedule.remindDaysBefore !== undefined && (
{schedule.remindDaysBefore !== null && schedule.remindDaysBefore !== undefined ? (
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1">🔔 {t("recurringTransactions.reminder")}:</span>
<span className="font-bold text-clay-primary">
......@@ -39,6 +39,13 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on
: t("recurringTransactions.reminderBadge", { days: schedule.remindDaysBefore })}
</span>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1">🔕 {t("recurringTransactions.reminder")}:</span>
<span className="font-medium text-clay-text-muted">
{t("recurringTransactions.reminderOff")}
</span>
</div>
)}
</div>
)}
......
......@@ -19,6 +19,8 @@ import {
RecurringTransactionSchedule,
} from "@/types/recurring-transaction";
import { formatAmountInput, parseAmountInput, normalizeAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function schema(t: TranslationFunction) {
......@@ -67,38 +69,14 @@ function flatten(nodes: CategoryTreeNode[], depth = 0): FlatCategory[] {
]);
}
function numberSeparators(locale: string) {
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
return {
group: parts.find((part) => part.type === "group")?.value || ",",
decimal: parts.find((part) => part.type === "decimal")?.value || ".",
};
}
function formatAmount(value: string, locale: string) {
if (!value) return "";
const [integer, fraction] = value.split(".");
const { group, decimal } = numberSeparators(locale);
const grouped = integer.replace(/\B(?=(\d{3})+(?!\d))/g, group);
return fraction === undefined ? grouped : `${grouped}${decimal}${fraction}`;
}
function parseAmount(value: string, locale: string) {
const { group, decimal } = numberSeparators(locale);
const normalized = value.trim().split(group).join("").replace(decimal, ".");
const [integer = "", fraction] = normalized.split(".", 2);
const integerDigits = integer.replace(/\D/g, "").slice(0, 16).replace(/^0+(?=\d)/, "") || "0";
const fractionDigits = fraction?.replace(/\D/g, "").slice(0, 2);
return fraction === undefined ? integerDigits : `${integerDigits}.${fractionDigits}`;
}
function defaults(schedule?: RecurringTransactionSchedule): FormValues {
function defaults(schedule?: RecurringTransactionSchedule, defaultCurrency = "VND"): FormValues {
const hasReminder = schedule
? (schedule.remindDaysBefore !== undefined && schedule.remindDaysBefore !== null)
: true;
const currency = schedule?.wallet?.currency || defaultCurrency;
return {
type: schedule?.type || "EXPENSE",
amount: schedule?.amount || "",
amount: normalizeAmountInput(schedule?.amount, currency),
walletId: schedule?.walletId || "",
categoryId: schedule?.categoryId || "",
description: schedule?.description || "",
......@@ -121,9 +99,10 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
onSubmit,
}) => {
const { t, intlLocale } = useI18n();
const formSchema = useMemo(() => schema(t), [t]);
const defaultValues = useMemo(() => defaults(schedule), [schedule]);
const walletsQuery = useWallets({ includeArchived: false, sortBy: "name", order: "asc", page: 1, limit: 100 });
const defaultWallet = (walletsQuery.data?.data || []).find((wallet) => wallet.isDefault) || walletsQuery.data?.data?.[0];
const formSchema = useMemo(() => schema(t), [t]);
const defaultValues = useMemo(() => defaults(schedule, defaultWallet?.currency), [schedule, defaultWallet?.currency]);
const categoriesQuery = useCategoryTree({ source: "ALL", includeArchived: false });
const { control, register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(formSchema),
......@@ -135,11 +114,36 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
const endDate = watch("endDate");
const frequency = watch("frequency");
const enableReminder = watch("enableReminder");
const selectedWalletId = watch("walletId");
const selectedWallet = (walletsQuery.data?.data || []).find((w) => w.id === selectedWalletId);
const selectedCurrency = selectedWallet?.currency || schedule?.wallet?.currency || defaultWallet?.currency || "VND";
const isVnd = selectedCurrency.trim().toUpperCase() === "VND";
const formId = schedule ? `edit-recurring-${schedule.id}` : "create-recurring";
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
useEffect(() => { if (isOpen) reset(defaultValues); }, [defaultValues, isOpen, reset]);
useEffect(() => {
if (isVnd) {
const current = watch("amount");
if (current && current.includes(".")) {
const normalized = normalizeAmountInput(current, "VND");
if (normalized !== current) {
setValue("amount", normalized, { shouldValidate: true, shouldDirty: true });
}
}
}
}, [isVnd, setValue, watch]);
useEffect(() => {
if (frequency === "DAILY") {
const currentDays = watch("remindDaysBefore");
if (currentDays !== null && currentDays !== undefined && currentDays > 0) {
setValue("remindDaysBefore", 0, { shouldValidate: true });
}
}
}, [frequency, setValue, watch]);
useEffect(() => {
if (schedule || watch("walletId")) return;
const wallets = walletsQuery.data?.data || [];
......@@ -243,13 +247,13 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
<Input
{...field}
label={t("recurringTransactions.form.amount")}
inputMode="decimal"
inputMode={isVnd ? "numeric" : "decimal"}
placeholder={t("transaction.amountPlaceholder")}
error={errors.amount?.message}
disabled={isSubmitting}
className="tabular-nums font-semibold"
value={formatAmount(field.value, intlLocale)}
onChange={(event) => field.onChange(parseAmount(event.target.value, intlLocale))}
value={formatAmountInput(field.value, intlLocale, selectedCurrency)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale, selectedCurrency))}
/>
)}
/>
......@@ -316,8 +320,10 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
defaultCurrency={selectedCurrency}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
const finalAmount = isVnd ? normalizeAmountInput(calculatedAmount, "VND") : calculatedAmount;
setValue("amount", finalAmount, {
shouldValidate: true,
shouldDirty: true,
});
......
......@@ -19,6 +19,7 @@ import { useWallets } from "@/hooks/use-wallets";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format";
import { normalizeAmountInput } from "@/lib/money-input";
import {
RecurringTransactionInput,
RecurringTransactionSchedule,
......@@ -87,12 +88,15 @@ const RecurringTransactionsPage: React.FC = () => {
openSnackbar({ type: "error", text: t("recurringTransactions.subscription.walletRequired") });
return;
}
const targetWallet = (walletsQuery.data?.data || []).find((w) => w.id === candidateWalletId);
const targetCurrency = targetWallet?.currency || recurringCandidate.currency || "VND";
const amountToSend = normalizeAmountInput(recurringCandidate.latestAmount, targetCurrency);
try {
await recurringConvertMutation.mutateAsync({
merchantName: recurringCandidate.merchantName,
walletId: candidateWalletId,
categoryId: recurringCandidate.categoryId,
amount: recurringCandidate.latestAmount,
amount: amountToSend,
frequency: recurringCandidate.frequency,
nextExpectedAt: recurringCandidate.nextExpectedAt,
});
......@@ -169,7 +173,7 @@ const RecurringTransactionsPage: React.FC = () => {
</div>
<div className="text-right">
<p className="font-baloo font-bold text-sm text-clay-primary">
{formatCurrency(amount, candidate.currency)}
{formatCurrency(amount, candidate.currency || "VND")}
</p>
<span className="text-[10px] text-clay-text-muted block font-medium">
{t("recurringTransactions.subscription.discoveredConfidence")}: {(candidate.confidenceScore * 100).toFixed(0)}%
......@@ -247,17 +251,21 @@ const RecurringTransactionsPage: React.FC = () => {
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-baloo text-base font-bold text-clay-text">{schedule.description || getCategoryDisplayName(schedule.category, t)}</h3>
<Badge type={schedule.isActive ? "income" : "info"}>{schedule.isActive ? t("recurringTransactions.active") : t("recurringTransactions.paused")}</Badge>
{schedule.remindDaysBefore !== null && schedule.remindDaysBefore !== undefined && (
{schedule.remindDaysBefore !== null && schedule.remindDaysBefore !== undefined ? (
<Badge type="warning" className="text-[10px] px-2 py-0.5 font-semibold">
🔔 {schedule.remindDaysBefore === 0
? t("recurringTransactions.reminderBadgeSameDay")
: t("recurringTransactions.reminderBadge", { days: schedule.remindDaysBefore })}
</Badge>
) : (
<Badge type="info" className="text-[10px] px-2 py-0.5 font-medium opacity-75">
🔕 {t("recurringTransactions.reminderOff")}
</Badge>
)}
</div>
<p className="mt-1 text-xs text-clay-text-muted">{schedule.wallet.name}{getCategoryDisplayName(schedule.category, t)}</p>
</div>
<p className={`font-baloo text-base font-bold ${schedule.type === "INCOME" ? "text-clay-income" : "text-clay-expense"}`}>{formatCurrency(Number(schedule.amount), schedule.wallet.currency)}</p>
<p className={`font-baloo text-base font-bold ${schedule.type === "INCOME" ? "text-clay-income" : "text-clay-expense"}`}>{formatCurrency(Number(schedule.amount), schedule.wallet?.currency || "VND")}</p>
</div>
<div className="grid grid-cols-2 gap-2 rounded-clay-sm bg-clay-bg p-3 text-xs shadow-clay-pressed">
<div><span className="block text-clay-text-muted">{t("recurringTransactions.cycle")}</span><b className="text-clay-text">{t(`recurringTransactions.frequency.${schedule.frequency}`)} × {schedule.repeatInterval}</b></div>
......
......@@ -15,7 +15,7 @@ import { getCategoryDisplayName } from "@/lib/category-format";
import { CategoryTreeNode, TransactionType } from "@/types/category";
import { CreateTransactionInput, Transaction, UpdateTransactionInput } from "@/types/transaction";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
import { formatAmountInput, parseAmountInput, normalizeAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
......@@ -114,8 +114,9 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
const defaultValues = useMemo<TransactionFormValues>(() => {
if (transaction) {
const walletCurrency = walletsQuery.data?.data?.find((w) => w.id === transaction.walletId)?.currency || "VND";
return {
amount: transaction.amount,
amount: normalizeAmountInput(transaction.amount, walletCurrency),
type: transaction.type,
walletId: transaction.walletId,
categoryId: transaction.categoryId,
......@@ -124,8 +125,9 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
location: transaction.location || "",
};
}
const initialWalletCurrency = walletsQuery.data?.data?.find((w) => w.id === initialValues?.walletId)?.currency || "VND";
return {
amount: initialValues?.amount || "",
amount: normalizeAmountInput(initialValues?.amount, initialWalletCurrency),
type: initialValues?.type || "EXPENSE",
walletId: initialValues?.walletId || "",
categoryId: initialValues?.categoryId || "",
......@@ -133,7 +135,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
description: initialValues?.description || "",
location: initialValues?.location || "",
};
}, [transaction, initialValues]);
}, [transaction, initialValues, walletsQuery.data?.data]);
const {
control,
......@@ -148,6 +150,23 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
defaultValues,
});
const selectedWalletId = watch("walletId");
const selectedWallet = walletsQuery.data?.data?.find((w) => w.id === selectedWalletId);
const selectedCurrency = selectedWallet?.currency || "VND";
const isVnd = selectedCurrency.trim().toUpperCase() === "VND";
useEffect(() => {
if (isVnd) {
const current = watch("amount");
if (current && current.includes(".")) {
const normalized = normalizeAmountInput(current, "VND");
if (normalized !== current) {
setValue("amount", normalized, { shouldValidate: true, shouldDirty: true });
}
}
}
}, [isVnd, setValue, watch]);
useEffect(() => {
if (isOpen) {
reset(defaultValues);
......@@ -169,7 +188,6 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
}, [selectedFile]);
const selectedType = watch("type") as TransactionType;
const selectedWalletId = watch("walletId");
const selectedCategoryId = watch("categoryId");
const selectedDate = watch("date");
......@@ -380,13 +398,13 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
<Input
{...field}
label={t("transaction.amount")}
inputMode="decimal"
inputMode={isVnd ? "numeric" : "decimal"}
placeholder={t("transaction.amountPlaceholder")}
error={errors.amount?.message}
disabled={isSubmitting}
className="tabular-nums font-semibold"
value={formatAmountInput(field.value, intlLocale)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
value={formatAmountInput(field.value, intlLocale, selectedCurrency)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale, selectedCurrency))}
/>
)}
/>
......
......@@ -15,7 +15,7 @@ import { formatWalletBalance } from "@/lib/wallet-format";
import { CreateTransferInput } from "@/types/transfer";
import { Wallet } from "@/types/wallet";
import { businessWallTimeToIso, instantToBusinessDateTimeInput } from "@/lib/business-time";
import { formatAmountInput, parseAmountInput } from "@/lib/money-input";
import { formatAmountInput, parseAmountInput, normalizeAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
......@@ -139,6 +139,18 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
const sourceWallet = wallets.find((wallet) => wallet.id === sourceWalletId);
const destinationWallet = wallets.find((wallet) => wallet.id === destinationWalletId);
useEffect(() => {
if (sourceWallet?.currency?.toUpperCase() === "VND") {
const current = watch("amount");
if (current && current.includes(".")) {
const normalized = normalizeAmountInput(current, "VND");
if (normalized !== current) {
setValue("amount", normalized, { shouldValidate: true, shouldDirty: true });
}
}
}
}, [sourceWallet?.currency, setValue, watch]);
useEffect(() => {
if (isOpen) reset(defaultValues);
}, [defaultValues, isOpen, reset]);
......@@ -259,13 +271,13 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
<Input
{...field}
label={t("transfer.amount")}
inputMode="decimal"
inputMode={sourceWallet?.currency?.toUpperCase() === "VND" ? "numeric" : "decimal"}
placeholder={t("transfer.amountPlaceholder")}
error={errors.amount?.message}
disabled={walletsQuery.isLoading || insufficientWallets}
className="font-semibold tabular-nums"
value={formatAmountInput(field.value, intlLocale)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
value={formatAmountInput(field.value, intlLocale, sourceWallet?.currency)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale, sourceWallet?.currency))}
endAdornment={sourceWallet ? (
<span className="text-xs font-bold text-clay-text-muted">{sourceWallet.currency}</span>
) : undefined}
......
......@@ -51,6 +51,8 @@ export interface NotificationQuery {
export interface NotificationSetting {
channels: NotificationChannel[];
/** chat_id của Zalo Bot. null nếu chưa liên kết. */
zaloBotChatId: string | null;
budgetAlertsEnabled: boolean;
savingGoalAlertsEnabled: boolean;
reminderAlertsEnabled: boolean;
......
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