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
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
import
React
,
{
useEffect
,
useState
}
from
"react"
;
import
{
CalculatorIcon
,
CheckIcon
,
CloseIcon
}
from
"@/components/ui/icons"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
useI18n
}
from
"@/i18n"
;
export
interface
CalculatorButtonProps
{
onClick
:
()
=>
void
;
disabled
?:
boolean
;
className
?:
string
;
id
?:
string
;
size
?:
number
;
title
?:
string
;
}
export
const
CalculatorButton
:
React
.
FC
<
CalculatorButtonProps
>
=
({
onClick
,
disabled
=
false
,
className
=
""
,
id
,
size
=
22
,
title
,
})
=>
{
const
{
t
}
=
useI18n
();
const
displayTitle
=
title
||
t
(
"transaction.calculator"
)
||
"Máy tính"
;
return
(
<
button
id=
{
id
}
type=
"button"
onClick=
{
onClick
}
disabled=
{
disabled
}
title=
{
displayTitle
}
aria
-
label=
{
displayTitle
}
className=
{
`flex h-[46px] w-[46px] shrink-0 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 ${className}`
}
>
<
CalculatorIcon
size=
{
size
}
/>
</
button
>
);
};
export
interface
CalculatorModalProps
{
isOpen
:
boolean
;
onClose
:
()
=>
void
;
onApply
:
(
amount
:
string
)
=>
void
;
initialAmount
?:
string
;
title
?:
string
;
}
type
Operator
=
"+"
|
"-"
|
"*"
|
"/"
;
function
getNumberSeparators
(
locale
:
string
):
{
group
:
string
;
decimal
:
string
}
{
const
defaultGroup
=
locale
.
startsWith
(
"vi"
)
?
"."
:
","
;
const
defaultDecimal
=
locale
.
startsWith
(
"vi"
)
?
","
:
"."
;
try
{
const
formatter
=
new
Intl
.
NumberFormat
(
locale
);
if
(
typeof
formatter
.
formatToParts
===
"function"
)
{
const
parts
=
formatter
.
formatToParts
(
1234.5
);
const
group
=
parts
.
find
((
p
)
=>
p
.
type
===
"group"
)?.
value
;
const
decimal
=
parts
.
find
((
p
)
=>
p
.
type
===
"decimal"
)?.
value
;
if
(
group
&&
decimal
)
{
return
{
group
,
decimal
};
}
}
const
nonDigits
=
formatter
.
format
(
1234.5
).
match
(
/
[^\d]
/g
);
const
g
=
nonDigits
?.[
0
];
const
d
=
nonDigits
?.[
1
];
if
(
g
&&
d
)
{
return
{
group
:
g
,
decimal
:
d
};
}
}
catch
{
// Fallback if Intl is unavailable or fails
}
return
{
group
:
defaultGroup
,
decimal
:
defaultDecimal
,
};
}
function
formatDisplayValue
(
raw
:
string
,
locale
:
string
):
string
{
if
(
!
raw
)
return
"0"
;
const
[
intPart
,
decPart
]
=
raw
.
split
(
"."
);
const
{
group
,
decimal
}
=
getNumberSeparators
(
locale
);
const
formattedInt
=
(
intPart
||
"0"
).
replace
(
/
\B(?=(\d{3})
+
(?!\d))
/g
,
group
);
return
decPart
!==
undefined
?
`
${
formattedInt
}${
decimal
}${
decPart
}
`
:
formattedInt
;
}
function
opSymbol
(
op
:
Operator
):
string
{
switch
(
op
)
{
case
"+"
:
return
"+"
;
case
"-"
:
return
"−"
;
case
"*"
:
return
"×"
;
case
"/"
:
return
"÷"
;
}
}
export
const
CalculatorModal
:
React
.
FC
<
CalculatorModalProps
>
=
({
isOpen
,
onClose
,
onApply
,
initialAmount
=
""
,
title
,
})
=>
{
const
{
t
,
intlLocale
}
=
useI18n
();
const
[
display
,
setDisplay
]
=
useState
<
string
>
(
"0"
);
const
[
expression
,
setExpression
]
=
useState
<
string
>
(
""
);
const
[
prevValue
,
setPrevValue
]
=
useState
<
number
|
null
>
(
null
);
const
[
operator
,
setOperator
]
=
useState
<
Operator
|
null
>
(
null
);
const
[
waitingForOperand
,
setWaitingForOperand
]
=
useState
<
boolean
>
(
false
);
const
[
isCalculated
,
setIsCalculated
]
=
useState
<
boolean
>
(
false
);
// Initialize or reset when modal opens
useEffect
(()
=>
{
if
(
isOpen
)
{
const
sanitized
=
initialAmount
?
String
(
parseFloat
(
initialAmount
)
||
0
)
:
"0"
;
setDisplay
(
sanitized
===
"0"
?
"0"
:
sanitized
);
setExpression
(
""
);
setPrevValue
(
null
);
setOperator
(
null
);
setWaitingForOperand
(
false
);
setIsCalculated
(
false
);
}
},
[
isOpen
,
initialAmount
]);
if
(
!
isOpen
)
return
null
;
const
calculateResult
=
(
prev
:
number
,
current
:
number
,
op
:
Operator
):
number
=>
{
let
res
=
0
;
switch
(
op
)
{
case
"+"
:
res
=
prev
+
current
;
break
;
case
"-"
:
res
=
prev
-
current
;
break
;
case
"*"
:
res
=
prev
*
current
;
break
;
case
"/"
:
res
=
current
===
0
?
0
:
prev
/
current
;
break
;
}
// Round to 2 decimal places and ensure non-negative
const
rounded
=
Math
.
round
(
res
*
100
)
/
100
;
return
Math
.
max
(
0
,
rounded
);
};
const
handleDigit
=
(
digit
:
string
)
=>
{
if
(
isCalculated
)
{
setIsCalculated
(
false
);
if
(
digit
===
"0"
)
{
if
(
display
!==
"0"
)
{
const
current
=
parseFloat
(
display
)
||
0
;
const
nextVal
=
display
.
includes
(
"."
)
?
Math
.
round
(
current
*
10
*
100
)
/
100
:
display
+
"0"
;
const
nextStr
=
String
(
nextVal
);
if
(
nextStr
.
length
<=
14
)
{
setDisplay
(
nextStr
);
setExpression
(
""
);
}
}
return
;
}
// Digit 1-9: starts fresh number
setDisplay
(
digit
);
setExpression
(
""
);
return
;
}
if
(
waitingForOperand
)
{
setDisplay
(
digit
);
setWaitingForOperand
(
false
);
}
else
{
if
(
display
===
"0"
)
{
setDisplay
(
digit
);
}
else
if
(
display
.
length
<
14
)
{
setDisplay
(
display
+
digit
);
}
}
};
const
handleTripleZero
=
()
=>
{
if
(
isCalculated
)
{
setIsCalculated
(
false
);
if
(
display
!==
"0"
)
{
const
current
=
parseFloat
(
display
)
||
0
;
const
nextVal
=
display
.
includes
(
"."
)
?
Math
.
round
(
current
*
1000
*
100
)
/
100
:
display
+
"000"
;
const
nextStr
=
String
(
nextVal
);
if
(
nextStr
.
length
<=
14
)
{
setDisplay
(
nextStr
);
setExpression
(
""
);
}
}
return
;
}
if
(
waitingForOperand
)
{
setDisplay
(
"0"
);
setWaitingForOperand
(
false
);
}
else
{
if
(
display
!==
"0"
&&
display
.
length
<=
11
)
{
setDisplay
(
display
+
"000"
);
}
}
};
const
handleDecimal
=
()
=>
{
if
(
isCalculated
)
{
setIsCalculated
(
false
);
setDisplay
(
"0."
);
setExpression
(
""
);
return
;
}
if
(
waitingForOperand
)
{
setDisplay
(
"0."
);
setWaitingForOperand
(
false
);
}
else
if
(
!
display
.
includes
(
"."
))
{
setDisplay
(
display
+
"."
);
}
};
const
handleOperator
=
(
nextOp
:
Operator
)
=>
{
setIsCalculated
(
false
);
const
currentNum
=
parseFloat
(
display
)
||
0
;
if
(
prevValue
!==
null
&&
operator
&&
!
waitingForOperand
)
{
const
computed
=
calculateResult
(
prevValue
,
currentNum
,
operator
);
setPrevValue
(
computed
);
setDisplay
(
String
(
computed
));
setExpression
(
`
${
formatDisplayValue
(
String
(
computed
),
intlLocale
)}
${
opSymbol
(
nextOp
)}
`
);
}
else
{
setPrevValue
(
currentNum
);
setExpression
(
`
${
formatDisplayValue
(
display
,
intlLocale
)}
${
opSymbol
(
nextOp
)}
`
);
}
setOperator
(
nextOp
);
setWaitingForOperand
(
true
);
};
const
handleEquals
=
()
=>
{
if
(
prevValue
===
null
||
!
operator
)
return
;
const
currentNum
=
parseFloat
(
display
)
||
0
;
const
computed
=
calculateResult
(
prevValue
,
currentNum
,
operator
);
setExpression
(
`
${
formatDisplayValue
(
String
(
prevValue
),
intlLocale
)}
${
opSymbol
(
operator
)}
${
formatDisplayValue
(
display
,
intlLocale
)}
=`
);
setDisplay
(
String
(
computed
));
setPrevValue
(
null
);
setOperator
(
null
);
setWaitingForOperand
(
false
);
setIsCalculated
(
true
);
};
const
handleClear
=
()
=>
{
setDisplay
(
"0"
);
setExpression
(
""
);
setPrevValue
(
null
);
setOperator
(
null
);
setWaitingForOperand
(
false
);
setIsCalculated
(
false
);
};
const
handleBackspace
=
()
=>
{
if
(
isCalculated
)
{
setIsCalculated
(
false
);
setExpression
(
""
);
}
if
(
waitingForOperand
)
return
;
if
(
display
.
length
>
1
)
{
setDisplay
(
display
.
slice
(
0
,
-
1
));
}
else
{
setDisplay
(
"0"
);
}
};
const
handleDone
=
()
=>
{
let
finalNum
=
parseFloat
(
display
)
||
0
;
// If there's an uncompleted operation, calculate it
if
(
prevValue
!==
null
&&
operator
&&
!
waitingForOperand
)
{
finalNum
=
calculateResult
(
prevValue
,
finalNum
,
operator
);
}
finalNum
=
Math
.
max
(
0
,
finalNum
);
const
resultStr
=
Number
.
isInteger
(
finalNum
)
?
String
(
finalNum
)
:
String
(
Number
(
finalNum
.
toFixed
(
2
)));
onApply
(
resultStr
);
onClose
();
};
return
(
<
div
className=
"fixed inset-0 z-[1050] flex items-end justify-center p-0 sm:items-center sm:p-4"
>
{
/* Backdrop */
}
<
div
className=
"absolute inset-0 bg-clay-overlay/60 backdrop-blur-[6px] transition-opacity"
onClick=
{
onClose
}
/>
{
/* Calculator Container */
}
<
div
className=
"relative w-full max-w-xs bg-clay-surface rounded-t-clay-lg sm:rounded-clay-lg shadow-clay-modal border-t border-x sm:border border-clay-highlight/60 p-5 flex flex-col gap-3.5 select-none animate-modal-content-in"
>
{
/* Header */
}
<
div
className=
"flex items-center justify-between pb-1 border-b border-clay-text-muted/10"
>
<
div
className=
"flex items-center gap-2"
>
<
CalculatorIcon
size=
{
22
}
className=
"text-clay-primary"
/>
<
h3
className=
"clay-title-h3 text-base"
>
{
title
||
t
(
"transaction.calculatorTitle"
)
||
"Máy tính giao dịch"
}
</
h3
>
</
div
>
<
button
type=
"button"
onClick=
{
onClose
}
aria
-
label=
{
t
(
"accessibility.closeModal"
)
||
"Đóng"
}
className=
"w-7 h-7 rounded-full flex items-center justify-center bg-clay-bg text-clay-text-muted hover:text-clay-text shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
<
CloseIcon
size=
{
16
}
/>
</
button
>
</
div
>
{
/* Display Screen */
}
<
div
className=
"bg-clay-bg rounded-clay-sm p-3 shadow-clay-pressed border border-clay-highlight/30 flex flex-col justify-center min-h-[68px] text-right"
>
<
div
className=
"text-xs font-nunito font-semibold text-clay-text-muted/80 tracking-wide h-4 truncate"
>
{
expression
||
"
\
u00A0"
}
</
div
>
<
div
className=
"text-2xl font-baloo font-bold text-clay-text tracking-tight mt-0.5 truncate"
>
{
formatDisplayValue
(
display
,
intlLocale
)
}
</
div
>
</
div
>
{
/* Keypad Grid */
}
<
div
className=
"grid grid-cols-4 gap-2"
>
{
/* Row 1 */
}
<
button
type=
"button"
onClick=
{
handleClear
}
className=
"h-11 rounded-clay-sm bg-clay-expense/15 text-clay-expense border border-clay-expense/30 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
C
</
button
>
<
button
type=
"button"
onClick=
{
handleBackspace
}
className=
"h-11 rounded-clay-sm bg-clay-expense/15 text-clay-expense border border-clay-expense/30 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
⌫
</
button
>
<
button
type=
"button"
onClick=
{
handleTripleZero
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-primary border border-clay-highlight/50 font-baloo font-bold text-xs shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
000
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleOperator
(
"/"
)
}
className=
"h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
÷
</
button
>
{
/* Row 2 */
}
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"7"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
7
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"8"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
8
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"9"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
9
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleOperator
(
"*"
)
}
className=
"h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
×
</
button
>
{
/* Row 3 */
}
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"4"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
4
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"5"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
5
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"6"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
6
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleOperator
(
"-"
)
}
className=
"h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
−
</
button
>
{
/* Row 4 */
}
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"1"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
1
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"2"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
2
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"3"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
3
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleOperator
(
"+"
)
}
className=
"h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
+
</
button
>
{
/* Row 5 */
}
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"0"
)
}
className=
"col-span-2 h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
0
</
button
>
<
button
type=
"button"
onClick=
{
handleDecimal
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-lg shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
.
</
button
>
<
button
type=
"button"
onClick=
{
handleEquals
}
className=
"h-11 rounded-clay-sm bg-clay-primary text-clay-on-primary border border-clay-primary-dark/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
=
</
button
>
</
div
>
{
/* Done Action Button */
}
<
Button
id=
"btn-calc-done"
type=
"button"
variant=
"primary"
fullWidth
onClick=
{
handleDone
}
className=
"mt-1 py-3 text-base font-baloo font-bold shadow-clay-raised"
>
<
span
className=
"flex items-center justify-center gap-2"
>
<
CheckIcon
size=
{
18
}
/>
<
span
>
{
t
(
"transaction.calcDone"
)
||
"Xong"
}
</
span
>
</
span
>
</
Button
>
</
div
>
</
div
>
);
};
export
default
CalculatorModal
;
src/i18n/locales/en.json
View file @
130901b6
...
@@ -1001,7 +1001,7 @@
...
@@ -1001,7 +1001,7 @@
},
},
"filters"
:
{
"filters"
:
{
"title"
:
"Report scope"
,
"title"
:
"Report scope"
,
"hint"
:
"Filter by time
, wallet, or currency
"
,
"hint"
:
"Filter by time
or wallet
"
,
"reset"
:
"Reset"
,
"reset"
:
"Reset"
,
"dateFrom"
:
"From date"
,
"dateFrom"
:
"From date"
,
"dateTo"
:
"Through date"
,
"dateTo"
:
"Through date"
,
...
...
src/i18n/locales/vi.json
View file @
130901b6
...
@@ -1035,7 +1035,7 @@
...
@@ -1035,7 +1035,7 @@
},
},
"filters"
:
{
"filters"
:
{
"title"
:
"Phạm vi báo cáo"
,
"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"
,
"reset"
:
"Đặt lại"
,
"dateFrom"
:
"Từ ngày"
,
"dateFrom"
:
"Từ ngày"
,
"dateTo"
:
"Đến hế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";
...
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button";
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
CalculatorButton
,
CalculatorModal
}
from
"@/components/ui/CalculatorModal"
;
import
{
useEvaluateAnomaly
}
from
"@/hooks/use-anomalies"
;
import
{
useEvaluateAnomaly
}
from
"@/hooks/use-anomalies"
;
import
{
useCategoryTree
}
from
"@/hooks/use-categories"
;
import
{
useCategoryTree
}
from
"@/hooks/use-categories"
;
import
{
useWallets
}
from
"@/hooks/use-wallets"
;
import
{
useWallets
}
from
"@/hooks/use-wallets"
;
...
@@ -49,6 +50,7 @@ export const AnomalyChecker: React.FC = () => {
...
@@ -49,6 +50,7 @@ export const AnomalyChecker: React.FC = () => {
includeArchived
:
false
,
includeArchived
:
false
,
});
});
const
[
result
,
setResult
]
=
useState
<
AnomalyEvaluationResult
|
null
>
(
null
);
const
[
result
,
setResult
]
=
useState
<
AnomalyEvaluationResult
|
null
>
(
null
);
const
[
isCalculatorOpen
,
setIsCalculatorOpen
]
=
useState
(
false
);
const
schema
=
useMemo
(()
=>
createSchema
(
t
),
[
t
]);
const
schema
=
useMemo
(()
=>
createSchema
(
t
),
[
t
]);
const
categories
=
flattenCategories
(
categoriesQuery
.
data
?.
data
||
[]);
const
categories
=
flattenCategories
(
categoriesQuery
.
data
?.
data
||
[]);
const
wallets
=
walletsQuery
.
data
?.
data
||
[];
const
wallets
=
walletsQuery
.
data
?.
data
||
[];
...
@@ -56,6 +58,7 @@ export const AnomalyChecker: React.FC = () => {
...
@@ -56,6 +58,7 @@ export const AnomalyChecker: React.FC = () => {
control
,
control
,
handleSubmit
,
handleSubmit
,
register
,
register
,
setValue
,
watch
,
watch
,
formState
:
{
errors
},
formState
:
{
errors
},
}
=
useForm
<
AnomalyFormValues
>
({
}
=
useForm
<
AnomalyFormValues
>
({
...
@@ -143,22 +146,33 @@ export const AnomalyChecker: React.FC = () => {
...
@@ -143,22 +146,33 @@ export const AnomalyChecker: React.FC = () => {
</
p
>
</
p
>
)
}
)
}
<
Controller
<
div
className=
"flex items-end gap-2"
>
name=
"amount"
<
div
className=
"flex-1 min-w-0"
>
control=
{
control
}
<
Controller
render=
{
({
field
})
=>
(
name=
"amount"
<
Input
control=
{
control
}
label=
{
t
(
"anomalies.amountToTest"
)
}
render=
{
({
field
})
=>
(
inputMode=
"decimal"
<
Input
placeholder=
{
t
(
"anomalies.amountPlaceholder"
)
}
label=
{
t
(
"anomalies.amountToTest"
)
}
value=
{
formatMoneyInput
(
field
.
value
,
intlLocale
)
}
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
}
disabled=
{
optionsLoading
||
evaluateMutation
.
isPending
}
error=
{
errors
.
amount
?.
message
}
onBlur=
{
field
.
onBlur
}
onChange=
{
(
event
)
=>
field
.
onChange
(
parseMoneyInput
(
event
.
target
.
value
,
intlLocale
))
}
/>
/>
)
}
</
div
>
/
>
</
div
>
<
Button
<
Button
type=
"submit"
type=
"submit"
...
@@ -205,6 +219,18 @@ export const AnomalyChecker: React.FC = () => {
...
@@ -205,6 +219,18 @@ export const AnomalyChecker: React.FC = () => {
</
div
>
</
div
>
</
div
>
</
div
>
)
}
)
}
<
CalculatorModal
isOpen=
{
isCalculatorOpen
}
onClose=
{
()
=>
setIsCalculatorOpen
(
false
)
}
initialAmount=
{
watch
(
"amount"
)
}
onApply=
{
(
calculatedAmount
)
=>
{
setValue
(
"amount"
,
calculatedAmount
,
{
shouldValidate
:
true
,
shouldDirty
:
true
,
});
}
}
/>
</
Card
>
</
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
{
zodResolver
}
from
"@hookform/resolvers/zod"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
z
}
from
"zod"
;
import
{
z
}
from
"zod"
;
...
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/Input";
...
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/Input";
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
Slider
}
from
"@/components/ui/Slider"
;
import
{
Slider
}
from
"@/components/ui/Slider"
;
import
{
CalculatorButton
,
CalculatorModal
}
from
"@/components/ui/CalculatorModal"
;
import
{
useCategoryTree
}
from
"@/hooks/use-categories"
;
import
{
useCategoryTree
}
from
"@/hooks/use-categories"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
getCategoryDisplayName
}
from
"@/lib/category-format"
;
import
{
getCategoryDisplayName
}
from
"@/lib/category-format"
;
...
@@ -149,6 +150,7 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
...
@@ -149,6 +150,7 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
const
selectedPeriod
=
watch
(
"period"
)
as
BudgetPeriod
;
const
selectedPeriod
=
watch
(
"period"
)
as
BudgetPeriod
;
const
selectedStartDate
=
watch
(
"startDate"
);
const
selectedStartDate
=
watch
(
"startDate"
);
const
selectedEndDate
=
watch
(
"endDate"
);
const
selectedEndDate
=
watch
(
"endDate"
);
const
[
isCalculatorOpen
,
setIsCalculatorOpen
]
=
useState
(
false
);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
selectedType
===
"OVERALL"
)
{
if
(
selectedType
===
"OVERALL"
)
{
...
@@ -193,7 +195,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
...
@@ -193,7 +195,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
const
formId
=
budget
?
`edit-budget-
${
budget
.
id
}
`
:
"create-budget"
;
const
formId
=
budget
?
`edit-budget-
${
budget
.
id
}
`
:
"create-budget"
;
return
(
return
(
<
Modal
<>
<
Modal
isOpen=
{
isOpen
}
isOpen=
{
isOpen
}
onClose=
{
onClose
}
onClose=
{
onClose
}
title=
{
budget
?
t
(
"budget.form.editTitle"
)
:
t
(
"budget.form.createTitle"
)
}
title=
{
budget
?
t
(
"budget.form.editTitle"
)
:
t
(
"budget.form.createTitle"
)
}
...
@@ -215,38 +218,49 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
...
@@ -215,38 +218,49 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
{
...
register
("
name
")}
{
...
register
("
name
")}
/>
/>
<
div
className=
"grid grid-cols-[minmax(0,1fr)_110px] gap-3"
>
<
div
className=
"flex items-end gap-2"
>
<
Controller
<
div
className=
"flex-1 min-w-0"
>
name=
"amount"
<
Controller
control=
{
control
}
name=
"amount"
render=
{
({
field
})
=>
(
control=
{
control
}
<
Input
render=
{
({
field
})
=>
(
label=
{
t
(
"budget.form.amount"
)
}
<
Input
inputMode=
"decimal"
label=
{
t
(
"budget.form.amount"
)
}
placeholder=
{
t
(
"budget.form.amountPlaceholder"
)
}
inputMode=
"decimal"
value=
{
formatAmountInput
(
field
.
value
,
intlLocale
)
}
placeholder=
{
t
(
"budget.form.amountPlaceholder"
)
}
onBlur=
{
field
.
onBlur
}
value=
{
formatAmountInput
(
field
.
value
,
intlLocale
)
}
onChange=
{
(
event
)
=>
field
.
onChange
(
parseAmountInput
(
event
.
target
.
value
,
intlLocale
))
}
onBlur=
{
field
.
onBlur
}
error=
{
errors
.
amount
?.
message
}
onChange=
{
(
event
)
=>
field
.
onChange
(
parseAmountInput
(
event
.
target
.
value
,
intlLocale
))
}
/>
error=
{
errors
.
amount
?.
message
}
)
}
/>
/>
)
}
<
Controller
/>
name=
"currency"
</
div
>
control=
{
control
}
<
div
className=
"w-[110px] shrink-0"
>
render=
{
({
field
})
=>
(
<
Controller
<
Input
name=
"currency"
label=
{
t
(
"budget.form.currency"
)
}
control=
{
control
}
value=
{
field
.
value
}
render=
{
({
field
})
=>
(
maxLength=
{
3
}
<
Input
autoCapitalize=
"characters"
label=
{
t
(
"budget.form.currency"
)
}
placeholder=
"VND"
value=
{
field
.
value
}
error=
{
errors
.
currency
?.
message
}
maxLength=
{
3
}
onBlur=
{
field
.
onBlur
}
autoCapitalize=
"characters"
onChange=
{
(
event
)
=>
field
.
onChange
(
event
.
target
.
value
.
replace
(
/
[^
A-Za-z
]
/g
,
""
).
toUpperCase
())
}
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
>
<
div
className=
"grid grid-cols-1 gap-3 sm:grid-cols-2"
>
<
div
className=
"grid grid-cols-1 gap-3 sm:grid-cols-2"
>
...
@@ -322,5 +336,18 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
...
@@ -322,5 +336,18 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
/>
/>
</
form
>
</
form
>
</
Modal
>
</
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
{
zodResolver
}
from
"@hookform/resolvers/zod"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
z
}
from
"zod"
;
import
{
z
}
from
"zod"
;
...
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
...
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
CalculatorButton
,
CalculatorModal
}
from
"@/components/ui/CalculatorModal"
;
import
{
LocalizedDateInput
}
from
"@/components/shared/LocalizedDateInput"
;
import
{
LocalizedDateInput
}
from
"@/components/shared/LocalizedDateInput"
;
import
{
useI18n
,
TranslationFunction
}
from
"@/i18n"
;
import
{
useI18n
,
TranslationFunction
}
from
"@/i18n"
;
import
{
useWallets
}
from
"@/hooks/use-wallets"
;
import
{
useWallets
}
from
"@/hooks/use-wallets"
;
...
@@ -126,6 +127,7 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
...
@@ -126,6 +127,7 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
const
anchorDate
=
watch
(
"anchorDate"
);
const
anchorDate
=
watch
(
"anchorDate"
);
const
endDate
=
watch
(
"endDate"
);
const
endDate
=
watch
(
"endDate"
);
const
formId
=
schedule
?
`edit-recurring-
${
schedule
.
id
}
`
:
"create-recurring"
;
const
formId
=
schedule
?
`edit-recurring-
${
schedule
.
id
}
`
:
"create-recurring"
;
const
[
isCalculatorOpen
,
setIsCalculatorOpen
]
=
useState
(
false
);
useEffect
(()
=>
{
if
(
isOpen
)
reset
(
defaultValues
);
},
[
defaultValues
,
isOpen
,
reset
]);
useEffect
(()
=>
{
if
(
isOpen
)
reset
(
defaultValues
);
},
[
defaultValues
,
isOpen
,
reset
]);
...
@@ -161,7 +163,8 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
...
@@ -161,7 +163,8 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
},
[
categoryId
,
categoryOptions
,
setValue
]);
},
[
categoryId
,
categoryOptions
,
setValue
]);
return
(
return
(
<
Modal
<>
<
Modal
isOpen=
{
isOpen
}
isOpen=
{
isOpen
}
onClose=
{
onClose
}
onClose=
{
onClose
}
title=
{
schedule
?
t
(
"recurringTransactions.editTitle"
)
:
t
(
"recurringTransactions.createTitle"
)
}
title=
{
schedule
?
t
(
"recurringTransactions.editTitle"
)
:
t
(
"recurringTransactions.createTitle"
)
}
...
@@ -184,14 +187,47 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
...
@@ -184,14 +187,47 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
missedRunPolicy
:
values
.
missedRunPolicy
,
missedRunPolicy
:
values
.
missedRunPolicy
,
...(
schedule
?
{}
:
{
isActive
:
true
}),
...(
schedule
?
{}
:
{
isActive
:
true
}),
}))
}
>
}))
}
>
<
div
className=
"grid grid-cols-2 gap-3"
>
<
div
className=
"flex items-end gap-2"
>
<
Select
label=
{
t
(
"recurringTransactions.form.type"
)
}
options=
{
[
<
div
className=
"flex-[1] min-w-0"
>
{
value
:
"EXPENSE"
,
label
:
t
(
"transaction.typeExpense"
)
},
<
Select
{
value
:
"INCOME"
,
label
:
t
(
"transaction.typeIncome"
)
},
label=
{
t
(
"recurringTransactions.form.type"
)
}
]
}
disabled=
{
isSubmitting
}
{
...
register
("
type
")}
/>
options=
{
[
<
Controller
name=
"amount"
control=
{
control
}
render=
{
({
field
})
=>
(
{
value
:
"EXPENSE"
,
label
:
t
(
"transaction.typeExpense"
)
},
<
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
))
}
/>
{
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
>
<
div
className=
"grid grid-cols-2 gap-3"
>
<
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
")}
/>
<
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> = ({
...
@@ -216,5 +252,18 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
</
p
>
</
p
>
</
form
>
</
form
>
</
Modal
>
</
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 {
...
@@ -13,7 +13,6 @@ interface ReportFiltersProps {
dateFrom
:
string
;
dateFrom
:
string
;
dateTo
:
string
;
dateTo
:
string
;
walletId
:
string
;
walletId
:
string
;
currency
:
string
;
wallets
:
Wallet
[];
wallets
:
Wallet
[];
isLoadingWallets
:
boolean
;
isLoadingWallets
:
boolean
;
dateError
?:
string
;
dateError
?:
string
;
...
@@ -21,7 +20,6 @@ interface ReportFiltersProps {
...
@@ -21,7 +20,6 @@ interface ReportFiltersProps {
onDateFromChange
:
(
value
:
string
)
=>
void
;
onDateFromChange
:
(
value
:
string
)
=>
void
;
onDateToChange
:
(
value
:
string
)
=>
void
;
onDateToChange
:
(
value
:
string
)
=>
void
;
onWalletChange
:
(
value
:
string
)
=>
void
;
onWalletChange
:
(
value
:
string
)
=>
void
;
onCurrencyChange
:
(
value
:
string
)
=>
void
;
onReset
:
()
=>
void
;
onReset
:
()
=>
void
;
}
}
...
@@ -30,7 +28,6 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
...
@@ -30,7 +28,6 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
dateFrom
,
dateFrom
,
dateTo
,
dateTo
,
walletId
,
walletId
,
currency
,
wallets
,
wallets
,
isLoadingWallets
,
isLoadingWallets
,
dateError
,
dateError
,
...
@@ -38,11 +35,9 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
...
@@ -38,11 +35,9 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
onDateFromChange
,
onDateFromChange
,
onDateToChange
,
onDateToChange
,
onWalletChange
,
onWalletChange
,
onCurrencyChange
,
onReset
,
onReset
,
})
=>
{
})
=>
{
const
{
t
}
=
useI18n
();
const
{
t
}
=
useI18n
();
const
currencies
=
Array
.
from
(
new
Set
(
wallets
.
map
((
wallet
)
=>
wallet
.
currency
))).
sort
();
return
(
return
(
<
Card
className=
"flex flex-col gap-4 p-4"
>
<
Card
className=
"flex flex-col gap-4 p-4"
>
...
@@ -51,7 +46,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
...
@@ -51,7 +46,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
<
h2
className=
"clay-title-h3"
>
{
t
(
"report.filters.title"
)
}
</
h2
>
<
h2
className=
"clay-title-h3"
>
{
t
(
"report.filters.title"
)
}
</
h2
>
<
p
className=
"clay-caption"
>
{
t
(
"report.filters.hint"
)
}
</
p
>
<
p
className=
"clay-caption"
>
{
t
(
"report.filters.hint"
)
}
</
p
>
</
div
>
</
div
>
{
(
period
!==
"MONTH"
||
walletId
||
currency
)
&&
(
{
(
period
!==
"MONTH"
||
walletId
)
&&
(
<
Button
variant=
"ghost"
className=
"shrink-0 px-3 text-sm"
onClick=
{
onReset
}
>
<
Button
variant=
"ghost"
className=
"shrink-0 px-3 text-sm"
onClick=
{
onReset
}
>
{
t
(
"report.filters.reset"
)
}
{
t
(
"report.filters.reset"
)
}
</
Button
>
</
Button
>
...
@@ -87,7 +82,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
...
@@ -87,7 +82,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
</
div
>
</
div
>
)
}
)
}
<
div
className=
"grid grid-cols-1 gap-3 sm:grid-cols-2"
>
<
div
>
<
Select
<
Select
label=
{
t
(
"report.filters.wallet"
)
}
label=
{
t
(
"report.filters.wallet"
)
}
value=
{
walletId
}
value=
{
walletId
}
...
@@ -98,16 +93,6 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
...
@@ -98,16 +93,6 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
...
wallets
.
map
((
wallet
)
=>
({
value
:
wallet
.
id
,
label
:
`${wallet.name} · ${wallet.currency}`
})),
...
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
>
</
div
>
</
Card
>
</
Card
>
);
);
...
...
src/pages/reports/index.tsx
View file @
130901b6
...
@@ -51,7 +51,6 @@ const ReportsPage: React.FC = () => {
...
@@ -51,7 +51,6 @@ const ReportsPage: React.FC = () => {
const
[
dateFrom
,
setDateFrom
]
=
useState
(
initialDates
.
from
);
const
[
dateFrom
,
setDateFrom
]
=
useState
(
initialDates
.
from
);
const
[
dateTo
,
setDateTo
]
=
useState
(
initialDates
.
to
);
const
[
dateTo
,
setDateTo
]
=
useState
(
initialDates
.
to
);
const
[
walletId
,
setWalletId
]
=
useState
(
""
);
const
[
walletId
,
setWalletId
]
=
useState
(
""
);
const
[
currency
,
setCurrency
]
=
useState
(
""
);
const
[
activeCurrency
,
setActiveCurrency
]
=
useState
(
""
);
const
[
activeCurrency
,
setActiveCurrency
]
=
useState
(
""
);
const
walletsQuery
=
useWalletSearch
({
includeArchived
:
false
,
sortBy
:
"name"
,
order
:
"asc"
},
true
);
const
walletsQuery
=
useWalletSearch
({
includeArchived
:
false
,
sortBy
:
"name"
,
order
:
"asc"
},
true
);
...
@@ -71,9 +70,8 @@ const ReportsPage: React.FC = () => {
...
@@ -71,9 +70,8 @@ const ReportsPage: React.FC = () => {
dateTo
:
toInclusiveBoundary
(
dateTo
,
true
),
dateTo
:
toInclusiveBoundary
(
dateTo
,
true
),
}
:
{}),
}
:
{}),
...(
walletId
?
{
walletId
}
:
{}),
...(
walletId
?
{
walletId
}
:
{}),
...(
!
walletId
&&
currency
?
{
currency
}
:
{}),
granularity
:
"AUTO"
,
granularity
:
"AUTO"
,
}),
[
cu
rrency
,
cu
stomDateOrderValid
,
dateFrom
,
dateTo
,
period
,
walletId
]);
}),
[
customDateOrderValid
,
dateFrom
,
dateTo
,
period
,
walletId
]);
const
overviewQuery
=
useReportOverview
(
reportQuery
,
queryEnabled
);
const
overviewQuery
=
useReportOverview
(
reportQuery
,
queryEnabled
);
const
cashFlowQuery
=
useCashFlowReport
(
reportQuery
,
queryEnabled
);
const
cashFlowQuery
=
useCashFlowReport
(
reportQuery
,
queryEnabled
);
...
@@ -90,10 +88,6 @@ const ReportsPage: React.FC = () => {
...
@@ -90,10 +88,6 @@ const ReportsPage: React.FC = () => {
},
[
overview
]);
},
[
overview
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
currency
&&
availableCurrencies
.
includes
(
currency
))
{
setActiveCurrency
(
currency
);
return
;
}
if
(
selectedWallet
&&
availableCurrencies
.
includes
(
selectedWallet
.
currency
))
{
if
(
selectedWallet
&&
availableCurrencies
.
includes
(
selectedWallet
.
currency
))
{
setActiveCurrency
(
selectedWallet
.
currency
);
setActiveCurrency
(
selectedWallet
.
currency
);
return
;
return
;
...
@@ -101,7 +95,7 @@ const ReportsPage: React.FC = () => {
...
@@ -101,7 +95,7 @@ const ReportsPage: React.FC = () => {
if
(
activeCurrency
&&
availableCurrencies
.
includes
(
activeCurrency
))
return
;
if
(
activeCurrency
&&
availableCurrencies
.
includes
(
activeCurrency
))
return
;
const
defaultCurrency
=
wallets
.
find
((
wallet
)
=>
wallet
.
isDefault
)?.
currency
;
const
defaultCurrency
=
wallets
.
find
((
wallet
)
=>
wallet
.
isDefault
)?.
currency
;
setActiveCurrency
((
defaultCurrency
&&
availableCurrencies
.
includes
(
defaultCurrency
))
?
defaultCurrency
:
(
availableCurrencies
[
0
]
||
""
));
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
metric
=
overview
?.
metricsByCurrency
.
find
((
item
)
=>
item
.
currency
===
activeCurrency
);
const
currencyWallets
=
overview
?.
wallets
.
items
.
filter
((
wallet
)
=>
wallet
.
currency
===
activeCurrency
)
||
[];
const
currencyWallets
=
overview
?.
wallets
.
items
.
filter
((
wallet
)
=>
wallet
.
currency
===
activeCurrency
)
||
[];
...
@@ -115,7 +109,6 @@ const ReportsPage: React.FC = () => {
...
@@ -115,7 +109,6 @@ const ReportsPage: React.FC = () => {
setWalletId
(
value
);
setWalletId
(
value
);
const
wallet
=
wallets
.
find
((
item
)
=>
item
.
id
===
value
);
const
wallet
=
wallets
.
find
((
item
)
=>
item
.
id
===
value
);
if
(
wallet
)
{
if
(
wallet
)
{
setCurrency
(
""
);
setActiveCurrency
(
wallet
.
currency
);
setActiveCurrency
(
wallet
.
currency
);
}
}
};
};
...
@@ -125,7 +118,6 @@ const ReportsPage: React.FC = () => {
...
@@ -125,7 +118,6 @@ const ReportsPage: React.FC = () => {
setDateFrom
(
initialDates
.
from
);
setDateFrom
(
initialDates
.
from
);
setDateTo
(
initialDates
.
to
);
setDateTo
(
initialDates
.
to
);
setWalletId
(
""
);
setWalletId
(
""
);
setCurrency
(
""
);
};
};
const
retryAll
=
()
=>
{
const
retryAll
=
()
=>
{
...
@@ -176,7 +168,6 @@ const ReportsPage: React.FC = () => {
...
@@ -176,7 +168,6 @@ const ReportsPage: React.FC = () => {
dateFrom=
{
dateFrom
}
dateFrom=
{
dateFrom
}
dateTo=
{
dateTo
}
dateTo=
{
dateTo
}
walletId=
{
walletId
}
walletId=
{
walletId
}
currency=
{
currency
}
wallets=
{
wallets
}
wallets=
{
wallets
}
isLoadingWallets=
{
walletsQuery
.
isLoading
}
isLoadingWallets=
{
walletsQuery
.
isLoading
}
dateError=
{
dateError
}
dateError=
{
dateError
}
...
@@ -184,11 +175,10 @@ const ReportsPage: React.FC = () => {
...
@@ -184,11 +175,10 @@ const ReportsPage: React.FC = () => {
onDateFromChange=
{
setDateFrom
}
onDateFromChange=
{
setDateFrom
}
onDateToChange=
{
setDateTo
}
onDateToChange=
{
setDateTo
}
onWalletChange=
{
handleWalletChange
}
onWalletChange=
{
handleWalletChange
}
onCurrencyChange=
{
(
value
)
=>
{
setCurrency
(
value
);
setActiveCurrency
(
value
);
}
}
onReset=
{
resetFilters
}
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"
)
}
>
<
div
className=
"flex gap-2 overflow-x-auto px-1 pb-2"
role=
"tablist"
aria
-
label=
{
t
(
"report.currencyTabs"
)
}
>
{
availableCurrencies
.
map
((
code
)
=>
(
{
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
)
}
>
<
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
{
zodResolver
}
from
"@hookform/resolvers/zod"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
z
}
from
"zod"
;
import
{
z
}
from
"zod"
;
...
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
...
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import
{
LocalizedDateInput
}
from
"@/components/shared/LocalizedDateInput"
;
import
{
LocalizedDateInput
}
from
"@/components/shared/LocalizedDateInput"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
CalculatorButton
,
CalculatorModal
}
from
"@/components/ui/CalculatorModal"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
formatMoneyInput
,
parseMoneyInput
,
positiveAmountPattern
,
toLocalDateTime
}
from
"@/lib/money-input"
;
import
{
formatMoneyInput
,
parseMoneyInput
,
positiveAmountPattern
,
toLocalDateTime
}
from
"@/lib/money-input"
;
import
{
SavingContribution
,
SavingContributionInput
}
from
"@/types/saving-goal"
;
import
{
SavingContribution
,
SavingContributionInput
}
from
"@/types/saving-goal"
;
...
@@ -51,7 +52,7 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
...
@@ -51,7 +52,7 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
const
schema
=
useMemo
(()
=>
createSchema
(
t
),
[
t
]);
const
schema
=
useMemo
(()
=>
createSchema
(
t
),
[
t
]);
const
defaultValues
=
useMemo
(()
=>
getDefaultValues
(
contribution
),
[
contribution
]);
const
defaultValues
=
useMemo
(()
=>
getDefaultValues
(
contribution
),
[
contribution
]);
const
formId
=
contribution
?
`edit-contribution-
${
contribution
.
id
}
`
:
"create-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
),
resolver
:
zodResolver
(
schema
),
defaultValues
,
defaultValues
,
});
});
...
@@ -61,70 +62,96 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
...
@@ -61,70 +62,96 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
},
[
defaultValues
,
isOpen
,
reset
]);
},
[
defaultValues
,
isOpen
,
reset
]);
const
contributedAt
=
watch
(
"contributedAt"
);
const
contributedAt
=
watch
(
"contributedAt"
);
const
[
isCalculatorOpen
,
setIsCalculatorOpen
]
=
useState
(
false
);
return
(
return
(
<
Modal
<>
isOpen=
{
isOpen
}
<
Modal
onClose=
{
onClose
}
isOpen=
{
isOpen
}
title=
{
contribution
?
t
(
"savingGoal.contribution.editTitle"
)
:
t
(
"savingGoal.contribution.createTitle"
)
}
onClose=
{
onClose
}
footer=
{
(
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
}
>
<
Button
type=
"button"
variant=
"ghost"
className=
"px-4 text-sm"
disabled=
{
isSubmitting
}
onClick=
{
onClose
}
>
{
t
(
"common.cancel"
)
}
</
Button
>
{
isSubmitting
?
t
(
"common.saving"
)
:
contribution
?
t
(
"common.save"
)
:
t
(
"savingGoal.contribution.add"
)
}
<
Button
type=
"submit"
form=
{
formId
}
className=
"px-4 text-sm"
disabled=
{
isSubmitting
}
>
</
Button
>
{
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
>
>
<
Controller
<
form
name=
"amount"
id=
{
formId
}
control=
{
control
}
className=
"flex flex-col gap-4"
render=
{
({
field
})
=>
(
onSubmit=
{
handleSubmit
((
values
)
=>
onSubmit
({
<
Input
amount
:
values
.
amount
,
label=
{
t
(
"savingGoal.contribution.amount"
,
{
currency
})
}
contributedAt
:
businessWallTimeToIso
(
values
.
contributedAt
),
inputMode=
"decimal"
note
:
values
.
note
.
trim
()
||
null
,
value=
{
formatMoneyInput
(
field
.
value
,
intlLocale
)
}
}))
}
placeholder=
{
t
(
"savingGoal.contribution.amountPlaceholder"
)
}
noValidate
disabled=
{
isSubmitting
}
>
error=
{
errors
.
amount
?.
message
}
<
div
className=
"flex items-end gap-2"
>
onBlur=
{
field
.
onBlur
}
<
div
className=
"flex-1 min-w-0"
>
onChange=
{
(
event
)
=>
field
.
onChange
(
parseMoneyInput
(
event
.
target
.
value
,
intlLocale
))
}
<
Controller
/>
name=
"amount"
)
}
control=
{
control
}
/>
render=
{
({
field
})
=>
(
<
LocalizedDateInput
<
Input
type=
"datetime-local"
label=
{
t
(
"savingGoal.contribution.amount"
,
{
currency
})
}
value=
{
contributedAt
}
inputMode=
"decimal"
max=
{
toLocalDateTime
()
}
value=
{
formatMoneyInput
(
field
.
value
,
intlLocale
)
}
label=
{
t
(
"savingGoal.contribution.date"
)
}
placeholder=
{
t
(
"savingGoal.contribution.amountPlaceholder"
)
}
disabled=
{
isSubmitting
}
disabled=
{
isSubmitting
}
error=
{
errors
.
contributedAt
?.
message
}
error=
{
errors
.
amount
?.
message
}
{
...
register
("
contributedAt
")}
onBlur=
{
field
.
onBlur
}
/>
onChange=
{
(
event
)
=>
field
.
onChange
(
parseMoneyInput
(
event
.
target
.
value
,
intlLocale
))
}
<
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`
}
</
div
>
rows=
{
3
}
<
div
className=
"flex flex-col items-center justify-end pb-0.5"
>
maxLength=
{
500
}
<
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
}
disabled=
{
isSubmitting
}
placeholder=
{
t
(
"savingGoal.contribution.notePlaceholder"
)
}
error=
{
errors
.
contributedAt
?.
message
}
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
("
contributedAt
")}
{
...
register
("
note
")}
/>
/>
{
errors
.
note
?.
message
&&
<
span
className=
"px-1 font-nunito text-xs text-clay-expense"
>
{
errors
.
note
.
message
}
</
span
>
}
<
div
className=
"flex flex-col gap-2"
>
</
div
>
<
label
htmlFor=
{
`${formId}-note`
}
className=
"px-1 font-nunito text-sm font-semibold text-clay-text"
>
{
t
(
"savingGoal.contribution.note"
)
}
</
label
>
</
form
>
<
textarea
</
Modal
>
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
{
zodResolver
}
from
"@hookform/resolvers/zod"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
z
}
from
"zod"
;
import
{
z
}
from
"zod"
;
...
@@ -7,6 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
...
@@ -7,6 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
CalculatorButton
,
CalculatorModal
}
from
"@/components/ui/CalculatorModal"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
formatMoneyInput
,
parseMoneyInput
,
positiveAmountPattern
,
toLocalDate
}
from
"@/lib/money-input"
;
import
{
formatMoneyInput
,
parseMoneyInput
,
positiveAmountPattern
,
toLocalDate
}
from
"@/lib/money-input"
;
import
{
CreateSavingGoalInput
,
SavingGoal
}
from
"@/types/saving-goal"
;
import
{
CreateSavingGoalInput
,
SavingGoal
}
from
"@/types/saving-goal"
;
...
@@ -84,6 +85,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
...
@@ -84,6 +85,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
const
selectedColor
=
watch
(
"color"
);
const
selectedColor
=
watch
(
"color"
);
const
selectedTargetDate
=
watch
(
"targetDate"
);
const
selectedTargetDate
=
watch
(
"targetDate"
);
const
currencyLocked
=
Boolean
(
goal
&&
goal
.
progress
.
contributionCount
>
0
);
const
currencyLocked
=
Boolean
(
goal
&&
goal
.
progress
.
contributionCount
>
0
);
const
[
isCalculatorOpen
,
setIsCalculatorOpen
]
=
useState
(
false
);
const
submitForm
=
(
values
:
SavingGoalFormValues
)
=>
{
const
submitForm
=
(
values
:
SavingGoalFormValues
)
=>
{
onSubmit
({
onSubmit
({
...
@@ -98,7 +100,8 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
...
@@ -98,7 +100,8 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
};
};
return
(
return
(
<
Modal
<>
<
Modal
isOpen=
{
isOpen
}
isOpen=
{
isOpen
}
onClose=
{
onClose
}
onClose=
{
onClose
}
title=
{
goal
?
t
(
"savingGoal.form.editTitle"
)
:
t
(
"savingGoal.form.createTitle"
)
}
title=
{
goal
?
t
(
"savingGoal.form.editTitle"
)
:
t
(
"savingGoal.form.createTitle"
)
}
...
@@ -122,39 +125,50 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
...
@@ -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
")}
/>
<
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"
>
<
div
className=
"flex items-end gap-2"
>
<
Controller
<
div
className=
"flex-1 min-w-0"
>
name=
"targetAmount"
<
Controller
control=
{
control
}
name=
"targetAmount"
render=
{
({
field
})
=>
(
control=
{
control
}
<
Input
render=
{
({
field
})
=>
(
label=
{
t
(
"savingGoal.form.targetAmount"
)
}
<
Input
inputMode=
"decimal"
label=
{
t
(
"savingGoal.form.targetAmount"
)
}
placeholder=
{
t
(
"savingGoal.form.amountPlaceholder"
)
}
inputMode=
"decimal"
value=
{
formatMoneyInput
(
field
.
value
,
intlLocale
)
}
placeholder=
{
t
(
"savingGoal.form.amountPlaceholder"
)
}
disabled=
{
isSubmitting
}
value=
{
formatMoneyInput
(
field
.
value
,
intlLocale
)
}
error=
{
errors
.
targetAmount
?.
message
}
disabled=
{
isSubmitting
}
onBlur=
{
field
.
onBlur
}
error=
{
errors
.
targetAmount
?.
message
}
onChange=
{
(
event
)
=>
field
.
onChange
(
parseMoneyInput
(
event
.
target
.
value
,
intlLocale
))
}
onBlur=
{
field
.
onBlur
}
/>
onChange=
{
(
event
)
=>
field
.
onChange
(
parseMoneyInput
(
event
.
target
.
value
,
intlLocale
))
}
)
}
/>
/>
)
}
<
Controller
/>
name=
"currency"
</
div
>
control=
{
control
}
<
div
className=
"w-[105px] shrink-0"
>
render=
{
({
field
})
=>
(
<
Controller
<
Input
name=
"currency"
label=
{
t
(
"savingGoal.form.currency"
)
}
control=
{
control
}
value=
{
field
.
value
}
render=
{
({
field
})
=>
(
maxLength=
{
3
}
<
Input
autoCapitalize=
"characters"
label=
{
t
(
"savingGoal.form.currency"
)
}
disabled=
{
isSubmitting
||
currencyLocked
}
value=
{
field
.
value
}
error=
{
errors
.
currency
?.
message
}
maxLength=
{
3
}
onBlur=
{
field
.
onBlur
}
autoCapitalize=
"characters"
onChange=
{
(
event
)
=>
field
.
onChange
(
event
.
target
.
value
.
replace
(
/
[^
A-Za-z
]
/g
,
""
).
toUpperCase
())
}
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
>
</
div
>
{
currencyLocked
&&
<
p
className=
"-mt-2 px-1 clay-caption"
>
{
t
(
"savingGoal.form.currencyLocked"
)
}
</
p
>
}
{
currencyLocked
&&
<
p
className=
"-mt-2 px-1 clay-caption"
>
{
t
(
"savingGoal.form.currencyLocked"
)
}
</
p
>
}
...
@@ -212,5 +226,18 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
...
@@ -212,5 +226,18 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
</
fieldset
>
</
fieldset
>
</
form
>
</
form
>
</
Modal
>
</
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
{
zodResolver
}
from
"@hookform/resolvers/zod"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
z
}
from
"zod"
;
import
{
z
}
from
"zod"
;
...
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
...
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Slider
}
from
"@/components/ui/Slider"
;
import
{
Slider
}
from
"@/components/ui/Slider"
;
import
{
CalculatorButton
,
CalculatorModal
}
from
"@/components/ui/CalculatorModal"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
formatMoneyInput
,
parseMoneyInput
,
positiveAmountPattern
}
from
"@/lib/money-input"
;
import
{
formatMoneyInput
,
parseMoneyInput
,
positiveAmountPattern
}
from
"@/lib/money-input"
;
import
{
Perturbation
}
from
"@/types/simulation"
;
import
{
Perturbation
}
from
"@/types/simulation"
;
...
@@ -53,6 +54,7 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
...
@@ -53,6 +54,7 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
defaultValues
:
{
type
:
"RECURRING_EXPENSE"
,
name
:
""
,
amount
:
""
},
defaultValues
:
{
type
:
"RECURRING_EXPENSE"
,
name
:
""
,
amount
:
""
},
});
});
const
adjustmentType
=
watch
(
"type"
);
const
adjustmentType
=
watch
(
"type"
);
const
[
isCalculatorOpen
,
setIsCalculatorOpen
]
=
useState
(
false
);
const
submitPerturbation
=
(
values
:
CustomPerturbationValues
)
=>
{
const
submitPerturbation
=
(
values
:
CustomPerturbationValues
)
=>
{
onAddPerturbation
({
onAddPerturbation
({
...
@@ -172,22 +174,33 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
...
@@ -172,22 +174,33 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
/>
/>
)
}
)
}
/>
/>
<
Controller
<
div
className=
"flex items-end gap-2"
>
name=
"amount"
<
div
className=
"flex-1 min-w-0"
>
control=
{
control
}
<
Controller
render=
{
({
field
})
=>
(
name=
"amount"
<
Input
control=
{
control
}
label=
{
t
(
"simulations.adjustmentAmount"
)
}
render=
{
({
field
})
=>
(
inputMode=
"decimal"
<
Input
placeholder=
{
t
(
"simulations.amountPlaceholder"
)
}
label=
{
t
(
"simulations.adjustmentAmount"
)
}
value=
{
formatMoneyInput
(
field
.
value
,
intlLocale
)
}
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
}
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"
>
<
Button
type=
"submit"
variant=
"secondary"
fullWidth
disabled=
{
isLoading
}
className=
"py-2 text-sm"
>
{
t
(
"simulations.addAdjustmentBtn"
)
}
{
t
(
"simulations.addAdjustmentBtn"
)
}
</
Button
>
</
Button
>
...
@@ -203,6 +216,18 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
...
@@ -203,6 +216,18 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
>
>
{
isLoading
?
t
(
"common.processing"
)
:
t
(
"simulations.runSimulationBtn"
)
}
{
isLoading
?
t
(
"common.processing"
)
:
t
(
"simulations.runSimulationBtn"
)
}
</
Button
>
</
Button
>
<
CalculatorModal
isOpen=
{
isCalculatorOpen
}
onClose=
{
()
=>
setIsCalculatorOpen
(
false
)
}
initialAmount=
{
watch
(
"amount"
)
}
onApply=
{
(
calculatedAmount
)
=>
{
setValue
(
"amount"
,
calculatedAmount
,
{
shouldValidate
:
true
,
shouldDirty
:
true
,
});
}
}
/>
</
Card
>
</
Card
>
);
);
};
};
src/pages/style-guide.tsx
View file @
130901b6
...
@@ -229,7 +229,7 @@ const StyleGuidePage: React.FC = () => {
...
@@ -229,7 +229,7 @@ const StyleGuidePage: React.FC = () => {
label=
{
t
(
"styleGuide.amount"
)
}
label=
{
t
(
"styleGuide.amount"
)
}
placeholder=
{
t
(
"styleGuide.amountPlaceholder"
)
}
placeholder=
{
t
(
"styleGuide.amountPlaceholder"
)
}
inputMode=
"decimal"
inputMode=
"decimal"
className=
"
text-right
font-semibold tabular-nums"
className=
"font-semibold tabular-nums"
value=
{
formatAmountInput
(
amountValue
,
intlLocale
)
}
value=
{
formatAmountInput
(
amountValue
,
intlLocale
)
}
onChange=
{
(
event
)
=>
setAmountValue
(
parseAmountInput
(
event
.
target
.
value
,
intlLocale
))
}
onChange=
{
(
event
)
=>
setAmountValue
(
parseAmountInput
(
event
.
target
.
value
,
intlLocale
))
}
/>
/>
...
@@ -413,7 +413,7 @@ const StyleGuidePage: React.FC = () => {
...
@@ -413,7 +413,7 @@ const StyleGuidePage: React.FC = () => {
label=
{
t
(
"styleGuide.amount"
)
}
label=
{
t
(
"styleGuide.amount"
)
}
inputMode=
"decimal"
inputMode=
"decimal"
placeholder=
{
t
(
"styleGuide.amountPlaceholder"
)
}
placeholder=
{
t
(
"styleGuide.amountPlaceholder"
)
}
className=
"
text-right
font-semibold tabular-nums"
className=
"font-semibold tabular-nums"
value=
{
formatAmountInput
(
modalAmountValue
,
intlLocale
)
}
value=
{
formatAmountInput
(
modalAmountValue
,
intlLocale
)
}
onChange=
{
(
event
)
=>
setModalAmountValue
(
parseAmountInput
(
event
.
target
.
value
,
intlLocale
))
}
onChange=
{
(
event
)
=>
setModalAmountValue
(
parseAmountInput
(
event
.
target
.
value
,
intlLocale
))
}
/>
/>
...
...
src/pages/transactions/components/TransactionCalculatorModal.tsx
View file @
130901b6
import
React
,
{
useEffect
,
useState
}
from
"react"
;
export
*
from
"@/components/ui/CalculatorModal"
;
import
{
CalculatorIcon
,
CheckIcon
,
CloseIcon
}
from
"@/components/ui/icons"
;
export
{
CalculatorModal
as
default
}
from
"@/components/ui/CalculatorModal"
;
import
{
Button
}
from
"@/components/ui/Button"
;
export
{
CalculatorModal
as
TransactionCalculatorModal
}
from
"@/components/ui/CalculatorModal"
;
import
{
useI18n
}
from
"@/i18n"
;
export
type
{
CalculatorModalProps
as
TransactionCalculatorModalProps
}
from
"@/components/ui/CalculatorModal"
;
export
interface
TransactionCalculatorModalProps
{
isOpen
:
boolean
;
onClose
:
()
=>
void
;
onApply
:
(
amount
:
string
)
=>
void
;
initialAmount
?:
string
;
}
type
Operator
=
"+"
|
"-"
|
"*"
|
"/"
;
function
getNumberSeparators
(
locale
:
string
):
{
group
:
string
;
decimal
:
string
}
{
const
defaultGroup
=
locale
.
startsWith
(
"vi"
)
?
"."
:
","
;
const
defaultDecimal
=
locale
.
startsWith
(
"vi"
)
?
","
:
"."
;
try
{
const
formatter
=
new
Intl
.
NumberFormat
(
locale
);
if
(
typeof
formatter
.
formatToParts
===
"function"
)
{
const
parts
=
formatter
.
formatToParts
(
1234.5
);
const
group
=
parts
.
find
((
p
)
=>
p
.
type
===
"group"
)?.
value
;
const
decimal
=
parts
.
find
((
p
)
=>
p
.
type
===
"decimal"
)?.
value
;
if
(
group
&&
decimal
)
{
return
{
group
,
decimal
};
}
}
const
nonDigits
=
formatter
.
format
(
1234.5
).
match
(
/
[^\d]
/g
);
const
g
=
nonDigits
?.[
0
];
const
d
=
nonDigits
?.[
1
];
if
(
g
&&
d
)
{
return
{
group
:
g
,
decimal
:
d
};
}
}
catch
{
// Fallback if Intl is unavailable or fails
}
return
{
group
:
defaultGroup
,
decimal
:
defaultDecimal
,
};
}
function
formatDisplayValue
(
raw
:
string
,
locale
:
string
):
string
{
if
(
!
raw
)
return
"0"
;
const
[
intPart
,
decPart
]
=
raw
.
split
(
"."
);
const
{
group
,
decimal
}
=
getNumberSeparators
(
locale
);
const
formattedInt
=
(
intPart
||
"0"
).
replace
(
/
\B(?=(\d{3})
+
(?!\d))
/g
,
group
);
return
decPart
!==
undefined
?
`
${
formattedInt
}${
decimal
}${
decPart
}
`
:
formattedInt
;
}
function
opSymbol
(
op
:
Operator
):
string
{
switch
(
op
)
{
case
"+"
:
return
"+"
;
case
"-"
:
return
"−"
;
case
"*"
:
return
"×"
;
case
"/"
:
return
"÷"
;
}
}
export
const
TransactionCalculatorModal
:
React
.
FC
<
TransactionCalculatorModalProps
>
=
({
isOpen
,
onClose
,
onApply
,
initialAmount
=
""
,
})
=>
{
const
{
t
,
intlLocale
}
=
useI18n
();
const
[
display
,
setDisplay
]
=
useState
<
string
>
(
"0"
);
const
[
expression
,
setExpression
]
=
useState
<
string
>
(
""
);
const
[
prevValue
,
setPrevValue
]
=
useState
<
number
|
null
>
(
null
);
const
[
operator
,
setOperator
]
=
useState
<
Operator
|
null
>
(
null
);
const
[
waitingForOperand
,
setWaitingForOperand
]
=
useState
<
boolean
>
(
false
);
const
[
isCalculated
,
setIsCalculated
]
=
useState
<
boolean
>
(
false
);
// Initialize or reset when modal opens
useEffect
(()
=>
{
if
(
isOpen
)
{
const
sanitized
=
initialAmount
?
String
(
parseFloat
(
initialAmount
)
||
0
)
:
"0"
;
setDisplay
(
sanitized
===
"0"
?
"0"
:
sanitized
);
setExpression
(
""
);
setPrevValue
(
null
);
setOperator
(
null
);
setWaitingForOperand
(
false
);
setIsCalculated
(
false
);
}
},
[
isOpen
,
initialAmount
]);
if
(
!
isOpen
)
return
null
;
const
calculateResult
=
(
prev
:
number
,
current
:
number
,
op
:
Operator
):
number
=>
{
let
res
=
0
;
switch
(
op
)
{
case
"+"
:
res
=
prev
+
current
;
break
;
case
"-"
:
res
=
prev
-
current
;
break
;
case
"*"
:
res
=
prev
*
current
;
break
;
case
"/"
:
res
=
current
===
0
?
0
:
prev
/
current
;
break
;
}
// Round to 2 decimal places and ensure non-negative
const
rounded
=
Math
.
round
(
res
*
100
)
/
100
;
return
Math
.
max
(
0
,
rounded
);
};
const
handleDigit
=
(
digit
:
string
)
=>
{
if
(
isCalculated
)
{
setIsCalculated
(
false
);
if
(
digit
===
"0"
)
{
if
(
display
!==
"0"
)
{
const
current
=
parseFloat
(
display
)
||
0
;
const
nextVal
=
display
.
includes
(
"."
)
?
Math
.
round
(
current
*
10
*
100
)
/
100
:
display
+
"0"
;
const
nextStr
=
String
(
nextVal
);
if
(
nextStr
.
length
<=
14
)
{
setDisplay
(
nextStr
);
setExpression
(
""
);
}
}
return
;
}
// Digit 1-9: starts fresh number
setDisplay
(
digit
);
setExpression
(
""
);
return
;
}
if
(
waitingForOperand
)
{
setDisplay
(
digit
);
setWaitingForOperand
(
false
);
}
else
{
if
(
display
===
"0"
)
{
setDisplay
(
digit
);
}
else
if
(
display
.
length
<
14
)
{
setDisplay
(
display
+
digit
);
}
}
};
const
handleTripleZero
=
()
=>
{
if
(
isCalculated
)
{
setIsCalculated
(
false
);
if
(
display
!==
"0"
)
{
const
current
=
parseFloat
(
display
)
||
0
;
const
nextVal
=
display
.
includes
(
"."
)
?
Math
.
round
(
current
*
1000
*
100
)
/
100
:
display
+
"000"
;
const
nextStr
=
String
(
nextVal
);
if
(
nextStr
.
length
<=
14
)
{
setDisplay
(
nextStr
);
setExpression
(
""
);
}
}
return
;
}
if
(
waitingForOperand
)
{
setDisplay
(
"0"
);
setWaitingForOperand
(
false
);
}
else
{
if
(
display
!==
"0"
&&
display
.
length
<=
11
)
{
setDisplay
(
display
+
"000"
);
}
}
};
const
handleDecimal
=
()
=>
{
if
(
isCalculated
)
{
setIsCalculated
(
false
);
setDisplay
(
"0."
);
setExpression
(
""
);
return
;
}
if
(
waitingForOperand
)
{
setDisplay
(
"0."
);
setWaitingForOperand
(
false
);
}
else
if
(
!
display
.
includes
(
"."
))
{
setDisplay
(
display
+
"."
);
}
};
const
handleOperator
=
(
nextOp
:
Operator
)
=>
{
setIsCalculated
(
false
);
const
currentNum
=
parseFloat
(
display
)
||
0
;
if
(
prevValue
!==
null
&&
operator
&&
!
waitingForOperand
)
{
const
computed
=
calculateResult
(
prevValue
,
currentNum
,
operator
);
setPrevValue
(
computed
);
setDisplay
(
String
(
computed
));
setExpression
(
`
${
formatDisplayValue
(
String
(
computed
),
intlLocale
)}
${
opSymbol
(
nextOp
)}
`
);
}
else
{
setPrevValue
(
currentNum
);
setExpression
(
`
${
formatDisplayValue
(
display
,
intlLocale
)}
${
opSymbol
(
nextOp
)}
`
);
}
setOperator
(
nextOp
);
setWaitingForOperand
(
true
);
};
const
handleEquals
=
()
=>
{
if
(
prevValue
===
null
||
!
operator
)
return
;
const
currentNum
=
parseFloat
(
display
)
||
0
;
const
computed
=
calculateResult
(
prevValue
,
currentNum
,
operator
);
setExpression
(
`
${
formatDisplayValue
(
String
(
prevValue
),
intlLocale
)}
${
opSymbol
(
operator
)}
${
formatDisplayValue
(
display
,
intlLocale
)}
=`
);
setDisplay
(
String
(
computed
));
setPrevValue
(
null
);
setOperator
(
null
);
setWaitingForOperand
(
false
);
setIsCalculated
(
true
);
};
const
handleClear
=
()
=>
{
setDisplay
(
"0"
);
setExpression
(
""
);
setPrevValue
(
null
);
setOperator
(
null
);
setWaitingForOperand
(
false
);
setIsCalculated
(
false
);
};
const
handleBackspace
=
()
=>
{
if
(
isCalculated
)
{
setIsCalculated
(
false
);
setExpression
(
""
);
}
if
(
waitingForOperand
)
return
;
if
(
display
.
length
>
1
)
{
setDisplay
(
display
.
slice
(
0
,
-
1
));
}
else
{
setDisplay
(
"0"
);
}
};
const
handleDone
=
()
=>
{
let
finalNum
=
parseFloat
(
display
)
||
0
;
// If there's an uncompleted operation, calculate it
if
(
prevValue
!==
null
&&
operator
&&
!
waitingForOperand
)
{
finalNum
=
calculateResult
(
prevValue
,
finalNum
,
operator
);
}
finalNum
=
Math
.
max
(
0
,
finalNum
);
const
resultStr
=
Number
.
isInteger
(
finalNum
)
?
String
(
finalNum
)
:
String
(
Number
(
finalNum
.
toFixed
(
2
)));
onApply
(
resultStr
);
onClose
();
};
return
(
<
div
className=
"fixed inset-0 z-[1050] flex items-end justify-center p-0 sm:items-center sm:p-4"
>
{
/* Backdrop */
}
<
div
className=
"absolute inset-0 bg-clay-overlay/60 backdrop-blur-[6px] transition-opacity"
onClick=
{
onClose
}
/>
{
/* Calculator Container */
}
<
div
className=
"relative w-full max-w-xs bg-clay-surface rounded-t-clay-lg sm:rounded-clay-lg shadow-clay-modal border-t border-x sm:border border-clay-highlight/60 p-5 flex flex-col gap-3.5 select-none animate-modal-content-in"
>
{
/* Header */
}
<
div
className=
"flex items-center justify-between pb-1 border-b border-clay-text-muted/10"
>
<
div
className=
"flex items-center gap-2"
>
<
CalculatorIcon
size=
{
22
}
className=
"text-clay-primary"
/>
<
h3
className=
"clay-title-h3 text-base"
>
{
t
(
"transaction.calculatorTitle"
)
||
"Máy tính giao dịch"
}
</
h3
>
</
div
>
<
button
type=
"button"
onClick=
{
onClose
}
aria
-
label=
{
t
(
"accessibility.closeModal"
)
||
"Đóng"
}
className=
"w-7 h-7 rounded-full flex items-center justify-center bg-clay-bg text-clay-text-muted hover:text-clay-text shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
<
CloseIcon
size=
{
16
}
/>
</
button
>
</
div
>
{
/* Display Screen */
}
<
div
className=
"bg-clay-bg rounded-clay-sm p-3 shadow-clay-pressed border border-clay-highlight/30 flex flex-col justify-center min-h-[68px] text-right"
>
<
div
className=
"text-xs font-nunito font-semibold text-clay-text-muted/80 tracking-wide h-4 truncate"
>
{
expression
||
"
\
u00A0"
}
</
div
>
<
div
className=
"text-2xl font-baloo font-bold text-clay-text tracking-tight mt-0.5 truncate"
>
{
formatDisplayValue
(
display
,
intlLocale
)
}
</
div
>
</
div
>
{
/* Keypad Grid */
}
<
div
className=
"grid grid-cols-4 gap-2"
>
{
/* Row 1 */
}
<
button
type=
"button"
onClick=
{
handleClear
}
className=
"h-11 rounded-clay-sm bg-clay-expense/15 text-clay-expense border border-clay-expense/30 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
C
</
button
>
<
button
type=
"button"
onClick=
{
handleBackspace
}
className=
"h-11 rounded-clay-sm bg-clay-expense/15 text-clay-expense border border-clay-expense/30 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
⌫
</
button
>
<
button
type=
"button"
onClick=
{
handleTripleZero
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-primary border border-clay-highlight/50 font-baloo font-bold text-xs shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
000
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleOperator
(
"/"
)
}
className=
"h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
÷
</
button
>
{
/* Row 2 */
}
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"7"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
7
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"8"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
8
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"9"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
9
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleOperator
(
"*"
)
}
className=
"h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
×
</
button
>
{
/* Row 3 */
}
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"4"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
4
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"5"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
5
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"6"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
6
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleOperator
(
"-"
)
}
className=
"h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
−
</
button
>
{
/* Row 4 */
}
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"1"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
1
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"2"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
2
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"3"
)
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
3
</
button
>
<
button
type=
"button"
onClick=
{
()
=>
handleOperator
(
"+"
)
}
className=
"h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
+
</
button
>
{
/* Row 5 */
}
<
button
type=
"button"
onClick=
{
()
=>
handleDigit
(
"0"
)
}
className=
"col-span-2 h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
0
</
button
>
<
button
type=
"button"
onClick=
{
handleDecimal
}
className=
"h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-lg shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
.
</
button
>
<
button
type=
"button"
onClick=
{
handleEquals
}
className=
"h-11 rounded-clay-sm bg-clay-primary text-clay-on-primary border border-clay-primary-dark/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
=
</
button
>
</
div
>
{
/* Done Action Button */
}
<
Button
id=
"btn-calc-done"
type=
"button"
variant=
"primary"
fullWidth
onClick=
{
handleDone
}
className=
"mt-1 py-3 text-base font-baloo font-bold shadow-clay-raised"
>
<
span
className=
"flex items-center justify-center gap-2"
>
<
CheckIcon
size=
{
18
}
/>
<
span
>
{
t
(
"transaction.calcDone"
)
||
"Xong"
}
</
span
>
</
span
>
</
Button
>
</
div
>
</
div
>
);
};
export
default
TransactionCalculatorModal
;
src/pages/transactions/components/TransactionFormModal.tsx
View file @
130901b6
...
@@ -7,8 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
...
@@ -7,8 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
CalculatorIcon
}
from
"@/components/ui/icons"
;
import
{
CalculatorButton
,
CalculatorModal
}
from
"@/components/ui/CalculatorModal"
;
import
{
TransactionCalculatorModal
}
from
"./TransactionCalculatorModal"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
useWallets
}
from
"@/hooks/use-wallets"
;
import
{
useWallets
}
from
"@/hooks/use-wallets"
;
import
{
useCategoryTree
}
from
"@/hooks/use-categories"
;
import
{
useCategoryTree
}
from
"@/hooks/use-categories"
;
...
@@ -111,11 +110,15 @@ interface FlatCategoryOption {
...
@@ -111,11 +110,15 @@ interface FlatCategoryOption {
depth
:
number
;
depth
:
number
;
}
}
function
flattenTree
(
nodes
:
CategoryTreeNode
[],
depth
=
0
):
FlatCategoryOption
[]
{
function
flattenTree
(
nodes
:
CategoryTreeNode
[]
=
[],
depth
=
0
):
FlatCategoryOption
[]
{
return
nodes
.
flatMap
((
node
)
=>
[
const
result
:
FlatCategoryOption
[]
=
[];
{
category
:
node
,
depth
},
for
(
const
node
of
nodes
||
[])
{
...
flattenTree
(
node
.
children
,
depth
+
1
),
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
)
=>
{
const
getLocalDateString
=
(
dateInput
?:
string
|
Date
)
=>
{
...
@@ -409,17 +412,11 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
...
@@ -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
className=
"font-nunito font-semibold text-sm px-1 invisible select-none"
aria
-
hidden=
"true"
>
</
span
>
</
span
>
<
b
utton
<
CalculatorB
utton
id=
"btn-transaction-calculator"
id=
"btn-transaction-calculator"
type=
"button"
onClick=
{
()
=>
setIsCalculatorOpen
(
true
)
}
onClick=
{
()
=>
setIsCalculatorOpen
(
true
)
}
disabled=
{
isSubmitting
}
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
>
</
div
>
{
/* Số tiền */
}
{
/* Số tiền */
}
...
@@ -435,7 +432,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
...
@@ -435,7 +432,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
placeholder=
{
t
(
"transaction.amountPlaceholder"
)
}
placeholder=
{
t
(
"transaction.amountPlaceholder"
)
}
error=
{
errors
.
amount
?.
message
}
error=
{
errors
.
amount
?.
message
}
disabled=
{
isSubmitting
}
disabled=
{
isSubmitting
}
className=
"t
ext-right t
abular-nums font-semibold"
className=
"tabular-nums font-semibold"
value=
{
formatAmountInput
(
field
.
value
,
intlLocale
)
}
value=
{
formatAmountInput
(
field
.
value
,
intlLocale
)
}
onChange=
{
(
event
)
=>
field
.
onChange
(
parseAmountInput
(
event
.
target
.
value
,
intlLocale
))
}
onChange=
{
(
event
)
=>
field
.
onChange
(
parseAmountInput
(
event
.
target
.
value
,
intlLocale
))
}
/>
/>
...
@@ -632,7 +629,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
...
@@ -632,7 +629,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
</
form
>
</
form
>
</
Modal
>
</
Modal
>
<
Transaction
CalculatorModal
<
CalculatorModal
isOpen=
{
isCalculatorOpen
}
isOpen=
{
isCalculatorOpen
}
onClose=
{
()
=>
setIsCalculatorOpen
(
false
)
}
onClose=
{
()
=>
setIsCalculatorOpen
(
false
)
}
initialAmount=
{
watch
(
"amount"
)
}
initialAmount=
{
watch
(
"amount"
)
}
...
...
src/pages/transactions/index.tsx
View file @
130901b6
...
@@ -684,7 +684,7 @@ const TransactionsPage: React.FC = () => {
...
@@ -684,7 +684,7 @@ const TransactionsPage: React.FC = () => {
label=
{
t
(
"transaction.filterMinAmount"
)
}
label=
{
t
(
"transaction.filterMinAmount"
)
}
inputMode=
"decimal"
inputMode=
"decimal"
placeholder=
{
t
(
"transaction.amountPlaceholder"
)
}
placeholder=
{
t
(
"transaction.amountPlaceholder"
)
}
className=
"t
ext-right t
abular-nums"
className=
"tabular-nums"
value=
{
formatAmountInput
(
minAmount
,
intlLocale
)
}
value=
{
formatAmountInput
(
minAmount
,
intlLocale
)
}
onChange=
{
(
e
)
=>
setMinAmount
(
parseAmountInput
(
e
.
target
.
value
,
intlLocale
))
}
onChange=
{
(
e
)
=>
setMinAmount
(
parseAmountInput
(
e
.
target
.
value
,
intlLocale
))
}
/>
/>
...
@@ -692,7 +692,7 @@ const TransactionsPage: React.FC = () => {
...
@@ -692,7 +692,7 @@ const TransactionsPage: React.FC = () => {
label=
{
t
(
"transaction.filterMaxAmount"
)
}
label=
{
t
(
"transaction.filterMaxAmount"
)
}
inputMode=
"decimal"
inputMode=
"decimal"
placeholder=
{
t
(
"transaction.amountPlaceholder"
)
}
placeholder=
{
t
(
"transaction.amountPlaceholder"
)
}
className=
"t
ext-right t
abular-nums"
className=
"tabular-nums"
value=
{
formatAmountInput
(
maxAmount
,
intlLocale
)
}
value=
{
formatAmountInput
(
maxAmount
,
intlLocale
)
}
onChange=
{
(
e
)
=>
setMaxAmount
(
parseAmountInput
(
e
.
target
.
value
,
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
{
zodResolver
}
from
"@hookform/resolvers/zod"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
z
}
from
"zod"
;
import
{
z
}
from
"zod"
;
...
@@ -7,6 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
...
@@ -7,6 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
CalculatorButton
,
CalculatorModal
}
from
"@/components/ui/CalculatorModal"
;
import
{
useWalletSearch
}
from
"@/hooks/use-wallets"
;
import
{
useWalletSearch
}
from
"@/hooks/use-wallets"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
getErrorMessage
}
from
"@/lib/error-message"
;
import
{
getErrorMessage
}
from
"@/lib/error-message"
;
...
@@ -175,6 +176,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
...
@@ -175,6 +176,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
const
destinationWalletId
=
watch
(
"destinationWalletId"
);
const
destinationWalletId
=
watch
(
"destinationWalletId"
);
const
amount
=
watch
(
"amount"
);
const
amount
=
watch
(
"amount"
);
const
transferredAt
=
watch
(
"transferredAt"
);
const
transferredAt
=
watch
(
"transferredAt"
);
const
[
isCalculatorOpen
,
setIsCalculatorOpen
]
=
useState
(
false
);
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
);
...
@@ -227,7 +229,8 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
...
@@ -227,7 +229,8 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
const
insufficientWallets
=
!
walletsQuery
.
isLoading
&&
!
walletsQuery
.
isError
&&
wallets
.
length
<
2
;
const
insufficientWallets
=
!
walletsQuery
.
isLoading
&&
!
walletsQuery
.
isError
&&
wallets
.
length
<
2
;
return
(
return
(
<
Modal
<>
<
Modal
isOpen=
{
isOpen
}
isOpen=
{
isOpen
}
onClose=
{
onClose
}
onClose=
{
onClose
}
title=
{
t
(
"transfer.create"
)
}
title=
{
t
(
"transfer.create"
)
}
...
@@ -288,26 +291,37 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
...
@@ -288,26 +291,37 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
{
...
register
("
destinationWalletId
")}
{
...
register
("
destinationWalletId
")}
/>
/>
<
Controller
<
div
className=
"flex items-end gap-2"
>
name=
"amount"
<
div
className=
"flex-1 min-w-0"
>
control=
{
control
}
<
Controller
render=
{
({
field
})
=>
(
name=
"amount"
<
Input
control=
{
control
}
{
...
field
}
render=
{
({
field
})
=>
(
label=
{
t
(
"transfer.amount"
)
}
<
Input
inputMode=
"decimal"
{
...
field
}
placeholder=
{
t
(
"transfer.amountPlaceholder"
)
}
label=
{
t
(
"transfer.amount"
)
}
error=
{
errors
.
amount
?.
message
}
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
}
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
&&
(
{
sourceWallet
&&
(
<
div
className=
"-mt-2 flex flex-wrap justify-between gap-1 px-1 text-xs font-semibold text-clay-text-muted"
>
<
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> = ({
...
@@ -345,5 +359,18 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
/>
/>
</
form
>
</
form
>
</
Modal
>
</
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
{
zodResolver
}
from
"@hookform/resolvers/zod"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
Controller
,
useForm
}
from
"react-hook-form"
;
import
{
z
}
from
"zod"
;
import
{
z
}
from
"zod"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
CalculatorButton
,
CalculatorModal
}
from
"@/components/ui/CalculatorModal"
;
import
{
WALLET_COLORS
,
WALLET_ICONS
}
from
"@/lib/wallet-format"
;
import
{
WALLET_COLORS
,
WALLET_ICONS
}
from
"@/lib/wallet-format"
;
import
{
Wallet
,
WalletInput
}
from
"@/types/wallet"
;
import
{
Wallet
,
WalletInput
}
from
"@/types/wallet"
;
import
{
WalletArtwork
}
from
"@/components/shared/WalletArtwork"
;
import
{
WalletArtwork
}
from
"@/components/shared/WalletArtwork"
;
...
@@ -152,6 +153,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
...
@@ -152,6 +153,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
const
selectedIcon
=
watch
(
"icon"
);
const
selectedIcon
=
watch
(
"icon"
);
const
selectedColor
=
watch
(
"color"
);
const
selectedColor
=
watch
(
"color"
);
const
currentCurrency
=
watch
(
"currency"
)?.
trim
()?.
toUpperCase
()
||
"VND"
;
const
currentCurrency
=
watch
(
"currency"
)?.
trim
()?.
toUpperCase
()
||
"VND"
;
const
[
isCalculatorOpen
,
setIsCalculatorOpen
]
=
useState
(
false
);
const
submitForm
=
(
values
:
WalletFormValues
)
=>
{
const
submitForm
=
(
values
:
WalletFormValues
)
=>
{
const
currency
=
values
.
currency
.
trim
().
toUpperCase
();
const
currency
=
values
.
currency
.
trim
().
toUpperCase
();
...
@@ -170,7 +172,8 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
...
@@ -170,7 +172,8 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
};
};
return
(
return
(
<
Modal
<>
<
Modal
isOpen=
{
isOpen
}
isOpen=
{
isOpen
}
onClose=
{
onClose
}
onClose=
{
onClose
}
title=
{
wallet
?
t
(
"wallet.form.editTitle"
)
:
t
(
"wallet.form.createTitle"
)
}
title=
{
wallet
?
t
(
"wallet.form.editTitle"
)
:
t
(
"wallet.form.createTitle"
)
}
...
@@ -196,25 +199,36 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
...
@@ -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
")}
/>
<
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"
>
<
div
className=
"flex items-end gap-2"
>
<
Controller
<
div
className=
"flex-1 min-w-0"
>
name=
"balance"
<
Controller
control=
{
control
}
name=
"balance"
render=
{
({
field
})
=>
(
control=
{
control
}
<
Input
render=
{
({
field
})
=>
(
{
...
field
}
<
Input
label=
{
t
(
"wallet.form.balance"
)
}
{
...
field
}
inputMode=
{
currentCurrency
===
"VND"
?
"numeric"
:
"decimal"
}
label=
{
t
(
"wallet.form.balance"
)
}
placeholder=
"0"
inputMode=
{
currentCurrency
===
"VND"
?
"numeric"
:
"decimal"
}
error=
{
errors
.
balance
?.
message
}
placeholder=
"0"
disabled=
{
isSubmitting
}
error=
{
errors
.
balance
?.
message
}
className=
"text-right tabular-nums"
disabled=
{
isSubmitting
}
value=
{
formatBalanceInput
(
field
.
value
,
intlLocale
,
currentCurrency
)
}
className=
"tabular-nums font-semibold"
onChange=
{
(
event
)
=>
field
.
onChange
(
parseBalanceInput
(
event
.
target
.
value
,
intlLocale
,
currentCurrency
))
}
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
>
<
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
>
</
div
>
<
fieldset
className=
"flex flex-col gap-2"
>
<
fieldset
className=
"flex flex-col gap-2"
>
...
@@ -277,5 +291,18 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
...
@@ -277,5 +291,18 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
)
}
)
}
</
form
>
</
form
>
</
Modal
>
</
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 @@
...
@@ -6,7 +6,7 @@
"noImplicitAny"
:
false
,
"noImplicitAny"
:
false
,
"preserveConstEnums"
:
true
,
"preserveConstEnums"
:
true
,
"jsx"
:
"react-jsx"
,
"jsx"
:
"react-jsx"
,
"lib"
:
[
"dom"
,
"
es5"
,
"es6"
,
"es7"
,
"es2017"
,
"es2018
"
],
"lib"
:
[
"dom"
,
"
dom.iterable"
,
"esnext
"
],
"allowSyntheticDefaultImports"
:
true
,
"allowSyntheticDefaultImports"
:
true
,
"esModuleInterop"
:
true
,
"esModuleInterop"
:
true
,
"allowJs"
:
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