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 ...@@ -76,14 +76,21 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children
new Intl.NumberFormat(intlLocale, options).format(value), [intlLocale]); new Intl.NumberFormat(intlLocale, options).format(value), [intlLocale]);
const formatCurrency = useCallback((value: number, currency: string) => { 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 { try {
return new Intl.NumberFormat(intlLocale, { return new Intl.NumberFormat(intlLocale, {
style: "currency", style: "currency",
currency, currency: normalizedCurrency,
maximumFractionDigits: currency === "VND" ? 0 : 2, minimumFractionDigits: isVnd ? 0 : undefined,
}).format(value); maximumFractionDigits: isVnd ? 0 : 2,
}).format(finalValue);
} catch { } 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]); }, [intlLocale]);
......
...@@ -1266,7 +1266,15 @@ ...@@ -1266,7 +1266,15 @@
"save": "Save preferences", "saveSuccess": "Notification preferences saved.", "saveFailed": "Could not save notification preferences." "save": "Save preferences", "saveSuccess": "Notification preferences saved.", "saveFailed": "Could not save notification preferences."
}, },
"channel": { "IN_APP": "In app", "EMAIL": "Email", "ZALO": "Zalo", "PUSH": "Push" }, "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": { "reminder": {
"createShort": "New reminder", "createShort": "New reminder",
...@@ -1662,6 +1670,7 @@ ...@@ -1662,6 +1670,7 @@
"attempts": "{{count}} attempt(s)", "attempts": "{{count}} attempt(s)",
"reminderBadge": "Remind {{days}}d before", "reminderBadge": "Remind {{days}}d before",
"reminderBadgeSameDay": "Remind same day", "reminderBadgeSameDay": "Remind same day",
"reminderOff": "No reminder",
"reminder": "Reminder", "reminder": "Reminder",
"frequency": { "frequency": {
"DAILY": "Daily", "DAILY": "Daily",
......
...@@ -1328,8 +1328,16 @@ ...@@ -1328,8 +1328,16 @@
"channelHint": { "channelHint": {
"IN_APP": "Hộp thư và badge", "IN_APP": "Hộp thư và badge",
"EMAIL": "Gửi tới email tài khoản", "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" "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": { "reminder": {
...@@ -1763,6 +1771,7 @@ ...@@ -1763,6 +1771,7 @@
"attempts": "{{count}} lần thử", "attempts": "{{count}} lần thử",
"reminderBadge": "Nhắc trước {{days}} ngày", "reminderBadge": "Nhắc trước {{days}} ngày",
"reminderBadgeSameDay": "Nhắc cùng ngày", "reminderBadgeSameDay": "Nhắc cùng ngày",
"reminderOff": "Tắt thông báo",
"reminder": "Nhắc nhở", "reminder": "Nhắc nhở",
"frequency": { "frequency": {
"DAILY": "Hàng ngày", "DAILY": "Hàng ngày",
......
...@@ -10,21 +10,46 @@ export function getNumberSeparators(locale: string): { group: string; decimal: s ...@@ -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; if (!value) return value;
const isVnd = currency?.trim()?.toUpperCase() === "VND";
const [integerPart, decimalPart] = value.split("."); const [integerPart, decimalPart] = value.split(".");
const { group, decimal } = getNumberSeparators(locale); const { group, decimal } = getNumberSeparators(locale);
const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group); const groupedInteger = (integerPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
if (isVnd) {
return groupedInteger;
}
return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`; return `${groupedInteger}${decimalPart !== undefined ? `${decimal}${decimalPart}` : ""}`;
} }
export const formatAmountInput = formatMoneyInput; 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(); const trimmedValue = value.trim();
if (!trimmedValue) return ""; if (!trimmedValue) return "";
const isVnd = currency?.trim()?.toUpperCase() === "VND";
const { group, decimal } = getNumberSeparators(locale); 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 integerDisplay = trimmedValue;
let decimalDisplay: string | undefined; let decimalDisplay: string | undefined;
......
...@@ -16,15 +16,21 @@ export function formatWalletBalance(balance: string, currency: string, locale: s ...@@ -16,15 +16,21 @@ export function formatWalletBalance(balance: string, currency: string, locale: s
return `${balance} ${currency}`; 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 { try {
return new Intl.NumberFormat(locale, { return new Intl.NumberFormat(locale, {
style: "currency", style: "currency",
currency, currency: normalizedCurrency,
minimumFractionDigits: isVnd ? 0 : undefined,
maximumFractionDigits: isVnd ? 0 : 2, maximumFractionDigits: isVnd ? 0 : 2,
}).format(numericBalance); }).format(finalBalance);
} catch { } 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"; ...@@ -16,7 +16,7 @@ import { getCategoryDisplayName } from "@/lib/category-format";
import { Budget, BudgetPeriod, BudgetType, CreateBudgetInput } from "@/types/budget"; import { Budget, BudgetPeriod, BudgetType, CreateBudgetInput } from "@/types/budget";
import { CategoryTreeNode } from "@/types/category"; import { CategoryTreeNode } from "@/types/category";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time"; 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})?$/; const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
...@@ -84,19 +84,22 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -84,19 +84,22 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
const { intlLocale, t } = useI18n(); const { intlLocale, t } = useI18n();
const categoriesQuery = useCategoryTree({ type: "EXPENSE", source: "ALL", includeArchived: false }); const categoriesQuery = useCategoryTree({ type: "EXPENSE", source: "ALL", includeArchived: false });
const schema = useMemo(() => createSchema(t), [t]); const schema = useMemo(() => createSchema(t), [t]);
const defaultValues = useMemo<BudgetFormValues>(() => ({ const defaultValues = useMemo<BudgetFormValues>(() => {
name: budget?.name || "", const currency = budget?.currency || "VND";
amount: budget?.amount || "", return {
currency: budget?.currency || "VND", name: budget?.name || "",
type: budget?.type || "CATEGORY", amount: normalizeAmountInput(budget?.amount, currency),
period: budget?.period || "MONTHLY", currency,
categoryId: budget?.categoryId || "", type: budget?.type || "CATEGORY",
startDate: localDate(budget?.startDate), period: budget?.period || "MONTHLY",
endDate: budget?.period === "CUSTOM" ? localDate(budget.endDate) : "", categoryId: budget?.categoryId || "",
alertThreshold: Number(budget?.alertThreshold || 80), startDate: localDate(budget?.startDate),
isRecurring: budget ? Boolean(budget.isRecurring) : true, endDate: budget?.period === "CUSTOM" ? localDate(budget.endDate) : "",
rolloverMode: budget?.rolloverMode || "RESET", alertThreshold: Number(budget?.alertThreshold || 80),
}), [budget]); isRecurring: budget ? Boolean(budget.isRecurring) : true,
rolloverMode: budget?.rolloverMode || "RESET",
};
}, [budget]);
const { const {
control, control,
...@@ -108,6 +111,21 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -108,6 +111,21 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
formState: { errors }, formState: { errors },
} = useForm<BudgetFormValues>({ resolver: zodResolver(schema), defaultValues }); } = 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(() => { useEffect(() => {
if (isOpen) reset(defaultValues); if (isOpen) reset(defaultValues);
}, [defaultValues, isOpen, reset]); }, [defaultValues, isOpen, reset]);
...@@ -196,11 +214,11 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -196,11 +214,11 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
render={({ field }) => ( render={({ field }) => (
<Input <Input
label={t("budget.form.amount")} label={t("budget.form.amount")}
inputMode="decimal" inputMode={isVnd ? "numeric" : "decimal"}
placeholder={t("budget.form.amountPlaceholder")} placeholder={t("budget.form.amountPlaceholder")}
value={formatAmountInput(field.value, intlLocale)} value={formatAmountInput(field.value, intlLocale, watchedCurrency)}
onBlur={field.onBlur} 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} error={errors.amount?.message}
/> />
)} )}
......
...@@ -15,8 +15,12 @@ const channelOptions: NotificationChannel[] = ["IN_APP", "EMAIL", "ZALO", "PUSH" ...@@ -15,8 +15,12 @@ const channelOptions: NotificationChannel[] = ["IN_APP", "EMAIL", "ZALO", "PUSH"
export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ setting, isSaving, onSave }) => { export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ setting, isSaving, onSave }) => {
const { t } = useI18n(); const { t } = useI18n();
const [draft, setDraft] = useState(setting); 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) => { const toggleChannel = (channel: NotificationChannel) => {
setDraft((current) => { setDraft((current) => {
...@@ -26,7 +30,18 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ sett ...@@ -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: "budgetAlertsEnabled", title: t("notification.settings.budget"), hint: t("notification.settings.budgetHint") },
{ key: "savingGoalAlertsEnabled", title: t("notification.settings.savingGoal"), hint: t("notification.settings.savingGoalHint") }, { key: "savingGoalAlertsEnabled", title: t("notification.settings.savingGoal"), hint: t("notification.settings.savingGoalHint") },
{ key: "reminderAlertsEnabled", title: t("notification.settings.reminder"), hint: t("notification.settings.reminderHint") }, { key: "reminderAlertsEnabled", title: t("notification.settings.reminder"), hint: t("notification.settings.reminderHint") },
...@@ -60,7 +75,52 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ sett ...@@ -60,7 +75,52 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ sett
<p className="clay-caption mt-3">{t("notification.settings.channelRequired")}</p> <p className="clay-caption mt-3">{t("notification.settings.channelRequired")}</p>
</Card> </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> </div>
); );
}; };
......
...@@ -30,7 +30,7 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on ...@@ -30,7 +30,7 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on
<span className="text-clay-text-muted">{t("recurringTransactions.cycle")}:</span> <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> <span className="font-bold text-clay-text">{t(`recurringTransactions.frequency.${schedule.frequency}`)} × {schedule.repeatInterval}</span>
</div> </div>
{schedule.remindDaysBefore !== null && schedule.remindDaysBefore !== undefined && ( {schedule.remindDaysBefore !== null && schedule.remindDaysBefore !== undefined ? (
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-clay-text-muted flex items-center gap-1">🔔 {t("recurringTransactions.reminder")}:</span> <span className="text-clay-text-muted flex items-center gap-1">🔔 {t("recurringTransactions.reminder")}:</span>
<span className="font-bold text-clay-primary"> <span className="font-bold text-clay-primary">
...@@ -39,6 +39,13 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on ...@@ -39,6 +39,13 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on
: t("recurringTransactions.reminderBadge", { days: schedule.remindDaysBefore })} : t("recurringTransactions.reminderBadge", { days: schedule.remindDaysBefore })}
</span> </span>
</div> </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> </div>
)} )}
......
...@@ -19,6 +19,8 @@ import { ...@@ -19,6 +19,8 @@ import {
RecurringTransactionSchedule, RecurringTransactionSchedule,
} from "@/types/recurring-transaction"; } from "@/types/recurring-transaction";
import { formatAmountInput, parseAmountInput, normalizeAmountInput } from "@/lib/money-input";
const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/; const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
function schema(t: TranslationFunction) { function schema(t: TranslationFunction) {
...@@ -67,38 +69,14 @@ function flatten(nodes: CategoryTreeNode[], depth = 0): FlatCategory[] { ...@@ -67,38 +69,14 @@ function flatten(nodes: CategoryTreeNode[], depth = 0): FlatCategory[] {
]); ]);
} }
function numberSeparators(locale: string) { function defaults(schedule?: RecurringTransactionSchedule, defaultCurrency = "VND"): FormValues {
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 {
const hasReminder = schedule const hasReminder = schedule
? (schedule.remindDaysBefore !== undefined && schedule.remindDaysBefore !== null) ? (schedule.remindDaysBefore !== undefined && schedule.remindDaysBefore !== null)
: true; : true;
const currency = schedule?.wallet?.currency || defaultCurrency;
return { return {
type: schedule?.type || "EXPENSE", type: schedule?.type || "EXPENSE",
amount: schedule?.amount || "", amount: normalizeAmountInput(schedule?.amount, currency),
walletId: schedule?.walletId || "", walletId: schedule?.walletId || "",
categoryId: schedule?.categoryId || "", categoryId: schedule?.categoryId || "",
description: schedule?.description || "", description: schedule?.description || "",
...@@ -121,9 +99,10 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -121,9 +99,10 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
onSubmit, onSubmit,
}) => { }) => {
const { t, intlLocale } = useI18n(); 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 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 categoriesQuery = useCategoryTree({ source: "ALL", includeArchived: false });
const { control, register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<FormValues>({ const { control, register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
...@@ -135,11 +114,36 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -135,11 +114,36 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
const endDate = watch("endDate"); const endDate = watch("endDate");
const frequency = watch("frequency"); const frequency = watch("frequency");
const enableReminder = watch("enableReminder"); 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 formId = schedule ? `edit-recurring-${schedule.id}` : "create-recurring";
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false); const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
useEffect(() => { if (isOpen) reset(defaultValues); }, [defaultValues, isOpen, reset]); 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(() => { useEffect(() => {
if (schedule || watch("walletId")) return; if (schedule || watch("walletId")) return;
const wallets = walletsQuery.data?.data || []; const wallets = walletsQuery.data?.data || [];
...@@ -243,13 +247,13 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -243,13 +247,13 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
<Input <Input
{...field} {...field}
label={t("recurringTransactions.form.amount")} label={t("recurringTransactions.form.amount")}
inputMode="decimal" inputMode={isVnd ? "numeric" : "decimal"}
placeholder={t("transaction.amountPlaceholder")} placeholder={t("transaction.amountPlaceholder")}
error={errors.amount?.message} error={errors.amount?.message}
disabled={isSubmitting} disabled={isSubmitting}
className="tabular-nums font-semibold" className="tabular-nums font-semibold"
value={formatAmount(field.value, intlLocale)} value={formatAmountInput(field.value, intlLocale, selectedCurrency)}
onChange={(event) => field.onChange(parseAmount(event.target.value, intlLocale))} onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale, selectedCurrency))}
/> />
)} )}
/> />
...@@ -316,8 +320,10 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -316,8 +320,10 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
isOpen={isCalculatorOpen} isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)} onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")} initialAmount={watch("amount")}
defaultCurrency={selectedCurrency}
onApply={(calculatedAmount) => { onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, { const finalAmount = isVnd ? normalizeAmountInput(calculatedAmount, "VND") : calculatedAmount;
setValue("amount", finalAmount, {
shouldValidate: true, shouldValidate: true,
shouldDirty: true, shouldDirty: true,
}); });
......
...@@ -19,6 +19,7 @@ import { useWallets } from "@/hooks/use-wallets"; ...@@ -19,6 +19,7 @@ import { useWallets } from "@/hooks/use-wallets";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time"; import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format"; import { getCategoryDisplayName } from "@/lib/category-format";
import { normalizeAmountInput } from "@/lib/money-input";
import { import {
RecurringTransactionInput, RecurringTransactionInput,
RecurringTransactionSchedule, RecurringTransactionSchedule,
...@@ -87,12 +88,15 @@ const RecurringTransactionsPage: React.FC = () => { ...@@ -87,12 +88,15 @@ const RecurringTransactionsPage: React.FC = () => {
openSnackbar({ type: "error", text: t("recurringTransactions.subscription.walletRequired") }); openSnackbar({ type: "error", text: t("recurringTransactions.subscription.walletRequired") });
return; 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 { try {
await recurringConvertMutation.mutateAsync({ await recurringConvertMutation.mutateAsync({
merchantName: recurringCandidate.merchantName, merchantName: recurringCandidate.merchantName,
walletId: candidateWalletId, walletId: candidateWalletId,
categoryId: recurringCandidate.categoryId, categoryId: recurringCandidate.categoryId,
amount: recurringCandidate.latestAmount, amount: amountToSend,
frequency: recurringCandidate.frequency, frequency: recurringCandidate.frequency,
nextExpectedAt: recurringCandidate.nextExpectedAt, nextExpectedAt: recurringCandidate.nextExpectedAt,
}); });
...@@ -169,7 +173,7 @@ const RecurringTransactionsPage: React.FC = () => { ...@@ -169,7 +173,7 @@ const RecurringTransactionsPage: React.FC = () => {
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="font-baloo font-bold text-sm text-clay-primary"> <p className="font-baloo font-bold text-sm text-clay-primary">
{formatCurrency(amount, candidate.currency)} {formatCurrency(amount, candidate.currency || "VND")}
</p> </p>
<span className="text-[10px] text-clay-text-muted block font-medium"> <span className="text-[10px] text-clay-text-muted block font-medium">
{t("recurringTransactions.subscription.discoveredConfidence")}: {(candidate.confidenceScore * 100).toFixed(0)}% {t("recurringTransactions.subscription.discoveredConfidence")}: {(candidate.confidenceScore * 100).toFixed(0)}%
...@@ -247,17 +251,21 @@ const RecurringTransactionsPage: React.FC = () => { ...@@ -247,17 +251,21 @@ const RecurringTransactionsPage: React.FC = () => {
<div className="flex flex-wrap items-center gap-2"> <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> <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> <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"> <Badge type="warning" className="text-[10px] px-2 py-0.5 font-semibold">
🔔 {schedule.remindDaysBefore === 0 🔔 {schedule.remindDaysBefore === 0
? t("recurringTransactions.reminderBadgeSameDay") ? t("recurringTransactions.reminderBadgeSameDay")
: t("recurringTransactions.reminderBadge", { days: schedule.remindDaysBefore })} : t("recurringTransactions.reminderBadge", { days: schedule.remindDaysBefore })}
</Badge> </Badge>
) : (
<Badge type="info" className="text-[10px] px-2 py-0.5 font-medium opacity-75">
🔕 {t("recurringTransactions.reminderOff")}
</Badge>
)} )}
</div> </div>
<p className="mt-1 text-xs text-clay-text-muted">{schedule.wallet.name}{getCategoryDisplayName(schedule.category, t)}</p> <p className="mt-1 text-xs text-clay-text-muted">{schedule.wallet.name}{getCategoryDisplayName(schedule.category, t)}</p>
</div> </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>
<div className="grid grid-cols-2 gap-2 rounded-clay-sm bg-clay-bg p-3 text-xs shadow-clay-pressed"> <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> <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"; ...@@ -15,7 +15,7 @@ import { getCategoryDisplayName } from "@/lib/category-format";
import { CategoryTreeNode, TransactionType } from "@/types/category"; import { CategoryTreeNode, TransactionType } from "@/types/category";
import { CreateTransactionInput, Transaction, UpdateTransactionInput } from "@/types/transaction"; import { CreateTransactionInput, Transaction, UpdateTransactionInput } from "@/types/transaction";
import { instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time"; 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})?$/; const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
...@@ -114,8 +114,9 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -114,8 +114,9 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
const defaultValues = useMemo<TransactionFormValues>(() => { const defaultValues = useMemo<TransactionFormValues>(() => {
if (transaction) { if (transaction) {
const walletCurrency = walletsQuery.data?.data?.find((w) => w.id === transaction.walletId)?.currency || "VND";
return { return {
amount: transaction.amount, amount: normalizeAmountInput(transaction.amount, walletCurrency),
type: transaction.type, type: transaction.type,
walletId: transaction.walletId, walletId: transaction.walletId,
categoryId: transaction.categoryId, categoryId: transaction.categoryId,
...@@ -124,8 +125,9 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -124,8 +125,9 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
location: transaction.location || "", location: transaction.location || "",
}; };
} }
const initialWalletCurrency = walletsQuery.data?.data?.find((w) => w.id === initialValues?.walletId)?.currency || "VND";
return { return {
amount: initialValues?.amount || "", amount: normalizeAmountInput(initialValues?.amount, initialWalletCurrency),
type: initialValues?.type || "EXPENSE", type: initialValues?.type || "EXPENSE",
walletId: initialValues?.walletId || "", walletId: initialValues?.walletId || "",
categoryId: initialValues?.categoryId || "", categoryId: initialValues?.categoryId || "",
...@@ -133,7 +135,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -133,7 +135,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
description: initialValues?.description || "", description: initialValues?.description || "",
location: initialValues?.location || "", location: initialValues?.location || "",
}; };
}, [transaction, initialValues]); }, [transaction, initialValues, walletsQuery.data?.data]);
const { const {
control, control,
...@@ -148,6 +150,23 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -148,6 +150,23 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
defaultValues, 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(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
reset(defaultValues); reset(defaultValues);
...@@ -169,7 +188,6 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -169,7 +188,6 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
}, [selectedFile]); }, [selectedFile]);
const selectedType = watch("type") as TransactionType; const selectedType = watch("type") as TransactionType;
const selectedWalletId = watch("walletId");
const selectedCategoryId = watch("categoryId"); const selectedCategoryId = watch("categoryId");
const selectedDate = watch("date"); const selectedDate = watch("date");
...@@ -380,13 +398,13 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -380,13 +398,13 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
<Input <Input
{...field} {...field}
label={t("transaction.amount")} label={t("transaction.amount")}
inputMode="decimal" inputMode={isVnd ? "numeric" : "decimal"}
placeholder={t("transaction.amountPlaceholder")} placeholder={t("transaction.amountPlaceholder")}
error={errors.amount?.message} error={errors.amount?.message}
disabled={isSubmitting} disabled={isSubmitting}
className="tabular-nums font-semibold" className="tabular-nums font-semibold"
value={formatAmountInput(field.value, intlLocale)} value={formatAmountInput(field.value, intlLocale, selectedCurrency)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))} onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale, selectedCurrency))}
/> />
)} )}
/> />
......
...@@ -15,7 +15,7 @@ import { formatWalletBalance } from "@/lib/wallet-format"; ...@@ -15,7 +15,7 @@ import { formatWalletBalance } from "@/lib/wallet-format";
import { CreateTransferInput } from "@/types/transfer"; import { CreateTransferInput } from "@/types/transfer";
import { Wallet } from "@/types/wallet"; import { Wallet } from "@/types/wallet";
import { businessWallTimeToIso, instantToBusinessDateTimeInput } from "@/lib/business-time"; 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})?$/; const amountPattern = /^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/;
...@@ -139,6 +139,18 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -139,6 +139,18 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
const sourceWallet = wallets.find((wallet) => wallet.id === sourceWalletId); const sourceWallet = wallets.find((wallet) => wallet.id === sourceWalletId);
const destinationWallet = wallets.find((wallet) => wallet.id === destinationWalletId); 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(() => { useEffect(() => {
if (isOpen) reset(defaultValues); if (isOpen) reset(defaultValues);
}, [defaultValues, isOpen, reset]); }, [defaultValues, isOpen, reset]);
...@@ -259,13 +271,13 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -259,13 +271,13 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
<Input <Input
{...field} {...field}
label={t("transfer.amount")} label={t("transfer.amount")}
inputMode="decimal" inputMode={sourceWallet?.currency?.toUpperCase() === "VND" ? "numeric" : "decimal"}
placeholder={t("transfer.amountPlaceholder")} placeholder={t("transfer.amountPlaceholder")}
error={errors.amount?.message} error={errors.amount?.message}
disabled={walletsQuery.isLoading || insufficientWallets} disabled={walletsQuery.isLoading || insufficientWallets}
className="font-semibold tabular-nums" className="font-semibold tabular-nums"
value={formatAmountInput(field.value, intlLocale)} value={formatAmountInput(field.value, intlLocale, sourceWallet?.currency)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))} onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale, sourceWallet?.currency))}
endAdornment={sourceWallet ? ( endAdornment={sourceWallet ? (
<span className="text-xs font-bold text-clay-text-muted">{sourceWallet.currency}</span> <span className="text-xs font-bold text-clay-text-muted">{sourceWallet.currency}</span>
) : undefined} ) : undefined}
......
...@@ -51,6 +51,8 @@ export interface NotificationQuery { ...@@ -51,6 +51,8 @@ export interface NotificationQuery {
export interface NotificationSetting { export interface NotificationSetting {
channels: NotificationChannel[]; channels: NotificationChannel[];
/** chat_id của Zalo Bot. null nếu chưa liên kết. */
zaloBotChatId: string | null;
budgetAlertsEnabled: boolean; budgetAlertsEnabled: boolean;
savingGoalAlertsEnabled: boolean; savingGoalAlertsEnabled: boolean;
reminderAlertsEnabled: 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