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