Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-fe
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-fe
Commits
130901b6
Commit
130901b6
authored
Sep 10, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(fe): integrate amount calculator modal across all forms and resolve type issues
parent
f1652ab8
Changes
18
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
984 additions
and
736 deletions
+984
-736
CalculatorModal.tsx
src/components/ui/CalculatorModal.tsx
+508
-0
en.json
src/i18n/locales/en.json
+1
-1
vi.json
src/i18n/locales/vi.json
+1
-1
AnomalyChecker.tsx
src/pages/anomalies/components/AnomalyChecker.tsx
+40
-14
BudgetFormModal.tsx
src/pages/budgets/components/BudgetFormModal.tsx
+61
-34
RecurringTransactionFormModal.tsx
...transactions/components/RecurringTransactionFormModal.tsx
+59
-10
ReportFilters.tsx
src/pages/reports/components/ReportFilters.tsx
+2
-17
index.tsx
src/pages/reports/index.tsx
+3
-13
ContributionFormModal.tsx
src/pages/saving-goals/components/ContributionFormModal.tsx
+89
-62
SavingGoalFormModal.tsx
src/pages/saving-goals/components/SavingGoalFormModal.tsx
+62
-35
SimulationControls.tsx
src/pages/simulations/components/SimulationControls.tsx
+40
-15
style-guide.tsx
src/pages/style-guide.tsx
+2
-2
TransactionCalculatorModal.tsx
...es/transactions/components/TransactionCalculatorModal.tsx
+4
-471
TransactionFormModal.tsx
src/pages/transactions/components/TransactionFormModal.tsx
+14
-17
index.tsx
src/pages/transactions/index.tsx
+2
-2
TransferFormModal.tsx
src/pages/transfers/components/TransferFormModal.tsx
+47
-20
WalletFormModal.tsx
src/pages/wallets/components/WalletFormModal.tsx
+48
-21
tsconfig.json
tsconfig.json
+1
-1
No files found.
src/components/ui/CalculatorModal.tsx
0 → 100644
View file @
130901b6
This diff is collapsed.
Click to expand it.
src/i18n/locales/en.json
View file @
130901b6
...
...
@@ -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"
,
...
...
src/i18n/locales/vi.json
View file @
130901b6
...
...
@@ -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"
,
...
...
src/pages/anomalies/components/AnomalyChecker.tsx
View file @
130901b6
...
...
@@ -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
>
);
};
src/pages/budgets/components/BudgetFormModal.tsx
View file @
130901b6
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
,
});
}
}
/>
</>
);
};
src/pages/recurring-transactions/components/RecurringTransactionFormModal.tsx
View file @
130901b6
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"
>
</
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
,
});
}
}
/>
</>
);
};
src/pages/reports/components/ReportFilters.tsx
View file @
130901b6
...
...
@@ -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
>
);
...
...
src/pages/reports/index.tsx
View file @
130901b6
...
...
@@ -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"
,
}),
[
cu
rrency
,
cu
stomDateOrderValid
,
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
)
}
>
...
...
src/pages/saving-goals/components/ContributionFormModal.tsx
View file @
130901b6
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
,
});
}
}
/>
</>
);
};
src/pages/saving-goals/components/SavingGoalFormModal.tsx
View file @
130901b6
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
,
});
}
}
/>
</>
);
};
src/pages/simulations/components/SimulationControls.tsx
View file @
130901b6
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
>
);
};
src/pages/style-guide.tsx
View file @
130901b6
...
...
@@ -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
))
}
/>
...
...
src/pages/transactions/components/TransactionCalculatorModal.tsx
View file @
130901b6
This diff is collapsed.
Click to expand it.
src/pages/transactions/components/TransactionFormModal.tsx
View file @
130901b6
...
...
@@ -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"
>
</
span
>
<
b
utton
<
CalculatorB
utton
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=
"t
ext-right t
abular-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
>
<
Transaction
CalculatorModal
<
CalculatorModal
isOpen=
{
isCalculatorOpen
}
onClose=
{
()
=>
setIsCalculatorOpen
(
false
)
}
initialAmount=
{
watch
(
"amount"
)
}
...
...
src/pages/transactions/index.tsx
View file @
130901b6
...
...
@@ -684,7 +684,7 @@ const TransactionsPage: React.FC = () => {
label=
{
t
(
"transaction.filterMinAmount"
)
}
inputMode=
"decimal"
placeholder=
{
t
(
"transaction.amountPlaceholder"
)
}
className=
"t
ext-right t
abular-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=
"t
ext-right t
abular-nums"
className=
"tabular-nums"
value=
{
formatAmountInput
(
maxAmount
,
intlLocale
)
}
onChange=
{
(
e
)
=>
setMaxAmount
(
parseAmountInput
(
e
.
target
.
value
,
intlLocale
))
}
/>
...
...
src/pages/transfers/components/TransferFormModal.tsx
View file @
130901b6
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
,
});
}
}
/>
</>
);
};
src/pages/wallets/components/WalletFormModal.tsx
View file @
130901b6
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
,
});
}
}
/>
</>
);
};
tsconfig.json
View file @
130901b6
...
...
@@ -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
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment