Commit 802420fa authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/universal-amount-calculator' into 'develop'

feat(fe): integrate amount calculator modal across all forms and resolve type issues

See merge request !37
parents c99fd307 130901b6
This diff is collapsed.
......@@ -1001,7 +1001,7 @@
},
"filters": {
"title": "Report scope",
"hint": "Filter by time, wallet, or currency",
"hint": "Filter by time or wallet",
"reset": "Reset",
"dateFrom": "From date",
"dateTo": "Through date",
......
......@@ -1035,7 +1035,7 @@
},
"filters": {
"title": "Phạm vi báo cáo",
"hint": "Lọc theo thời gian, ví hoặc loại tiền tệ",
"hint": "Lọc theo thời gian hoặc ví",
"reset": "Đặt lại",
"dateFrom": "Từ ngày",
"dateTo": "Đến hết ngày",
......
......@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Select } from "@/components/ui/Select";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { useEvaluateAnomaly } from "@/hooks/use-anomalies";
import { useCategoryTree } from "@/hooks/use-categories";
import { useWallets } from "@/hooks/use-wallets";
......@@ -49,6 +50,7 @@ export const AnomalyChecker: React.FC = () => {
includeArchived: false,
});
const [result, setResult] = useState<AnomalyEvaluationResult | null>(null);
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const schema = useMemo(() => createSchema(t), [t]);
const categories = flattenCategories(categoriesQuery.data?.data || []);
const wallets = walletsQuery.data?.data || [];
......@@ -56,6 +58,7 @@ export const AnomalyChecker: React.FC = () => {
control,
handleSubmit,
register,
setValue,
watch,
formState: { errors },
} = useForm<AnomalyFormValues>({
......@@ -143,22 +146,33 @@ export const AnomalyChecker: React.FC = () => {
</p>
)}
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("anomalies.amountToTest")}
inputMode="decimal"
placeholder={t("anomalies.amountPlaceholder")}
value={formatMoneyInput(field.value, intlLocale)}
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("anomalies.amountToTest")}
inputMode="decimal"
placeholder={t("anomalies.amountPlaceholder")}
value={formatMoneyInput(field.value, intlLocale)}
disabled={optionsLoading || evaluateMutation.isPending}
error={errors.amount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-anomaly-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={optionsLoading || evaluateMutation.isPending}
error={errors.amount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
</div>
</div>
<Button
type="submit"
......@@ -205,6 +219,18 @@ export const AnomalyChecker: React.FC = () => {
</div>
</div>
)}
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</Card>
);
};
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
......@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { Slider } from "@/components/ui/Slider";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { useCategoryTree } from "@/hooks/use-categories";
import { TranslationFunction, useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format";
......@@ -149,6 +150,7 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
const selectedPeriod = watch("period") as BudgetPeriod;
const selectedStartDate = watch("startDate");
const selectedEndDate = watch("endDate");
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
useEffect(() => {
if (selectedType === "OVERALL") {
......@@ -193,7 +195,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
const formId = budget ? `edit-budget-${budget.id}` : "create-budget";
return (
<Modal
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={budget ? t("budget.form.editTitle") : t("budget.form.createTitle")}
......@@ -215,38 +218,49 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
{...register("name")}
/>
<div className="grid grid-cols-[minmax(0,1fr)_110px] gap-3">
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("budget.form.amount")}
inputMode="decimal"
placeholder={t("budget.form.amountPlaceholder")}
value={formatAmountInput(field.value, intlLocale)}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
error={errors.amount?.message}
/>
)}
/>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Input
label={t("budget.form.currency")}
value={field.value}
maxLength={3}
autoCapitalize="characters"
placeholder="VND"
error={errors.currency?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(event.target.value.replace(/[^A-Za-z]/g, "").toUpperCase())}
/>
)}
/>
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("budget.form.amount")}
inputMode="decimal"
placeholder={t("budget.form.amountPlaceholder")}
value={formatAmountInput(field.value, intlLocale)}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
error={errors.amount?.message}
/>
)}
/>
</div>
<div className="w-[110px] shrink-0">
<Controller
name="currency"
control={control}
render={({ field }) => (
<Input
label={t("budget.form.currency")}
value={field.value}
maxLength={3}
autoCapitalize="characters"
placeholder="VND"
error={errors.currency?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(event.target.value.replace(/[^A-Za-z]/g, "").toUpperCase())}
/>
)}
/>
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-budget-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
......@@ -322,5 +336,18 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
/>
</form>
</Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
);
};
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
......@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { useI18n, TranslationFunction } from "@/i18n";
import { useWallets } from "@/hooks/use-wallets";
......@@ -126,6 +127,7 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
const anchorDate = watch("anchorDate");
const endDate = watch("endDate");
const formId = schedule ? `edit-recurring-${schedule.id}` : "create-recurring";
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
useEffect(() => { if (isOpen) reset(defaultValues); }, [defaultValues, isOpen, reset]);
......@@ -161,7 +163,8 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
}, [categoryId, categoryOptions, setValue]);
return (
<Modal
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={schedule ? t("recurringTransactions.editTitle") : t("recurringTransactions.createTitle")}
......@@ -184,14 +187,47 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
missedRunPolicy: values.missedRunPolicy,
...(schedule ? {} : { isActive: true }),
}))}>
<div className="grid grid-cols-2 gap-3">
<Select label={t("recurringTransactions.form.type")} options={[
{ value: "EXPENSE", label: t("transaction.typeExpense") },
{ value: "INCOME", label: t("transaction.typeIncome") },
]} disabled={isSubmitting} {...register("type")} />
<Controller name="amount" control={control} render={({ field }) => (
<Input {...field} label={t("recurringTransactions.form.amount")} inputMode="decimal" error={errors.amount?.message} disabled={isSubmitting} value={formatAmount(field.value, intlLocale)} onChange={(event) => field.onChange(parseAmount(event.target.value, intlLocale))} />
)} />
<div className="flex items-end gap-2">
<div className="flex-[1] min-w-0">
<Select
label={t("recurringTransactions.form.type")}
options={[
{ value: "EXPENSE", label: t("transaction.typeExpense") },
{ value: "INCOME", label: t("transaction.typeIncome") },
]}
disabled={isSubmitting}
{...register("type")}
/>
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<span className="font-nunito font-semibold text-sm px-1 invisible select-none" aria-hidden="true">
&nbsp;
</span>
<CalculatorButton
id="btn-recurring-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
/>
</div>
<div className="flex-[1.2] min-w-0">
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
{...field}
label={t("recurringTransactions.form.amount")}
inputMode="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))}
/>
)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Select label={t("recurringTransactions.form.wallet")} options={walletOptions} error={errors.walletId?.message} disabled={isSubmitting || walletsQuery.isLoading} {...register("walletId")} />
......@@ -216,5 +252,18 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
</p>
</form>
</Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
);
};
......@@ -13,7 +13,6 @@ interface ReportFiltersProps {
dateFrom: string;
dateTo: string;
walletId: string;
currency: string;
wallets: Wallet[];
isLoadingWallets: boolean;
dateError?: string;
......@@ -21,7 +20,6 @@ interface ReportFiltersProps {
onDateFromChange: (value: string) => void;
onDateToChange: (value: string) => void;
onWalletChange: (value: string) => void;
onCurrencyChange: (value: string) => void;
onReset: () => void;
}
......@@ -30,7 +28,6 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
dateFrom,
dateTo,
walletId,
currency,
wallets,
isLoadingWallets,
dateError,
......@@ -38,11 +35,9 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
onDateFromChange,
onDateToChange,
onWalletChange,
onCurrencyChange,
onReset,
}) => {
const { t } = useI18n();
const currencies = Array.from(new Set(wallets.map((wallet) => wallet.currency))).sort();
return (
<Card className="flex flex-col gap-4 p-4">
......@@ -51,7 +46,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
<h2 className="clay-title-h3">{t("report.filters.title")}</h2>
<p className="clay-caption">{t("report.filters.hint")}</p>
</div>
{(period !== "MONTH" || walletId || currency) && (
{(period !== "MONTH" || walletId) && (
<Button variant="ghost" className="shrink-0 px-3 text-sm" onClick={onReset}>
{t("report.filters.reset")}
</Button>
......@@ -87,7 +82,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<Select
label={t("report.filters.wallet")}
value={walletId}
......@@ -98,16 +93,6 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
...wallets.map((wallet) => ({ value: wallet.id, label: `${wallet.name} · ${wallet.currency}` })),
]}
/>
<Select
label={t("report.filters.currency")}
value={currency}
disabled={Boolean(walletId) || isLoadingWallets}
onChange={(event) => onCurrencyChange(event.target.value)}
options={[
{ value: "", label: t("report.filters.allCurrencies") },
...currencies.map((code) => ({ value: code, label: code })),
]}
/>
</div>
</Card>
);
......
......@@ -51,7 +51,6 @@ const ReportsPage: React.FC = () => {
const [dateFrom, setDateFrom] = useState(initialDates.from);
const [dateTo, setDateTo] = useState(initialDates.to);
const [walletId, setWalletId] = useState("");
const [currency, setCurrency] = useState("");
const [activeCurrency, setActiveCurrency] = useState("");
const walletsQuery = useWalletSearch({ includeArchived: false, sortBy: "name", order: "asc" }, true);
......@@ -71,9 +70,8 @@ const ReportsPage: React.FC = () => {
dateTo: toInclusiveBoundary(dateTo, true),
} : {}),
...(walletId ? { walletId } : {}),
...(!walletId && currency ? { currency } : {}),
granularity: "AUTO",
}), [currency, customDateOrderValid, dateFrom, dateTo, period, walletId]);
}), [customDateOrderValid, dateFrom, dateTo, period, walletId]);
const overviewQuery = useReportOverview(reportQuery, queryEnabled);
const cashFlowQuery = useCashFlowReport(reportQuery, queryEnabled);
......@@ -90,10 +88,6 @@ const ReportsPage: React.FC = () => {
}, [overview]);
useEffect(() => {
if (currency && availableCurrencies.includes(currency)) {
setActiveCurrency(currency);
return;
}
if (selectedWallet && availableCurrencies.includes(selectedWallet.currency)) {
setActiveCurrency(selectedWallet.currency);
return;
......@@ -101,7 +95,7 @@ const ReportsPage: React.FC = () => {
if (activeCurrency && availableCurrencies.includes(activeCurrency)) return;
const defaultCurrency = wallets.find((wallet) => wallet.isDefault)?.currency;
setActiveCurrency((defaultCurrency && availableCurrencies.includes(defaultCurrency)) ? defaultCurrency : (availableCurrencies[0] || ""));
}, [activeCurrency, availableCurrencies.join("|"), currency, selectedWallet, wallets]);
}, [activeCurrency, availableCurrencies.join("|"), selectedWallet, wallets]);
const metric = overview?.metricsByCurrency.find((item) => item.currency === activeCurrency);
const currencyWallets = overview?.wallets.items.filter((wallet) => wallet.currency === activeCurrency) || [];
......@@ -115,7 +109,6 @@ const ReportsPage: React.FC = () => {
setWalletId(value);
const wallet = wallets.find((item) => item.id === value);
if (wallet) {
setCurrency("");
setActiveCurrency(wallet.currency);
}
};
......@@ -125,7 +118,6 @@ const ReportsPage: React.FC = () => {
setDateFrom(initialDates.from);
setDateTo(initialDates.to);
setWalletId("");
setCurrency("");
};
const retryAll = () => {
......@@ -176,7 +168,6 @@ const ReportsPage: React.FC = () => {
dateFrom={dateFrom}
dateTo={dateTo}
walletId={walletId}
currency={currency}
wallets={wallets}
isLoadingWallets={walletsQuery.isLoading}
dateError={dateError}
......@@ -184,11 +175,10 @@ const ReportsPage: React.FC = () => {
onDateFromChange={setDateFrom}
onDateToChange={setDateTo}
onWalletChange={handleWalletChange}
onCurrencyChange={(value) => { setCurrency(value); setActiveCurrency(value); }}
onReset={resetFilters}
/>
{availableCurrencies.length > 1 && !currency && !walletId && (
{availableCurrencies.length > 1 && !walletId && (
<div className="flex gap-2 overflow-x-auto px-1 pb-2" role="tablist" aria-label={t("report.currencyTabs")}>
{availableCurrencies.map((code) => (
<Button key={code} variant={activeCurrency === code ? "primary" : "secondary"} shape="pill" className="shrink-0 px-5 py-2 text-sm" onClick={() => setActiveCurrency(code)}>
......
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
......@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDateTime } from "@/lib/money-input";
import { SavingContribution, SavingContributionInput } from "@/types/saving-goal";
......@@ -51,7 +52,7 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
const schema = useMemo(() => createSchema(t), [t]);
const defaultValues = useMemo(() => getDefaultValues(contribution), [contribution]);
const formId = contribution ? `edit-contribution-${contribution.id}` : "create-contribution";
const { control, register, handleSubmit, reset, watch, formState: { errors } } = useForm<ContributionFormValues>({
const { control, register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<ContributionFormValues>({
resolver: zodResolver(schema),
defaultValues,
});
......@@ -61,70 +62,96 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
}, [defaultValues, isOpen, reset]);
const contributedAt = watch("contributedAt");
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={contribution ? t("savingGoal.contribution.editTitle") : t("savingGoal.contribution.createTitle")}
footer={(
<>
<Button type="button" variant="ghost" className="px-4 text-sm" disabled={isSubmitting} onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" form={formId} className="px-4 text-sm" disabled={isSubmitting}>
{isSubmitting ? t("common.saving") : contribution ? t("common.save") : t("savingGoal.contribution.add")}
</Button>
</>
)}
>
<form
id={formId}
className="flex flex-col gap-4"
onSubmit={handleSubmit((values) => onSubmit({
amount: values.amount,
contributedAt: businessWallTimeToIso(values.contributedAt),
note: values.note.trim() || null,
}))}
noValidate
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={contribution ? t("savingGoal.contribution.editTitle") : t("savingGoal.contribution.createTitle")}
footer={(
<>
<Button type="button" variant="ghost" className="px-4 text-sm" disabled={isSubmitting} onClick={onClose}>{t("common.cancel")}</Button>
<Button type="submit" form={formId} className="px-4 text-sm" disabled={isSubmitting}>
{isSubmitting ? t("common.saving") : contribution ? t("common.save") : t("savingGoal.contribution.add")}
</Button>
</>
)}
>
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("savingGoal.contribution.amount", { currency })}
inputMode="decimal"
value={formatMoneyInput(field.value, intlLocale)}
placeholder={t("savingGoal.contribution.amountPlaceholder")}
disabled={isSubmitting}
error={errors.amount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
<LocalizedDateInput
type="datetime-local"
value={contributedAt}
max={toLocalDateTime()}
label={t("savingGoal.contribution.date")}
disabled={isSubmitting}
error={errors.contributedAt?.message}
{...register("contributedAt")}
/>
<div className="flex flex-col gap-2">
<label htmlFor={`${formId}-note`} className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("savingGoal.contribution.note")}</label>
<textarea
id={`${formId}-note`}
rows={3}
maxLength={500}
<form
id={formId}
className="flex flex-col gap-4"
onSubmit={handleSubmit((values) => onSubmit({
amount: values.amount,
contributedAt: businessWallTimeToIso(values.contributedAt),
note: values.note.trim() || null,
}))}
noValidate
>
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("savingGoal.contribution.amount", { currency })}
inputMode="decimal"
value={formatMoneyInput(field.value, intlLocale)}
placeholder={t("savingGoal.contribution.amountPlaceholder")}
disabled={isSubmitting}
error={errors.amount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-contribution-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
/>
</div>
</div>
<LocalizedDateInput
type="datetime-local"
value={contributedAt}
max={toLocalDateTime()}
label={t("savingGoal.contribution.date")}
disabled={isSubmitting}
placeholder={t("savingGoal.contribution.notePlaceholder")}
className="w-full resize-none rounded-clay-sm border border-transparent bg-clay-bg px-4 py-3 font-nunito text-base text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out placeholder-clay-text-muted/65 focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
{...register("note")}
error={errors.contributedAt?.message}
{...register("contributedAt")}
/>
{errors.note?.message && <span className="px-1 font-nunito text-xs text-clay-expense">{errors.note.message}</span>}
</div>
</form>
</Modal>
<div className="flex flex-col gap-2">
<label htmlFor={`${formId}-note`} className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("savingGoal.contribution.note")}</label>
<textarea
id={`${formId}-note`}
rows={3}
maxLength={500}
disabled={isSubmitting}
placeholder={t("savingGoal.contribution.notePlaceholder")}
className="w-full resize-none rounded-clay-sm border border-transparent bg-clay-bg px-4 py-3 font-nunito text-base text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out placeholder-clay-text-muted/65 focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
{...register("note")}
/>
{errors.note?.message && <span className="px-1 font-nunito text-xs text-clay-expense">{errors.note.message}</span>}
</div>
</form>
</Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
);
};
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
......@@ -7,6 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDate } from "@/lib/money-input";
import { CreateSavingGoalInput, SavingGoal } from "@/types/saving-goal";
......@@ -84,6 +85,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
const selectedColor = watch("color");
const selectedTargetDate = watch("targetDate");
const currencyLocked = Boolean(goal && goal.progress.contributionCount > 0);
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const submitForm = (values: SavingGoalFormValues) => {
onSubmit({
......@@ -98,7 +100,8 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
};
return (
<Modal
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={goal ? t("savingGoal.form.editTitle") : t("savingGoal.form.createTitle")}
......@@ -122,39 +125,50 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
<Input label={t("savingGoal.form.name")} placeholder={t("savingGoal.form.namePlaceholder")} maxLength={100} disabled={isSubmitting} error={errors.name?.message} {...register("name")} />
<div className="grid grid-cols-[minmax(0,1fr)_105px] gap-3">
<Controller
name="targetAmount"
control={control}
render={({ field }) => (
<Input
label={t("savingGoal.form.targetAmount")}
inputMode="decimal"
placeholder={t("savingGoal.form.amountPlaceholder")}
value={formatMoneyInput(field.value, intlLocale)}
disabled={isSubmitting}
error={errors.targetAmount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Input
label={t("savingGoal.form.currency")}
value={field.value}
maxLength={3}
autoCapitalize="characters"
disabled={isSubmitting || currencyLocked}
error={errors.currency?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(event.target.value.replace(/[^A-Za-z]/g, "").toUpperCase())}
/>
)}
/>
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller
name="targetAmount"
control={control}
render={({ field }) => (
<Input
label={t("savingGoal.form.targetAmount")}
inputMode="decimal"
placeholder={t("savingGoal.form.amountPlaceholder")}
value={formatMoneyInput(field.value, intlLocale)}
disabled={isSubmitting}
error={errors.targetAmount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
</div>
<div className="w-[105px] shrink-0">
<Controller
name="currency"
control={control}
render={({ field }) => (
<Input
label={t("savingGoal.form.currency")}
value={field.value}
maxLength={3}
autoCapitalize="characters"
disabled={isSubmitting || currencyLocked}
error={errors.currency?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(event.target.value.replace(/[^A-Za-z]/g, "").toUpperCase())}
/>
)}
/>
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-saving-goal-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
/>
</div>
</div>
{currencyLocked && <p className="-mt-2 px-1 clay-caption">{t("savingGoal.form.currencyLocked")}</p>}
......@@ -212,5 +226,18 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
</fieldset>
</form>
</Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("targetAmount")}
onApply={(calculatedAmount) => {
setValue("targetAmount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
);
};
import React, { useMemo } from "react";
import React, { useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
......@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Slider } from "@/components/ui/Slider";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern } from "@/lib/money-input";
import { Perturbation } from "@/types/simulation";
......@@ -53,6 +54,7 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
defaultValues: { type: "RECURRING_EXPENSE", name: "", amount: "" },
});
const adjustmentType = watch("type");
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const submitPerturbation = (values: CustomPerturbationValues) => {
onAddPerturbation({
......@@ -172,22 +174,33 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
/>
)}
/>
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("simulations.adjustmentAmount")}
inputMode="decimal"
placeholder={t("simulations.amountPlaceholder")}
value={formatMoneyInput(field.value, intlLocale)}
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("simulations.adjustmentAmount")}
inputMode="decimal"
placeholder={t("simulations.amountPlaceholder")}
value={formatMoneyInput(field.value, intlLocale)}
disabled={isLoading}
error={errors.amount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-simulation-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isLoading}
error={errors.amount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
</div>
</div>
<Button type="submit" variant="secondary" fullWidth disabled={isLoading} className="py-2 text-sm">
{t("simulations.addAdjustmentBtn")}
</Button>
......@@ -203,6 +216,18 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
>
{isLoading ? t("common.processing") : t("simulations.runSimulationBtn")}
</Button>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</Card>
);
};
......@@ -229,7 +229,7 @@ const StyleGuidePage: React.FC = () => {
label={t("styleGuide.amount")}
placeholder={t("styleGuide.amountPlaceholder")}
inputMode="decimal"
className="text-right font-semibold tabular-nums"
className="font-semibold tabular-nums"
value={formatAmountInput(amountValue, intlLocale)}
onChange={(event) => setAmountValue(parseAmountInput(event.target.value, intlLocale))}
/>
......@@ -413,7 +413,7 @@ const StyleGuidePage: React.FC = () => {
label={t("styleGuide.amount")}
inputMode="decimal"
placeholder={t("styleGuide.amountPlaceholder")}
className="text-right font-semibold tabular-nums"
className="font-semibold tabular-nums"
value={formatAmountInput(modalAmountValue, intlLocale)}
onChange={(event) => setModalAmountValue(parseAmountInput(event.target.value, intlLocale))}
/>
......
......@@ -7,8 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { CalculatorIcon } from "@/components/ui/icons";
import { TransactionCalculatorModal } from "./TransactionCalculatorModal";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { TranslationFunction, useI18n } from "@/i18n";
import { useWallets } from "@/hooks/use-wallets";
import { useCategoryTree } from "@/hooks/use-categories";
......@@ -111,11 +110,15 @@ interface FlatCategoryOption {
depth: number;
}
function flattenTree(nodes: CategoryTreeNode[], depth = 0): FlatCategoryOption[] {
return nodes.flatMap((node) => [
{ category: node, depth },
...flattenTree(node.children, depth + 1),
]);
function flattenTree(nodes: CategoryTreeNode[] = [], depth = 0): FlatCategoryOption[] {
const result: FlatCategoryOption[] = [];
for (const node of nodes || []) {
result.push({ category: node, depth });
if (node.children && node.children.length > 0) {
result.push(...flattenTree(node.children, depth + 1));
}
}
return result;
}
const getLocalDateString = (dateInput?: string | Date) => {
......@@ -409,17 +412,11 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
<span className="font-nunito font-semibold text-sm px-1 invisible select-none" aria-hidden="true">
&nbsp;
</span>
<button
<CalculatorButton
id="btn-transaction-calculator"
type="button"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
title={t("transaction.calculator") || "Máy tính"}
aria-label={t("transaction.calculator") || "Máy tính"}
className="flex h-[46px] w-[46px] items-center justify-center rounded-clay-sm bg-clay-surface text-clay-primary border border-clay-highlight/60 shadow-clay-raised transition-all duration-200 ease-in-out hover:shadow-clay-hover hover:scale-105 active:shadow-clay-pressed active:scale-95 disabled:opacity-50 disabled:pointer-events-none"
>
<CalculatorIcon size={22} />
</button>
/>
</div>
{/* Số tiền */}
......@@ -435,7 +432,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
placeholder={t("transaction.amountPlaceholder")}
error={errors.amount?.message}
disabled={isSubmitting}
className="text-right tabular-nums font-semibold"
className="tabular-nums font-semibold"
value={formatAmountInput(field.value, intlLocale)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
/>
......@@ -632,7 +629,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
</form>
</Modal>
<TransactionCalculatorModal
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
......
......@@ -684,7 +684,7 @@ const TransactionsPage: React.FC = () => {
label={t("transaction.filterMinAmount")}
inputMode="decimal"
placeholder={t("transaction.amountPlaceholder")}
className="text-right tabular-nums"
className="tabular-nums"
value={formatAmountInput(minAmount, intlLocale)}
onChange={(e) => setMinAmount(parseAmountInput(e.target.value, intlLocale))}
/>
......@@ -692,7 +692,7 @@ const TransactionsPage: React.FC = () => {
label={t("transaction.filterMaxAmount")}
inputMode="decimal"
placeholder={t("transaction.amountPlaceholder")}
className="text-right tabular-nums"
className="tabular-nums"
value={formatAmountInput(maxAmount, intlLocale)}
onChange={(e) => setMaxAmount(parseAmountInput(e.target.value, intlLocale))}
/>
......
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
......@@ -7,6 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { useWalletSearch } from "@/hooks/use-wallets";
import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
......@@ -175,6 +176,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
const destinationWalletId = watch("destinationWalletId");
const amount = watch("amount");
const transferredAt = watch("transferredAt");
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const sourceWallet = wallets.find((wallet) => wallet.id === sourceWalletId);
const destinationWallet = wallets.find((wallet) => wallet.id === destinationWalletId);
......@@ -227,7 +229,8 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
const insufficientWallets = !walletsQuery.isLoading && !walletsQuery.isError && wallets.length < 2;
return (
<Modal
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={t("transfer.create")}
......@@ -288,26 +291,37 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
{...register("destinationWalletId")}
/>
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
{...field}
label={t("transfer.amount")}
inputMode="decimal"
placeholder={t("transfer.amountPlaceholder")}
error={errors.amount?.message}
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
{...field}
label={t("transfer.amount")}
inputMode="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))}
endAdornment={sourceWallet ? (
<span className="text-xs font-bold text-clay-text-muted">{sourceWallet.currency}</span>
) : undefined}
/>
)}
/>
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-transfer-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={walletsQuery.isLoading || insufficientWallets}
className="text-right font-semibold tabular-nums"
value={formatAmountInput(field.value, intlLocale)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
endAdornment={sourceWallet ? (
<span className="text-xs font-bold text-clay-text-muted">{sourceWallet.currency}</span>
) : undefined}
/>
)}
/>
</div>
</div>
{sourceWallet && (
<div className="-mt-2 flex flex-wrap justify-between gap-1 px-1 text-xs font-semibold text-clay-text-muted">
......@@ -345,5 +359,18 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
/>
</form>
</Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
);
};
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { WALLET_COLORS, WALLET_ICONS } from "@/lib/wallet-format";
import { Wallet, WalletInput } from "@/types/wallet";
import { WalletArtwork } from "@/components/shared/WalletArtwork";
......@@ -152,6 +153,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
const selectedIcon = watch("icon");
const selectedColor = watch("color");
const currentCurrency = watch("currency")?.trim()?.toUpperCase() || "VND";
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const submitForm = (values: WalletFormValues) => {
const currency = values.currency.trim().toUpperCase();
......@@ -170,7 +172,8 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
};
return (
<Modal
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={wallet ? t("wallet.form.editTitle") : t("wallet.form.createTitle")}
......@@ -196,25 +199,36 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
<Input label={t("wallet.form.name")} placeholder={t("wallet.form.namePlaceholder")} error={errors.name?.message} disabled={isSubmitting} {...register("name")} />
<div className="grid grid-cols-[1fr_96px] gap-3">
<Controller
name="balance"
control={control}
render={({ field }) => (
<Input
{...field}
label={t("wallet.form.balance")}
inputMode={currentCurrency === "VND" ? "numeric" : "decimal"}
placeholder="0"
error={errors.balance?.message}
disabled={isSubmitting}
className="text-right tabular-nums"
value={formatBalanceInput(field.value, intlLocale, currentCurrency)}
onChange={(event) => field.onChange(parseBalanceInput(event.target.value, intlLocale, currentCurrency))}
/>
)}
/>
<Input label={t("wallet.form.currency")} maxLength={3} placeholder="VND" error={errors.currency?.message} disabled={isSubmitting} className="uppercase" {...register("currency")} />
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller
name="balance"
control={control}
render={({ field }) => (
<Input
{...field}
label={t("wallet.form.balance")}
inputMode={currentCurrency === "VND" ? "numeric" : "decimal"}
placeholder="0"
error={errors.balance?.message}
disabled={isSubmitting}
className="tabular-nums font-semibold"
value={formatBalanceInput(field.value, intlLocale, currentCurrency)}
onChange={(event) => field.onChange(parseBalanceInput(event.target.value, intlLocale, currentCurrency))}
/>
)}
/>
</div>
<div className="w-[96px] shrink-0">
<Input label={t("wallet.form.currency")} maxLength={3} placeholder="VND" error={errors.currency?.message} disabled={isSubmitting} className="uppercase" {...register("currency")} />
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-wallet-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
/>
</div>
</div>
<fieldset className="flex flex-col gap-2">
......@@ -277,5 +291,18 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
)}
</form>
</Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("balance")}
onApply={(calculatedAmount) => {
setValue("balance", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
);
};
......@@ -6,7 +6,7 @@
"noImplicitAny": false,
"preserveConstEnums": true,
"jsx": "react-jsx",
"lib": ["dom", "es5", "es6", "es7", "es2017", "es2018"],
"lib": ["dom", "dom.iterable", "esnext"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowJs": true,
......
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