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
9a70ebeb
Commit
9a70ebeb
authored
Sep 14, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(notifications): display recurring transaction due date and navigate to details modal
parent
43bf8bad
Changes
9
Show whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
191 additions
and
26 deletions
+191
-26
use-recurring-transactions.ts
src/hooks/use-recurring-transactions.ts
+9
-0
en.json
src/i18n/locales/en.json
+4
-1
vi.json
src/i18n/locales/vi.json
+4
-1
navigation-state.ts
src/lib/navigation-state.ts
+7
-0
notification-format.ts
src/lib/notification-format.ts
+50
-1
index.tsx
src/pages/notifications/index.tsx
+14
-1
RecurringTransactionDetailsModal.tsx
...nsactions/components/RecurringTransactionDetailsModal.tsx
+54
-20
index.tsx
src/pages/recurring-transactions/index.tsx
+44
-2
recurring-transaction.service.ts
src/services/recurring-transaction.service.ts
+5
-0
No files found.
src/hooks/use-recurring-transactions.ts
View file @
9a70ebeb
...
...
@@ -12,6 +12,7 @@ export const recurringTransactionKeys = {
all
:
[
"recurring-transactions"
]
as
const
,
lists
:
()
=>
[...
recurringTransactionKeys
.
all
,
"list"
]
as
const
,
list
:
(
query
:
RecurringTransactionQuery
)
=>
[...
recurringTransactionKeys
.
lists
(),
query
]
as
const
,
detail
:
(
id
:
string
)
=>
[...
recurringTransactionKeys
.
all
,
id
]
as
const
,
preview
:
(
id
:
string
)
=>
[...
recurringTransactionKeys
.
all
,
id
,
"preview"
]
as
const
,
history
:
(
id
:
string
)
=>
[...
recurringTransactionKeys
.
all
,
id
,
"history"
]
as
const
,
};
...
...
@@ -25,6 +26,14 @@ export function useRecurringTransactions(query: RecurringTransactionQuery) {
});
}
export
function
useRecurringTransaction
(
id
:
string
,
enabled
=
true
)
{
return
useQuery
({
queryKey
:
recurringTransactionKeys
.
detail
(
id
),
queryFn
:
()
=>
recurringTransactionService
.
getById
(
id
),
enabled
:
enabled
&&
Boolean
(
id
),
});
}
export
function
useRecurringPreview
(
id
:
string
,
enabled
:
boolean
)
{
return
useQuery
({
queryKey
:
recurringTransactionKeys
.
preview
(
id
),
...
...
src/i18n/locales/en.json
View file @
9a70ebeb
...
...
@@ -1272,8 +1272,11 @@
"goalDueMessage"
:
"The target date is {{date}}."
,
"unusualTitle"
:
"Unusual transaction detected"
,
"unusualMessage"
:
"This expense is significantly higher than your recent spending in the same wallet."
,
"reminderFallback"
:
"A scheduled financial reminder is due."
"reminderFallback"
:
"A scheduled financial reminder is due."
,
"recurringDueMessage"
:
"Recurring transaction is due on {{date}}."
,
"recurringDueMessageWithName"
:
"Recurring transaction ({{name}}) is due on {{date}}."
},
"dueDateLabel"
:
"Due date"
,
"settings"
:
{
"alertTitle"
:
"Alert types"
,
"alertHint"
:
"Choose which financial signals FinWise should monitor."
,
...
...
src/i18n/locales/vi.json
View file @
9a70ebeb
...
...
@@ -1319,8 +1319,11 @@
"goalDueMessage"
:
"Ngày mục tiêu là {{date}}."
,
"unusualTitle"
:
"Phát hiện giao dịch bất thường"
,
"unusualMessage"
:
"Khoản chi này cao hơn đáng kể so với lịch sử chi tiêu gần đây trong cùng ví."
,
"reminderFallback"
:
"Một lịch nhắc tài chính đã đến hạn."
"reminderFallback"
:
"Một lịch nhắc tài chính đã đến hạn."
,
"recurringDueMessage"
:
"Giao dịch định kỳ sắp đến hạn thực hiện vào ngày {{date}}."
,
"recurringDueMessageWithName"
:
"Giao dịch định kỳ ({{name}}) sắp đến hạn thực hiện vào ngày {{date}}."
},
"dueDateLabel"
:
"Ngày đến hạn"
,
"settings"
:
{
"alertTitle"
:
"Loại cảnh báo"
,
"alertHint"
:
"Chọn những tín hiệu tài chính bạn muốn FinWise theo dõi."
,
...
...
src/lib/navigation-state.ts
View file @
9a70ebeb
...
...
@@ -6,6 +6,7 @@ export interface FinWiseNavigationState {
fromAIRecommendations
?:
boolean
;
fromReports
?:
boolean
;
transactionId
?:
string
;
scheduleId
?:
string
;
transactionTypeFilter
?:
TransactionType
;
categoryIdFilter
?:
string
;
walletIdFilter
?:
string
;
...
...
@@ -43,6 +44,12 @@ export function getTransactionId(state: unknown): string | undefined {
return
typeof
value
===
"string"
&&
value
.
trim
()
?
value
:
undefined
;
}
export
function
getScheduleId
(
state
:
unknown
):
string
|
undefined
{
if
(
!
state
||
typeof
state
!==
"object"
)
return
undefined
;
const
value
=
(
state
as
FinWiseNavigationState
).
scheduleId
;
return
typeof
value
===
"string"
&&
value
.
trim
()
?
value
:
undefined
;
}
export
function
getCategoryIdFilter
(
state
:
unknown
):
string
|
undefined
{
if
(
!
state
||
typeof
state
!==
"object"
)
return
undefined
;
const
value
=
(
state
as
FinWiseNavigationState
).
categoryIdFilter
;
...
...
src/lib/notification-format.ts
View file @
9a70ebeb
import
{
TranslationFunction
}
from
"@/i18n"
;
import
{
format
BusinessDate
}
from
"@/lib/business-time"
;
import
{
addCalendarDays
,
formatBusinessDate
,
instantTo
BusinessDate
}
from
"@/lib/business-time"
;
import
{
NotificationItem
,
NotificationType
}
from
"@/types/notification"
;
interface
NotificationFormatter
{
...
...
@@ -46,6 +46,36 @@ function extractBusinessDate(message: string): string | undefined {
return
message
.
match
(
/
\b\d{4}
-
\d{2}
-
\d{2}\b
/
)?.[
0
];
}
export
function
extractNotificationDueDate
(
notification
:
NotificationItem
):
string
|
undefined
{
// 1. Explicit dueDate in data
if
(
typeof
notification
.
data
?.
dueDate
===
"string"
&&
notification
.
data
.
dueDate
.
trim
())
{
return
notification
.
data
.
dueDate
.
trim
();
}
// 2. dueDate query param in actionUrl
const
urlDueDate
=
notification
.
actionUrl
?.
match
(
/
[
?&
]
dueDate=
([^
&#
]
+
)
/
)?.[
1
];
if
(
urlDueDate
)
return
urlDueDate
;
// 3. Business date directly in message (e.g. "vào ngày YYYY-MM-DD")
const
msgDate
=
extractBusinessDate
(
notification
.
message
);
if
(
msgDate
)
return
msgDate
;
// 4. Compute from remindDaysBefore in actionUrl + notification trigger/creation time
const
remindDaysMatch
=
notification
.
actionUrl
?.
match
(
/
[
?&
]
remindDaysBefore=
(\d
+
)
/
);
if
(
remindDaysMatch
&&
notification
.
type
===
"RECURRING_PAYMENT_DUE"
)
{
const
days
=
parseInt
(
remindDaysMatch
[
1
],
10
);
const
baseDate
=
notification
.
data
?.
scheduledAt
?
String
(
notification
.
data
.
scheduledAt
)
:
notification
.
createdAt
;
if
(
baseDate
)
{
const
baseBusiness
=
instantToBusinessDate
(
baseDate
);
return
addCalendarDays
(
baseBusiness
,
days
);
}
}
return
undefined
;
}
export
function
formatNotificationContent
(
notification
:
NotificationItem
,
formatter
:
NotificationFormatter
,
...
...
@@ -106,6 +136,25 @@ export function formatNotificationContent(
};
}
if
(
notification
.
type
===
"RECURRING_PAYMENT_DUE"
)
{
const
dueDate
=
extractNotificationDueDate
(
notification
);
if
(
dueDate
)
{
const
formattedDueDate
=
formatBusinessDate
(
dueDate
,
formatter
.
intlLocale
,
{
day
:
"2-digit"
,
month
:
"2-digit"
,
year
:
"numeric"
,
});
const
nameMatch
=
notification
.
title
.
match
(
/
\((
.+
)\)
/
);
const
recurringName
=
nameMatch
?
nameMatch
[
1
]
:
undefined
;
return
{
title
:
notification
.
title
,
message
:
recurringName
?
formatter
.
t
(
"notification.content.recurringDueMessageWithName"
,
{
name
:
recurringName
,
date
:
formattedDueDate
})
:
formatter
.
t
(
"notification.content.recurringDueMessage"
,
{
date
:
formattedDueDate
}),
};
}
}
if
(
(
notification
.
type
===
"USER_REMINDER"
||
notification
.
type
===
"RECURRING_PAYMENT_DUE"
)
&&
notification
.
message
===
"A scheduled reminder is due."
...
...
src/pages/notifications/index.tsx
View file @
9a70ebeb
...
...
@@ -94,6 +94,8 @@ const NotificationsPage: React.FC = () => {
if
(
/^https:
\/\/
/i
.
test
(
item
.
actionUrl
))
window
.
open
(
item
.
actionUrl
,
"_blank"
,
"noopener,noreferrer"
);
else
{
const
transactionPathMatch
=
item
.
actionUrl
.
match
(
/^
\/
transactions
\/([^/
?#
]
+
)
/
);
const
recurringIdMatch
=
item
.
actionUrl
.
match
(
/
[
?&
]
id=
([^/
?#&
]
+
)
/
);
const
dataTransactionId
=
item
.
data
?.
transactionId
;
const
transactionId
=
typeof
dataTransactionId
===
"string"
?
dataTransactionId
...
...
@@ -101,9 +103,20 @@ const NotificationsPage: React.FC = () => {
?
item
.
sourceId
:
transactionPathMatch
?.[
1
];
const
dataScheduleId
=
item
.
data
?.
scheduleId
;
const
scheduleId
=
typeof
dataScheduleId
===
"string"
?
dataScheduleId
:
recurringIdMatch
?.[
1
];
navigate
(
transactionPathMatch
?
"/transactions"
:
item
.
actionUrl
,
{
state
:
{
fromNotifications
:
true
,
...(
transactionId
?
{
transactionId
}
:
{})
}
},
{
state
:
{
fromNotifications
:
true
,
...(
transactionId
?
{
transactionId
}
:
{}),
...(
scheduleId
?
{
scheduleId
}
:
{}),
},
},
);
}
};
...
...
src/pages/recurring-transactions/components/RecurringTransactionDetailsModal.tsx
View file @
9a70ebeb
...
...
@@ -5,6 +5,7 @@ import { Modal } from "@/components/ui/Modal";
import
{
useRecurringHistory
,
useRecurringPreview
}
from
"@/hooks/use-recurring-transactions"
;
import
{
useI18n
}
from
"@/i18n"
;
import
{
formatBusinessDate
}
from
"@/lib/business-time"
;
import
{
getCategoryDisplayName
}
from
"@/lib/category-format"
;
import
{
RecurringTransactionSchedule
}
from
"@/types/recurring-transaction"
;
interface
Props
{
...
...
@@ -13,7 +14,7 @@ interface Props {
}
export
const
RecurringTransactionDetailsModal
:
React
.
FC
<
Props
>
=
({
schedule
,
onClose
})
=>
{
const
{
t
,
intlLocale
}
=
useI18n
();
const
{
t
,
formatCurrency
,
intlLocale
}
=
useI18n
();
const
id
=
schedule
?.
id
||
""
;
const
preview
=
useRecurringPreview
(
id
,
Boolean
(
schedule
));
const
history
=
useRecurringHistory
(
id
,
Boolean
(
schedule
));
...
...
@@ -25,29 +26,62 @@ export const RecurringTransactionDetailsModal: React.FC<Props> = ({ schedule, on
<
Modal
isOpen=
{
Boolean
(
schedule
)
}
onClose=
{
onClose
}
title=
{
t
(
"recurringTransactions.detailsTitle"
)
}
>
<
div
className=
"space-y-5"
>
{
schedule
&&
(
<
div
className=
"rounded-clay-sm bg-clay-bg p-3 text-xs shadow-clay-pressed space-y-1.5"
>
<
div
className=
"flex justify-between"
>
<
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
>
<
div
className=
"space-y-3 rounded-clay-md bg-clay-surface p-3.5 shadow-clay-card border border-clay-border/40"
>
<
div
className=
"flex items-start justify-between gap-3"
>
<
div
>
<
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
>
<
Badge
type=
{
schedule
.
isActive
?
"income"
:
"info"
}
>
{
schedule
.
isActive
?
t
(
"recurringTransactions.active"
)
:
t
(
"recurringTransactions.paused"
)
}
</
Badge
>
</
div
>
<
p
className=
"mt-1 text-xs text-clay-text-muted"
>
{
schedule
.
wallet
?.
name
}
•
{
getCategoryDisplayName
(
schedule
.
category
,
t
)
}
{
schedule
.
location
?
` • ${schedule.location}`
:
""
}
</
p
>
</
div
>
<
p
className=
{
`font-baloo text-lg font-bold ${schedule.type === "INCOME" ? "text-clay-income" : "text-clay-expense"}`
}
>
{
formatCurrency
(
Number
(
schedule
.
amount
),
schedule
.
wallet
?.
currency
||
"VND"
)
}
</
p
>
</
div
>
<
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
>
{
schedule
.
remindDaysBefore
!==
null
&&
schedule
.
remindDaysBefore
!==
undefined
?
(
<
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-bold text-clay-primary"
>
{
schedule
.
remindDaysBefore
===
0
<
div
>
<
span
className=
"block text-clay-text-muted"
>
{
t
(
"recurringTransactions.nextRun"
)
}
</
span
>
<
b
className=
"text-clay-text"
>
{
schedule
.
nextRunAt
?
formatDate
(
schedule
.
nextRunAt
)
:
"—"
}
</
b
>
</
div
>
<
div
>
<
span
className=
"block text-clay-text-muted"
>
{
t
(
"recurringTransactions.form.anchorDate"
)
}
</
span
>
<
b
className=
"text-clay-text"
>
{
formatDate
(
schedule
.
anchorDate
)
}
</
b
>
</
div
>
<
div
>
<
span
className=
"block text-clay-text-muted"
>
{
t
(
"recurringTransactions.reminder"
)
}
</
span
>
<
b
className=
"text-clay-primary"
>
{
schedule
.
remindDaysBefore
!==
null
&&
schedule
.
remindDaysBefore
!==
undefined
?
(
schedule
.
remindDaysBefore
===
0
?
t
(
"recurringTransactions.reminderBadgeSameDay"
)
:
t
(
"recurringTransactions.reminderBadge"
,
{
days
:
schedule
.
remindDaysBefore
})
}
</
span
>
:
t
(
"recurringTransactions.reminderBadge"
,
{
days
:
schedule
.
remindDaysBefore
}))
:
t
(
"recurringTransactions.reminderOff"
)
}
</
b
>
</
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
>
{
schedule
.
endDate
&&
(
<
div
className=
"col-span-2"
>
<
span
className=
"block text-clay-text-muted"
>
{
t
(
"recurringTransactions.form.endDate"
)
}
</
span
>
<
b
className=
"text-clay-text"
>
{
formatDate
(
schedule
.
endDate
)
}
</
b
>
</
div
>
)
}
</
div
>
</
div
>
)
}
<
section
className=
"space-y-2"
>
...
...
src/pages/recurring-transactions/index.tsx
View file @
9a70ebeb
import
React
,
{
useState
}
from
"react"
;
import
{
Header
,
Page
,
useSnackbar
}
from
"zmp-ui"
;
import
React
,
{
use
Effect
,
useRef
,
use
State
}
from
"react"
;
import
{
Header
,
Page
,
use
Location
,
use
Snackbar
}
from
"zmp-ui"
;
import
{
Badge
}
from
"@/components/ui/Badge"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Card
}
from
"@/components/ui/Card"
;
...
...
@@ -10,6 +10,7 @@ import {
useCreateRecurringTransaction
,
useDeleteRecurringTransaction
,
usePauseRecurringTransaction
,
useRecurringTransaction
,
useRecurringTransactions
,
useResumeRecurringTransaction
,
useUpdateRecurringTransaction
,
...
...
@@ -20,6 +21,7 @@ import { useI18n } from "@/i18n";
import
{
formatBusinessDate
}
from
"@/lib/business-time"
;
import
{
getCategoryDisplayName
}
from
"@/lib/category-format"
;
import
{
normalizeAmountInput
}
from
"@/lib/money-input"
;
import
{
getScheduleId
}
from
"@/lib/navigation-state"
;
import
{
RecurringTransactionInput
,
RecurringTransactionSchedule
,
...
...
@@ -29,6 +31,7 @@ import { RecurringTransactionDetailsModal } from "./components/RecurringTransact
import
{
RecurringTransactionFormModal
}
from
"./components/RecurringTransactionFormModal"
;
const
RecurringTransactionsPage
:
React
.
FC
=
()
=>
{
const
location
=
useLocation
();
const
{
t
,
formatCurrency
,
intlLocale
}
=
useI18n
();
const
{
openSnackbar
}
=
useSnackbar
();
...
...
@@ -54,6 +57,45 @@ const RecurringTransactionsPage: React.FC = () => {
const
schedules
=
query
.
data
?.
data
||
[];
const
isSaving
=
createMutation
.
isPending
||
updateMutation
.
isPending
;
const
stateScheduleId
=
getScheduleId
(
location
.
state
);
const
searchParams
=
new
URLSearchParams
(
location
.
search
||
(
typeof
window
!==
"undefined"
?
window
.
location
.
search
:
""
)
);
const
urlScheduleId
=
searchParams
.
get
(
"id"
);
const
targetScheduleId
=
stateScheduleId
||
urlScheduleId
||
undefined
;
const
openedScheduleRef
=
useRef
<
string
>
();
// Fetch individual schedule if targetScheduleId is specified but not in current list
const
shouldFetchSingle
=
Boolean
(
targetScheduleId
&&
!
query
.
isLoading
&&
!
schedules
.
some
((
s
)
=>
s
.
id
===
targetScheduleId
)
);
const
singleScheduleQuery
=
useRecurringTransaction
(
targetScheduleId
||
""
,
shouldFetchSingle
);
// Auto-open details modal when targetScheduleId matches a schedule in the list
useEffect
(()
=>
{
if
(
!
targetScheduleId
||
openedScheduleRef
.
current
===
targetScheduleId
)
return
;
const
found
=
schedules
.
find
((
s
)
=>
s
.
id
===
targetScheduleId
);
if
(
found
)
{
openedScheduleRef
.
current
=
targetScheduleId
;
setDetails
(
found
);
}
},
[
schedules
,
targetScheduleId
]);
// Auto-open details modal if loaded via singleScheduleQuery
useEffect
(()
=>
{
if
(
!
targetScheduleId
||
openedScheduleRef
.
current
===
targetScheduleId
)
return
;
const
schedule
=
singleScheduleQuery
.
data
?.
data
;
if
(
schedule
&&
schedule
.
id
===
targetScheduleId
)
{
openedScheduleRef
.
current
=
targetScheduleId
;
setDetails
(
schedule
);
}
},
[
singleScheduleQuery
.
data
?.
data
,
targetScheduleId
]);
const
formatDate
=
(
value
:
string
)
=>
formatBusinessDate
(
value
,
intlLocale
,
{
day
:
"2-digit"
,
month
:
"2-digit"
,
year
:
"numeric"
,
});
...
...
src/services/recurring-transaction.service.ts
View file @
9a70ebeb
...
...
@@ -21,6 +21,11 @@ export const recurringTransactionService = {
return
ensureSuccess
(
response
.
data
);
},
async
getById
(
id
:
string
):
Promise
<
RecurringTransactionResponse
<
RecurringTransactionSchedule
>>
{
const
response
=
await
apiClient
.
get
<
RecurringTransactionResponse
<
RecurringTransactionSchedule
>>
(
`/recurring-transactions/
${
id
}
`
);
return
ensureSuccess
(
response
.
data
);
},
async
create
(
input
:
RecurringTransactionInput
):
Promise
<
RecurringTransactionResponse
<
RecurringTransactionSchedule
>>
{
const
response
=
await
apiClient
.
post
<
RecurringTransactionResponse
<
RecurringTransactionSchedule
>>
(
"/recurring-transactions"
,
input
);
return
ensureSuccess
(
response
.
data
);
...
...
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