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
a1c7ad10
Commit
a1c7ad10
authored
Aug 17, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(ai-assistant): implement AI Chat UI, quick actions, store, and i18n support
parent
8a15bc17
Changes
23
Show whitespace changes
Inline
Side-by-side
Showing
23 changed files
with
2213 additions
and
36 deletions
+2213
-36
layout.tsx
src/components/layout.tsx
+2
-0
DocumentTitle.tsx
src/components/shared/DocumentTitle.tsx
+1
-0
icons.tsx
src/components/ui/icons.tsx
+40
-0
use-ai-assistant.ts
src/hooks/use-ai-assistant.ts
+57
-0
en.json
src/i18n/locales/en.json
+126
-2
vi.json
src/i18n/locales/vi.json
+126
-2
navigation-state.ts
src/lib/navigation-state.ts
+7
-0
AIChatView.tsx
src/pages/ai-assistant/components/AIChatView.tsx
+334
-0
AIErrorState.tsx
src/pages/ai-assistant/components/AIErrorState.tsx
+45
-0
AIInsightsView.tsx
src/pages/ai-assistant/components/AIInsightsView.tsx
+414
-0
AISkeleton.tsx
src/pages/ai-assistant/components/AISkeleton.tsx
+26
-0
ReceiptScannerView.tsx
src/pages/ai-assistant/components/ReceiptScannerView.tsx
+359
-0
index.tsx
src/pages/ai-assistant/index.tsx
+133
-0
index.tsx
src/pages/budgets/index.tsx
+8
-2
index.tsx
src/pages/index.tsx
+8
-0
AIInsightsPreview.tsx
src/pages/reports/components/AIInsightsPreview.tsx
+106
-15
index.tsx
src/pages/saving-goals/index.tsx
+8
-2
TransactionFormModal.tsx
src/pages/transactions/components/TransactionFormModal.tsx
+25
-11
index.tsx
src/pages/transactions/index.tsx
+47
-2
ai-assistant.service.ts
src/services/ai-assistant.service.ts
+145
-0
ai-chat-store.ts
src/stores/ai-chat-store.ts
+32
-0
auth-store.ts
src/stores/auth-store.ts
+2
-0
ai.ts
src/types/ai.ts
+162
-0
No files found.
src/components/layout.tsx
View file @
a1c7ad10
...
...
@@ -36,6 +36,7 @@ import SavingGoalsPage from "@/pages/saving-goals/index";
import
SavingGoalDetailPage
from
"@/pages/saving-goals/detail"
;
import
ReportsPage
from
"@/pages/reports/index"
;
import
NotificationsPage
from
"@/pages/notifications/index"
;
import
AIAssistantPage
from
"@/pages/ai-assistant/index"
;
const
AuthInitializer
:
React
.
FC
<
{
children
:
React
.
ReactNode
}
>
=
({
children
})
=>
{
const
{
setAuth
,
clearAuth
,
setInitialized
}
=
useAuthStore
();
...
...
@@ -104,6 +105,7 @@ const Layout = () => {
<
Route
path=
"/saving-goals/:id"
element=
{
<
AuthGuard
><
SavingGoalDetailPage
/></
AuthGuard
>
}
></
Route
>
<
Route
path=
"/reports"
element=
{
<
AuthGuard
><
ReportsPage
/></
AuthGuard
>
}
></
Route
>
<
Route
path=
"/notifications"
element=
{
<
AuthGuard
><
NotificationsPage
/></
AuthGuard
>
}
></
Route
>
<
Route
path=
"/ai-assistant"
element=
{
<
AuthGuard
><
AIAssistantPage
/></
AuthGuard
>
}
></
Route
>
<
Route
path=
"/style-guide"
element=
{
<
AuthGuard
><
StyleGuidePage
/></
AuthGuard
>
}
></
Route
>
</
AnimationRoutes
>
</
AuthInitializer
>
...
...
src/components/shared/DocumentTitle.tsx
View file @
a1c7ad10
...
...
@@ -25,6 +25,7 @@ const PAGE_TITLES: ReadonlyArray<{
{
matches
:
(
pathname
)
=>
pathname
.
startsWith
(
"/saving-goals/"
),
key
:
"document.savingGoalDetail"
},
{
matches
:
(
pathname
)
=>
pathname
===
"/reports"
,
key
:
"document.reports"
},
{
matches
:
(
pathname
)
=>
pathname
===
"/notifications"
,
key
:
"document.notifications"
},
{
matches
:
(
pathname
)
=>
pathname
===
"/ai-assistant"
,
key
:
"document.aiAssistant"
},
{
matches
:
(
pathname
)
=>
pathname
===
"/style-guide"
,
key
:
"document.styleGuide"
},
];
...
...
src/components/ui/icons.tsx
View file @
a1c7ad10
...
...
@@ -185,3 +185,43 @@ export const FoodIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<
path
d=
"M12 6v6h6"
stroke=
"rgb(var(--color-clay-text))"
/>
</
svg
>
);
// Camera / receipt capture
export
const
CameraIcon
:
React
.
FC
<
IconProps
>
=
({
size
=
24
,
...
props
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
filter=
"url(#clay-3d-shadow)"
{
...
props
}
>
<
rect
x=
"2"
y=
"6"
width=
"20"
height=
"15"
rx=
"4"
fill=
"url(#clay-grad-info)"
fillOpacity=
"0.3"
stroke=
"rgb(var(--color-clay-info))"
/>
<
path
d=
"M8 6l1.5-3h5L16 6"
stroke=
"rgb(var(--color-clay-text))"
/>
<
circle
cx=
"12"
cy=
"13.5"
r=
"4"
fill=
"url(#clay-grad-primary)"
stroke=
"rgb(var(--color-clay-primary-dark))"
/>
</
svg
>
);
// Document / uploaded receipt
export
const
DocumentIcon
:
React
.
FC
<
IconProps
>
=
({
size
=
24
,
...
props
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
filter=
"url(#clay-3d-shadow)"
{
...
props
}
>
<
path
d=
"M6 2h8l4 4v16H6z"
fill=
"url(#clay-grad-info)"
fillOpacity=
"0.3"
stroke=
"rgb(var(--color-clay-info))"
/>
<
path
d=
"M14 2v5h5M9 12h6M9 16h6"
stroke=
"rgb(var(--color-clay-text))"
/>
</
svg
>
);
// Time / rate-limit countdown
export
const
ClockIcon
:
React
.
FC
<
IconProps
>
=
({
size
=
24
,
...
props
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
filter=
"url(#clay-3d-shadow)"
{
...
props
}
>
<
circle
cx=
"12"
cy=
"12"
r=
"9"
fill=
"url(#clay-grad-warning)"
fillOpacity=
"0.35"
stroke=
"rgb(var(--color-clay-warning))"
/>
<
path
d=
"M12 7v5l3 2"
stroke=
"rgb(var(--color-clay-text))"
/>
</
svg
>
);
// Suggested idea / quick prompt
export
const
LightbulbIcon
:
React
.
FC
<
IconProps
>
=
({
size
=
24
,
...
props
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
filter=
"url(#clay-3d-shadow)"
{
...
props
}
>
<
path
d=
"M8.5 15.5A7 7 0 1 1 15.5 15.5C14.5 16.3 14 17.2 14 18h-4c0-.8-.5-1.7-1.5-2.5z"
fill=
"url(#clay-grad-warning)"
fillOpacity=
"0.65"
stroke=
"rgb(var(--color-clay-warning))"
/>
<
path
d=
"M10 21h4M10 18h4"
stroke=
"rgb(var(--color-clay-text))"
/>
</
svg
>
);
// Destructive clear/delete action
export
const
TrashIcon
:
React
.
FC
<
IconProps
>
=
({
size
=
20
,
...
props
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
{
...
props
}
>
<
path
d=
"M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"
/>
</
svg
>
);
src/hooks/use-ai-assistant.ts
0 → 100644
View file @
a1c7ad10
import
{
useMutation
,
useQuery
}
from
"@tanstack/react-query"
;
import
{
aiAssistantService
}
from
"@/services/ai-assistant.service"
;
import
{
AIChatInput
,
AIChatData
,
AIInsightsInput
,
AIInsightsData
,
AIRecommendationsInput
,
AIRecommendationsData
,
CategorizeTransactionInput
,
CategorizeTransactionData
,
ExtractReceiptInput
,
ExtractReceiptData
,
AIServiceResponse
,
}
from
"@/types/ai"
;
export
function
useAIChat
()
{
return
useMutation
<
AIServiceResponse
<
AIChatData
>
,
Error
,
AIChatInput
>
({
mutationFn
:
(
input
)
=>
aiAssistantService
.
chat
(
input
),
});
}
export
function
useAIInsights
(
input
:
AIInsightsInput
,
enabled
=
true
)
{
return
useQuery
<
AIServiceResponse
<
AIInsightsData
>
,
Error
>
({
queryKey
:
[
"ai-insights"
,
input
],
queryFn
:
()
=>
aiAssistantService
.
analyzeInsights
(
input
),
enabled
,
staleTime
:
5
*
60
*
1000
,
retry
:
false
,
});
}
export
function
useAIRecommendations
(
input
:
AIRecommendationsInput
,
enabled
=
true
)
{
return
useQuery
<
AIServiceResponse
<
AIRecommendationsData
>
,
Error
>
({
queryKey
:
[
"ai-recommendations"
,
input
],
queryFn
:
()
=>
aiAssistantService
.
getRecommendations
(
input
),
enabled
,
staleTime
:
10
*
60
*
1000
,
retry
:
false
,
});
}
export
function
useExtractReceipt
()
{
return
useMutation
<
AIServiceResponse
<
ExtractReceiptData
>
,
Error
,
{
file
:
File
;
hints
?:
ExtractReceiptInput
}
>
({
mutationFn
:
({
file
,
hints
})
=>
aiAssistantService
.
extractReceipt
(
file
,
hints
),
});
}
export
function
useCategorizeTransaction
()
{
return
useMutation
<
AIServiceResponse
<
CategorizeTransactionData
>
,
Error
,
CategorizeTransactionInput
>
({
mutationFn
:
(
input
)
=>
aiAssistantService
.
categorizeTransaction
(
input
),
});
}
src/i18n/locales/en.json
View file @
a1c7ad10
...
...
@@ -6,6 +6,7 @@
"edit"
:
"Edit"
,
"saving"
:
"Saving..."
,
"delete"
:
"Delete"
,
"error"
:
"Something went wrong"
,
"retry"
:
"Try again"
,
"loading"
:
"Loading..."
,
"processing"
:
"Processing..."
,
...
...
@@ -52,7 +53,8 @@
"savingGoals"
:
"Saving Goals"
,
"savingGoalDetail"
:
"Saving Goal Details"
,
"reports"
:
"Financial Reports"
,
"notifications"
:
"Notifications & Reminders"
"notifications"
:
"Notifications & Reminders"
,
"aiAssistant"
:
"AI Financial Assistant"
},
"validation"
:
{
"emailRequired"
:
"Email is required"
,
...
...
@@ -201,7 +203,8 @@
"budgets"
:
"Manage spending budgets"
,
"savingGoals"
:
"Manage saving goals"
,
"reports"
:
"Financial reports & analytics"
,
"notifications"
:
"Notifications & reminders"
"notifications"
:
"Notifications & reminders"
,
"aiAssistant"
:
"AI Financial Assistant"
},
"profile"
:
{
"header"
:
"Account"
,
...
...
@@ -1208,5 +1211,126 @@
"interval"
:
"Repeat interval must be from 1 to 365"
,
"future"
:
"A one-time reminder must be in the future"
,
"onceNoEnd"
:
"A one-time reminder cannot have an end time"
,
"endAfter"
:
"End time must be after the start time"
}
},
"ai"
:
{
"header"
:
"FinWise AI Financial Assistant"
,
"shortHeader"
:
"AI Assistant"
,
"liveBadge"
:
"Live AI"
,
"subtitle"
:
"Smart analysis, spending advice & OCR receipt extraction"
,
"tabs"
:
{
"label"
:
"AI Assistant features"
,
"chat"
:
"Advisor"
,
"insights"
:
"Insights"
,
"ocr"
:
"Receipt"
},
"chat"
:
{
"title"
:
"FinWise AI Advisor"
,
"subtitle"
:
"Ask questions about income, expenses, budgets & saving goals"
,
"placeholder"
:
"Ask AI about your personal finances..."
,
"send"
:
"Send"
,
"clearHistory"
:
"New conversation"
,
"empty"
:
"Start chatting with your AI Assistant"
,
"emptyHint"
:
"Feel free to ask any question about your spending, budget, or saving strategies."
,
"quickPromptsTitle"
:
"Quick Questions"
,
"quickPrompts"
:
{
"spending"
:
"Analyze my spending and income for this month?"
,
"reduce"
:
"Where can I cut expenses to save money?"
,
"anomalies"
:
"Are there any unusual spending items recently?"
,
"saving"
:
"Formulate a strategy to save 20% of income?"
},
"highlights"
:
"Key Highlights"
,
"caveats"
:
"Data Caveats"
,
"suggestedActions"
:
"Suggested Actions"
,
"rateLimitTitle"
:
"AI Request Rate Limit"
,
"rateLimitMessage"
:
"You've sent too many AI requests recently. Please try again in {{seconds}} seconds."
,
"countdown"
:
"{{seconds}} sec"
},
"insights"
:
{
"title"
:
"AI Financial Insights & Recommendations"
,
"subtitle"
:
"Automated trend analysis, anomaly detection, and optimization advice"
,
"period"
:
"Analysis Period"
,
"tabsLabel"
:
"Financial insights and recommendations"
,
"analysisTab"
:
"Analysis"
,
"recommendationsTab"
:
"Recommendations"
,
"focus"
:
"Focus Area"
,
"priority"
:
"Recommendation Priority"
,
"focusOptions"
:
{
"ALL"
:
"All"
,
"SPENDING"
:
"Spending"
,
"INCOME"
:
"Income"
,
"CASH_FLOW"
:
"Cash Flow"
},
"priorityOptions"
:
{
"BALANCED"
:
"Balanced"
,
"REDUCE_SPENDING"
:
"Reduce Spending"
,
"GROW_SAVINGS"
:
"Grow Savings"
},
"sections"
:
{
"trends"
:
"Spending Trends"
,
"anomalies"
:
"Anomaly Alerts"
,
"recommendations"
:
"Improvement Suggestions"
,
"budgetRecs"
:
"Budget Limit Recommendations"
,
"savingRecs"
:
"Saving Goal Recommendations"
,
"actionRecs"
:
"Priority Actions"
},
"trendDirection"
:
{
"UP"
:
"Up"
,
"DOWN"
:
"Down"
,
"STABLE"
:
"Stable"
},
"severity"
:
{
"LOW"
:
"Low"
,
"MEDIUM"
:
"Medium"
,
"HIGH"
:
"High"
},
"priorityLevel"
:
{
"LOW"
:
"Low"
,
"MEDIUM"
:
"Medium"
,
"HIGH"
:
"High"
},
"applyBudget"
:
"Create Budget"
,
"applySaving"
:
"Create Saving Goal"
,
"empty"
:
"Not enough data for analysis"
,
"emptyHint"
:
"Add more transactions so FinWise AI can generate accurate insights."
},
"ocr"
:
{
"title"
:
"AI Receipt Extraction (OCR)"
,
"subtitle"
:
"Upload or take a photo of a receipt for automatic data extraction"
,
"uploadTitle"
:
"Upload or capture a receipt"
,
"uploadHint"
:
"Supports JPEG, PNG, WebP or PDF files (max 5 MB)"
,
"selectFile"
:
"Select Receipt File"
,
"takePhoto"
:
"Take Photo with Camera"
,
"extracting"
:
"AI is analyzing your receipt..."
,
"extractingHint"
:
"Extracting merchant, total, date, and category may take a few seconds..."
,
"resultTitle"
:
"Extracted Details"
,
"confidence"
:
"AI Confidence"
,
"merchant"
:
"Merchant / Supplier"
,
"date"
:
"Transaction Date"
,
"totalAmount"
:
"Total Amount"
,
"taxAmount"
:
"Tax Amount"
,
"category"
:
"Suggested Category"
,
"lineItems"
:
"Receipt Line Items"
,
"item"
:
"Product / Service"
,
"quantity"
:
"Qty"
,
"price"
:
"Price"
,
"amount"
:
"Total"
,
"rawText"
:
"Raw Text from Receipt"
,
"previewAlt"
:
"Receipt preview"
,
"itemCount"
:
"{{count}} items"
,
"warnings"
:
"AI Warnings"
,
"confirmTransaction"
:
"Confirm Create Transaction"
,
"confirmHint"
:
"Pre-fill this extracted receipt data into a new Transaction"
,
"reScan"
:
"Scan Another Receipt"
},
"preview"
:
{
"errorTitle"
:
"Unable to load AI insights"
},
"errors"
:
{
"rateLimit"
:
"AI rate limit reached. Please wait for the countdown to finish."
,
"unavailable"
:
"AI service is temporarily unavailable. Please try again later."
,
"notConfigured"
:
"AI service is not configured with an API Key."
,
"invalidResponse"
:
"AI response format was invalid. Please try again."
,
"extractFailed"
:
"Failed to extract receipt data. Please check the image and try again."
}
}
}
src/i18n/locales/vi.json
View file @
a1c7ad10
...
...
@@ -6,6 +6,7 @@
"edit"
:
"Chỉnh sửa"
,
"saving"
:
"Đang lưu..."
,
"delete"
:
"Xóa"
,
"error"
:
"Đã xảy ra lỗi"
,
"retry"
:
"Thử lại"
,
"loading"
:
"Đang tải..."
,
"processing"
:
"Đang xử lý..."
,
...
...
@@ -52,7 +53,8 @@
"savingGoals"
:
"Mục tiêu tiết kiệm"
,
"savingGoalDetail"
:
"Chi tiết mục tiêu tiết kiệm"
,
"reports"
:
"Báo cáo tài chính"
,
"notifications"
:
"Thông báo & nhắc nhở"
"notifications"
:
"Thông báo & nhắc nhở"
,
"aiAssistant"
:
"Trợ lý tài chính AI"
},
"validation"
:
{
"emailRequired"
:
"Email không được để trống"
,
...
...
@@ -201,7 +203,8 @@
"budgets"
:
"Quản lý ngân sách chi tiêu"
,
"savingGoals"
:
"Quản lý mục tiêu tiết kiệm"
,
"reports"
:
"Báo cáo & phân tích tài chính"
,
"notifications"
:
"Thông báo & nhắc nhở"
"notifications"
:
"Thông báo & nhắc nhở"
,
"aiAssistant"
:
"Trợ lý tài chính AI"
},
"profile"
:
{
"header"
:
"Tài Khoản"
,
...
...
@@ -1217,5 +1220,126 @@
"interval"
:
"Chu kỳ lặp phải từ 1 đến 365"
,
"future"
:
"Lịch nhắc một lần phải nằm trong tương lai"
,
"onceNoEnd"
:
"Lịch nhắc một lần không có ngày kết thúc"
,
"endAfter"
:
"Thời gian kết thúc phải sau thời gian bắt đầu"
}
},
"ai"
:
{
"header"
:
"Trợ lý Tài chính FinWise AI"
,
"shortHeader"
:
"Trợ lý AI"
,
"liveBadge"
:
"AI trực tiếp"
,
"subtitle"
:
"Phân tích thông minh, tư vấn chi tiêu & trích xuất hóa đơn OCR"
,
"tabs"
:
{
"label"
:
"Các chức năng của Trợ lý AI"
,
"chat"
:
"Tư vấn"
,
"insights"
:
"Phân tích"
,
"ocr"
:
"Hóa đơn"
},
"chat"
:
{
"title"
:
"FinWise AI Advisor"
,
"subtitle"
:
"Hỏi đáp thu chi, ngân sách & mục tiêu tiết kiệm"
,
"placeholder"
:
"Hỏi AI về tài chính cá nhân..."
,
"send"
:
"Gửi"
,
"clearHistory"
:
"Cuộc trò chuyện mới"
,
"empty"
:
"Bắt đầu trò chuyện với Trợ lý AI"
,
"emptyHint"
:
"Bạn có thể hỏi bất kỳ thắc mắc nào về thu chi, ngân sách hoặc mẹo tiết kiệm."
,
"quickPromptsTitle"
:
"Gợi ý câu hỏi nhanh"
,
"quickPrompts"
:
{
"spending"
:
"Phân tích tình hình thu chi tháng này của tôi?"
,
"reduce"
:
"Tôi có thể cắt giảm chi tiêu ở đâu để tiết kiệm?"
,
"anomalies"
:
"Có khoản chi tiêu nào bất thường gần đây không?"
,
"saving"
:
"Lập chiến lược để tiết kiệm 20% thu nhập?"
},
"highlights"
:
"Điểm nổi bật"
,
"caveats"
:
"Lưu ý dữ liệu"
,
"suggestedActions"
:
"Gợi ý hành động"
,
"rateLimitTitle"
:
"Giới hạn yêu cầu AI"
,
"rateLimitMessage"
:
"Bạn đã gửi quá nhiều yêu cầu AI trong thời gian ngắn. Vui lòng thử lại sau {{seconds}} giây."
,
"countdown"
:
"{{seconds}} giây"
},
"insights"
:
{
"title"
:
"Phân tích Tài chính và Đề xuất của AI"
,
"subtitle"
:
"Tự động phân tích xu hướng chi tiêu, phát hiện bất thường và đề xuất kế hoạch tài chính"
,
"period"
:
"Kỳ báo cáo"
,
"tabsLabel"
:
"Phân tích và đề xuất tài chính"
,
"analysisTab"
:
"Phân tích"
,
"recommendationsTab"
:
"Đề xuất"
,
"focus"
:
"Trọng tâm"
,
"priority"
:
"Ưu tiên đề xuất"
,
"focusOptions"
:
{
"ALL"
:
"Tất cả"
,
"SPENDING"
:
"Chi tiêu"
,
"INCOME"
:
"Thu nhập"
,
"CASH_FLOW"
:
"Dòng tiền"
},
"priorityOptions"
:
{
"BALANCED"
:
"Cân bằng"
,
"REDUCE_SPENDING"
:
"Cắt giảm chi tiêu"
,
"GROW_SAVINGS"
:
"Tăng tiết kiệm"
},
"sections"
:
{
"trends"
:
"Xu hướng chi tiêu"
,
"anomalies"
:
"Cảnh báo bất thường"
,
"recommendations"
:
"Đề xuất cải thiện"
,
"budgetRecs"
:
"Đề xuất Hạn mức Ngân sách"
,
"savingRecs"
:
"Đề xuất Đóng góp Tiết kiệm"
,
"actionRecs"
:
"Hành động ưu tiên"
},
"trendDirection"
:
{
"UP"
:
"Tăng"
,
"DOWN"
:
"Giảm"
,
"STABLE"
:
"Ổn định"
},
"severity"
:
{
"LOW"
:
"Nhẹ"
,
"MEDIUM"
:
"Trung bình"
,
"HIGH"
:
"Nghiêm trọng"
},
"priorityLevel"
:
{
"LOW"
:
"Thấp"
,
"MEDIUM"
:
"Vừa"
,
"HIGH"
:
"Cao"
},
"applyBudget"
:
"Tạo ngân sách"
,
"applySaving"
:
"Tạo mục tiêu tiết kiệm"
,
"empty"
:
"Chưa có đủ dữ liệu phân tích"
,
"emptyHint"
:
"Hãy ghi chép thêm giao dịch để AI FinWise đưa ra phân tích chính xác nhất."
},
"ocr"
:
{
"title"
:
"Trích xuất Hóa đơn bằng AI (OCR)"
,
"subtitle"
:
"Tải hoặc chụp ảnh hóa đơn để AI tự động bóc tách thông tin giao dịch"
,
"uploadTitle"
:
"Tải lên hoặc chụp ảnh hóa đơn"
,
"uploadHint"
:
"Hỗ trợ tệp JPEG, PNG, WebP hoặc PDF (tối đa 5 MB)"
,
"selectFile"
:
"Chọn tệp hóa đơn"
,
"takePhoto"
:
"Chụp từ Camera"
,
"extracting"
:
"AI đang phân tích hóa đơn..."
,
"extractingHint"
:
"Trích xuất tên cửa hàng, số tiền, ngày và danh mục có thể mất vài giây..."
,
"resultTitle"
:
"Thông tin trích xuất"
,
"confidence"
:
"Độ tin cậy AI"
,
"merchant"
:
"Cửa hàng / Nhà cung cấp"
,
"date"
:
"Ngày giao dịch"
,
"totalAmount"
:
"Tổng số tiền"
,
"taxAmount"
:
"Tiền thuế"
,
"category"
:
"Danh mục gợi ý"
,
"lineItems"
:
"Chi tiết hóa đơn"
,
"item"
:
"Sản phẩm / Dịch vụ"
,
"quantity"
:
"SL"
,
"price"
:
"Đơn giá"
,
"amount"
:
"Thành tiền"
,
"rawText"
:
"Văn bản thô từ hóa đơn"
,
"previewAlt"
:
"Bản xem trước hóa đơn"
,
"itemCount"
:
"{{count}} món"
,
"warnings"
:
"Cảnh báo từ AI"
,
"confirmTransaction"
:
"Xác nhận tạo Giao dịch"
,
"confirmHint"
:
"Tự động điền dữ liệu hóa đơn này vào Form tạo giao dịch mới"
,
"reScan"
:
"Quét hóa đơn khác"
},
"preview"
:
{
"errorTitle"
:
"Không thể tải phân tích AI"
},
"errors"
:
{
"rateLimit"
:
"Đã vượt giới hạn yêu cầu AI. Vui lòng chờ đếm ngược kết thúc."
,
"unavailable"
:
"Dịch vụ AI tạm thời không khả dụng. Vui lòng thử lại sau."
,
"notConfigured"
:
"Dịch vụ AI chưa được cấu hình API Key."
,
"invalidResponse"
:
"Dữ liệu AI trả về không hợp lệ. Vui lòng thử lại."
,
"extractFailed"
:
"Không thể phân tích hóa đơn. Vui lòng kiểm tra ảnh và thử lại."
}
}
}
src/lib/navigation-state.ts
View file @
a1c7ad10
export
interface
FinWiseNavigationState
{
fromNotifications
?:
boolean
;
fromAIRecommendations
?:
boolean
;
tab
?:
"chat"
|
"insights"
|
"ocr"
;
insightsTab
?:
"insights"
|
"recommendations"
;
}
export
function
isFromNotifications
(
state
:
unknown
):
boolean
{
...
...
@@ -7,3 +10,7 @@ export function isFromNotifications(state: unknown): boolean {
return
(
state
as
FinWiseNavigationState
).
fromNotifications
===
true
;
}
export
function
isFromAIRecommendations
(
state
:
unknown
):
boolean
{
if
(
!
state
||
typeof
state
!==
"object"
)
return
false
;
return
(
state
as
FinWiseNavigationState
).
fromAIRecommendations
===
true
;
}
src/pages/ai-assistant/components/AIChatView.tsx
0 → 100644
View file @
a1c7ad10
import
React
,
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
Input
}
from
"@/components/ui/Input"
;
import
{
AIAssistantIcon
,
ChevronRightIcon
,
ClockIcon
,
LightbulbIcon
,
NotificationIcon
,
PlusIcon
,
SavingGoalIcon
,
}
from
"@/components/ui/icons"
;
import
{
useI18n
}
from
"@/i18n"
;
import
{
useAIChat
}
from
"@/hooks/use-ai-assistant"
;
import
{
AIAssistantError
}
from
"@/services/ai-assistant.service"
;
import
{
AIChatMessage
,
useAIChatStore
}
from
"@/stores/ai-chat-store"
;
export
const
AIChatView
:
React
.
FC
=
()
=>
{
const
{
t
}
=
useI18n
();
const
chatMutation
=
useAIChat
();
const
{
messages
,
addMessages
,
updateMessage
,
clearMessages
}
=
useAIChatStore
();
const
[
questionInput
,
setQuestionInput
]
=
useState
(
""
);
const
[
retryCountdown
,
setRetryCountdown
]
=
useState
<
number
|
null
>
(
null
);
const
chatContainerRef
=
useRef
<
HTMLDivElement
>
(
null
);
// Countdown timer for Rate Limit (429)
useEffect
(()
=>
{
if
(
retryCountdown
===
null
||
retryCountdown
<=
0
)
return
;
const
timer
=
setInterval
(()
=>
{
setRetryCountdown
((
prev
)
=>
(
prev
!==
null
&&
prev
>
1
?
prev
-
1
:
null
));
},
1000
);
return
()
=>
clearInterval
(
timer
);
},
[
retryCountdown
]);
// Scroll internal chat container to bottom without affecting window scroll
const
scrollToBottom
=
()
=>
{
if
(
chatContainerRef
.
current
)
{
chatContainerRef
.
current
.
scrollTop
=
chatContainerRef
.
current
.
scrollHeight
;
}
};
useEffect
(()
=>
{
scrollToBottom
();
},
[
messages
.
length
,
messages
[
messages
.
length
-
1
]?.
streamingText
]);
// Simulated Streaming effect for AI text response
const
streamAIResponse
=
(
msgId
:
string
,
fullText
:
string
)
=>
{
let
index
=
0
;
const
speedMs
=
15
;
// smooth typing interval
const
interval
=
setInterval
(()
=>
{
index
+=
3
;
// type 3 chars per interval
if
(
index
>=
fullText
.
length
)
{
clearInterval
(
interval
);
updateMessage
(
msgId
,
{
text
:
fullText
,
streamingText
:
undefined
,
isStreaming
:
false
,
});
}
else
{
updateMessage
(
msgId
,
{
streamingText
:
fullText
.
slice
(
0
,
index
)
});
}
},
speedMs
);
};
const
handleSend
=
(
textToSend
?:
string
)
=>
{
const
query
=
(
textToSend
||
questionInput
).
trim
();
if
(
!
query
||
chatMutation
.
isPending
||
retryCountdown
!==
null
)
return
;
const
userMsg
:
AIChatMessage
=
{
id
:
`user-
${
Date
.
now
()}
`
,
sender
:
"user"
,
text
:
query
,
timestamp
:
new
Date
().
toLocaleTimeString
([],
{
hour
:
"2-digit"
,
minute
:
"2-digit"
}),
};
const
aiMsgPlaceholder
:
AIChatMessage
=
{
id
:
`ai-
${
Date
.
now
()}
`
,
sender
:
"ai"
,
text
:
""
,
streamingText
:
""
,
isStreaming
:
true
,
timestamp
:
new
Date
().
toLocaleTimeString
([],
{
hour
:
"2-digit"
,
minute
:
"2-digit"
}),
};
addMessages
(
userMsg
,
aiMsgPlaceholder
);
if
(
!
textToSend
)
setQuestionInput
(
""
);
chatMutation
.
mutate
(
{
question
:
query
},
{
onSuccess
:
(
res
)
=>
{
if
(
res
.
success
&&
res
.
data
)
{
updateMessage
(
aiMsgPlaceholder
.
id
,
{
data
:
res
.
data
,
text
:
res
.
data
.
answer
,
});
streamAIResponse
(
aiMsgPlaceholder
.
id
,
res
.
data
.
answer
);
}
},
onError
:
(
err
)
=>
{
const
aiErr
=
err
as
AIAssistantError
;
if
(
aiErr
.
status
===
429
&&
aiErr
.
retryAfterSeconds
)
{
setRetryCountdown
(
aiErr
.
retryAfterSeconds
);
}
updateMessage
(
aiMsgPlaceholder
.
id
,
{
isStreaming
:
false
,
text
:
aiErr
.
message
||
t
(
"ai.errors.unavailable"
),
});
},
}
);
};
const
handleQuickPrompt
=
(
promptKey
:
"spending"
|
"reduce"
|
"anomalies"
|
"saving"
)
=>
{
const
promptText
=
t
(
`ai.chat.quickPrompts.
${
promptKey
}
`
);
handleSend
(
promptText
);
};
return
(
<
div
className=
"flex h-[calc(100vh-340px)] min-h-[360px] max-h-[560px] flex-col supports-[height:100dvh]:h-[calc(100dvh-340px)]"
>
{
/* Rate Limit Banner */
}
{
retryCountdown
!==
null
&&
(
<
div
className=
"mb-3 flex flex-wrap items-center justify-between gap-2 rounded-clay border border-clay-warning/40 bg-clay-warning/15 p-3 text-xs font-semibold text-clay-text shadow-clay-pressed"
>
<
div
className=
"flex min-w-0 flex-1 items-center gap-2"
>
<
ClockIcon
size=
{
18
}
aria
-
hidden=
"true"
className=
"shrink-0 text-clay-warning"
/>
<
span
>
{
t
(
"ai.chat.rateLimitMessage"
,
{
seconds
:
retryCountdown
})
}
</
span
>
</
div
>
<
span
className=
"shrink-0 font-baloo text-sm font-bold text-clay-warning"
>
{
t
(
"ai.chat.countdown"
,
{
seconds
:
retryCountdown
})
}
</
span
>
</
div
>
)
}
{
/* Chat Messages Container */
}
<
div
ref=
{
chatContainerRef
}
className=
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain px-1 py-2"
style=
{
{
paddingBottom
:
`calc(var(--zaui-safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) + ${messages.length > 0 ? 120 : 80}px)`
,
}
}
>
{
messages
.
length
===
0
?
(
/* Empty Chat State & Quick Prompts */
<
div
className=
"my-auto flex flex-col items-center justify-center text-center p-4"
>
<
div
className=
"flex h-16 w-16 items-center justify-center rounded-full bg-clay-primary/15 shadow-clay-raised mb-3 text-clay-primary border border-clay-highlight/30"
>
<
AIAssistantIcon
size=
{
36
}
/>
</
div
>
<
h3
className=
"clay-title-h3"
>
{
t
(
"ai.chat.empty"
)
}
</
h3
>
<
p
className=
"clay-caption mt-1 max-w-xs"
>
{
t
(
"ai.chat.emptyHint"
)
}
</
p
>
<
div
className=
"mt-6 w-full max-w-md"
>
<
p
className=
"font-nunito text-xs font-bold text-clay-text-muted mb-2 uppercase tracking-wider text-left px-1"
>
{
t
(
"ai.chat.quickPromptsTitle"
)
}
</
p
>
<
div
className=
"grid grid-cols-1 sm:grid-cols-2 gap-2"
>
{
([
"spending"
,
"reduce"
,
"anomalies"
,
"saving"
]
as
const
).
map
((
key
)
=>
(
<
button
key=
{
key
}
type=
"button"
className=
"flex items-center gap-2 rounded-clay-sm border border-clay-highlight/40 bg-clay-surface p-3 text-left shadow-clay-hover transition-all duration-200 ease-in-out hover:bg-clay-primary/5 focus:outline-none focus:ring-2 focus:ring-clay-primary/35"
onClick=
{
()
=>
handleQuickPrompt
(
key
)
}
>
<
LightbulbIcon
size=
{
17
}
aria
-
hidden=
"true"
className=
"shrink-0 text-clay-warning"
/>
<
span
className=
"font-nunito text-xs font-semibold text-clay-text leading-snug"
>
{
t
(
`ai.chat.quickPrompts.${key}`
)
}
</
span
>
</
button
>
))
}
</
div
>
</
div
>
</
div
>
)
:
(
/* Chat History List */
messages
.
map
((
msg
)
=>
(
<
div
key=
{
msg
.
id
}
className=
{
`flex flex-col max-w-[85%] ${
msg.sender === "user" ? "self-end items-end" : "self-start items-start"
}`
}
>
<
div
className=
"flex items-center gap-1.5 mb-1 px-1"
>
<
span
className=
"font-nunito text-[10px] font-bold text-clay-text-muted"
>
{
msg
.
sender
===
"user"
?
t
(
"common.user"
)
:
"FinWise AI"
}
</
span
>
<
span
className=
"text-[10px] text-clay-text-muted/60"
>
{
msg
.
timestamp
}
</
span
>
</
div
>
<
div
className=
{
`rounded-clay p-4 shadow-clay-raised transition-all duration-200 ease-in-out ${
msg.sender === "user"
? "bg-clay-primary text-clay-on-primary rounded-br-none"
: "bg-clay-surface text-clay-text border border-clay-highlight/30 rounded-bl-none"
}`
}
>
{
/* Streaming or Full text */
}
<
p
className=
"font-nunito text-sm leading-relaxed whitespace-pre-wrap"
>
{
msg
.
isStreaming
?
msg
.
streamingText
:
msg
.
text
}
{
msg
.
isStreaming
&&
<
span
className=
"inline-block animate-pulse ml-0.5 font-bold"
>
▍
</
span
>
}
</
p
>
{
/* Additional AI Structured Data (Highlights, Caveats, Suggested Actions) */
}
{
msg
.
data
&&
!
msg
.
isStreaming
&&
(
<
div
className=
"mt-3 pt-3 border-t border-clay-text/10 flex flex-col gap-2.5"
>
{
/* Highlights */
}
{
msg
.
data
.
highlights
.
length
>
0
&&
(
<
div
className=
"rounded-clay-sm bg-clay-income/10 p-2.5 border border-clay-income/30"
>
<
span
className=
"mb-1 flex items-center gap-1.5 font-nunito text-xs font-bold uppercase tracking-wider text-clay-income"
>
<
SavingGoalIcon
size=
{
15
}
aria
-
hidden=
"true"
/>
{
t
(
"ai.chat.highlights"
)
}
</
span
>
<
ul
className=
"list-disc list-inside text-xs text-clay-text space-y-0.5"
>
{
msg
.
data
.
highlights
.
map
((
hl
,
i
)
=>
(
<
li
key=
{
i
}
>
{
hl
}
</
li
>
))
}
</
ul
>
</
div
>
)
}
{
/* Caveats */
}
{
msg
.
data
.
caveats
.
length
>
0
&&
(
<
div
className=
"rounded-clay-sm bg-clay-warning/10 p-2.5 border border-clay-warning/30"
>
<
span
className=
"mb-1 flex items-center gap-1.5 font-nunito text-xs font-bold uppercase tracking-wider text-clay-warning"
>
<
NotificationIcon
size=
{
15
}
aria
-
hidden=
"true"
/>
{
t
(
"ai.chat.caveats"
)
}
</
span
>
<
ul
className=
"list-disc list-inside text-xs text-clay-text space-y-0.5"
>
{
msg
.
data
.
caveats
.
map
((
cv
,
i
)
=>
(
<
li
key=
{
i
}
>
{
cv
}
</
li
>
))
}
</
ul
>
</
div
>
)
}
{
/* Suggested Actions */
}
{
msg
.
data
.
suggestedActions
.
length
>
0
&&
(
<
div
>
<
span
className=
"mb-1.5 flex items-center gap-1.5 font-nunito text-[11px] font-bold uppercase tracking-wider text-clay-primary"
>
<
AIAssistantIcon
size=
{
15
}
aria
-
hidden=
"true"
/>
{
t
(
"ai.chat.suggestedActions"
)
}
</
span
>
<
div
className=
"flex flex-wrap gap-1.5"
>
{
msg
.
data
.
suggestedActions
.
map
((
action
,
i
)
=>
(
<
button
key=
{
i
}
type=
"button"
className=
"inline-flex items-center gap-1 rounded-full border border-clay-primary/30 bg-clay-primary/10 px-3 py-1 text-xs font-semibold text-clay-primary shadow-clay-pressed transition-all duration-200 ease-in-out hover:bg-clay-primary/20 focus:outline-none focus:ring-2 focus:ring-clay-primary/35"
onClick=
{
()
=>
handleSend
(
action
)
}
>
{
action
}
<
ChevronRightIcon
size=
{
13
}
aria
-
hidden=
"true"
/>
</
button
>
))
}
</
div
>
</
div
>
)
}
</
div
>
)
}
</
div
>
</
div
>
))
)
}
</
div
>
{
/* Fixed quick prompts and input composer */
}
<
div
className=
"fixed inset-x-4 z-[100] mx-auto flex max-w-[54rem] flex-col gap-1"
style=
{
{
bottom
:
"calc(var(--zaui-safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) + 12px)"
,
}
}
>
{
messages
.
length
>
0
&&
(
<
div
className=
"flex gap-1.5 overflow-x-auto px-0.5 py-1 no-scrollbar"
>
{
([
"spending"
,
"reduce"
,
"anomalies"
,
"saving"
]
as
const
).
map
((
key
)
=>
(
<
button
key=
{
key
}
type=
"button"
className=
"inline-flex shrink-0 items-center gap-1.5 rounded-full border border-clay-highlight/50 bg-clay-bg px-3 py-1 text-xs font-semibold text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out hover:bg-clay-primary/10 focus:outline-none focus:ring-2 focus:ring-clay-primary/35"
onClick=
{
()
=>
handleQuickPrompt
(
key
)
}
>
<
LightbulbIcon
size=
{
15
}
aria
-
hidden=
"true"
className=
"text-clay-warning"
/>
{
t
(
`ai.chat.quickPrompts.${key}`
)
}
</
button
>
))
}
</
div
>
)
}
<
Card
className=
"flex w-full items-center gap-2 border border-clay-highlight/40 p-2"
>
<
div
className=
"min-w-0 flex-1"
>
<
Input
aria
-
label=
{
t
(
"ai.chat.placeholder"
)
}
placeholder=
{
t
(
"ai.chat.placeholder"
)
}
value=
{
questionInput
}
onChange=
{
(
e
)
=>
setQuestionInput
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
"Enter"
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
}
}
disabled=
{
chatMutation
.
isPending
||
retryCountdown
!==
null
}
className=
"border-none text-sm shadow-none focus:ring-0"
/>
</
div
>
{
messages
.
length
>
0
&&
(
<
Button
variant=
"ghost"
className=
"p-2 text-clay-text-muted hover:text-clay-expense text-xs font-bold shrink-0"
onClick=
{
clearMessages
}
disabled=
{
chatMutation
.
isPending
}
aria
-
label=
{
t
(
"ai.chat.clearHistory"
)
}
title=
{
t
(
"ai.chat.clearHistory"
)
}
>
<
PlusIcon
size=
{
18
}
aria
-
hidden=
"true"
/>
</
Button
>
)
}
<
Button
variant=
"primary"
className=
"px-4 py-2 text-xs font-bold shrink-0"
onClick=
{
()
=>
handleSend
()
}
disabled=
{
!
questionInput
.
trim
()
||
chatMutation
.
isPending
||
retryCountdown
!==
null
}
>
{
chatMutation
.
isPending
?
t
(
"common.processing"
)
:
t
(
"ai.chat.send"
)
}
</
Button
>
</
Card
>
</
div
>
</
div
>
);
};
src/pages/ai-assistant/components/AIErrorState.tsx
0 → 100644
View file @
a1c7ad10
import
React
from
"react"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
NotificationIcon
}
from
"@/components/ui/icons"
;
import
{
useI18n
}
from
"@/i18n"
;
import
{
AIAssistantError
}
from
"@/services/ai-assistant.service"
;
interface
AIErrorStateProps
{
error
:
Error
|
null
;
onRetry
?:
()
=>
void
;
showIcon
?:
boolean
;
}
export
const
AIErrorState
:
React
.
FC
<
AIErrorStateProps
>
=
({
error
,
onRetry
,
showIcon
=
true
})
=>
{
const
{
t
}
=
useI18n
();
const
isRateLimit
=
(
error
as
AIAssistantError
)?.
status
===
429
;
const
is503NotConfigured
=
(
error
as
AIAssistantError
)?.
code
===
"AI_PROVIDER_NOT_CONFIGURED"
;
let
title
=
t
(
"common.error"
);
let
message
=
error
?.
message
||
t
(
"ai.errors.unavailable"
);
if
(
isRateLimit
)
{
message
=
t
(
"ai.errors.rateLimit"
);
}
else
if
(
is503NotConfigured
)
{
message
=
t
(
"ai.errors.notConfigured"
);
}
return
(
<
Card
className=
"p-6 text-center border border-clay-expense/30 bg-clay-expense/5"
>
{
showIcon
&&
(
<
div
className=
"mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-clay-expense/15 text-clay-expense font-bold text-xl shadow-clay-raised"
>
<
NotificationIcon
size=
{
25
}
aria
-
hidden=
"true"
/>
</
div
>
)
}
<
h3
className=
"font-baloo text-lg font-bold text-clay-text mb-1"
>
{
title
}
</
h3
>
<
p
className=
"clay-caption max-w-md mx-auto mb-4 text-clay-text-muted"
>
{
message
}
</
p
>
{
onRetry
&&
!
isRateLimit
&&
(
<
Button
variant=
"secondary"
className=
"px-5 text-xs font-bold"
onClick=
{
onRetry
}
>
{
t
(
"common.retry"
)
}
</
Button
>
)
}
</
Card
>
);
};
src/pages/ai-assistant/components/AIInsightsView.tsx
0 → 100644
View file @
a1c7ad10
import
React
,
{
useState
}
from
"react"
;
import
{
useNavigate
}
from
"zmp-ui"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
Select
}
from
"@/components/ui/Select"
;
import
{
AIAssistantIcon
,
BudgetIcon
,
ChevronRightIcon
,
NotificationIcon
,
ReportIcon
,
SavingGoalIcon
,
}
from
"@/components/ui/icons"
;
import
{
useI18n
}
from
"@/i18n"
;
import
{
useAIInsights
,
useAIRecommendations
}
from
"@/hooks/use-ai-assistant"
;
import
{
FinWiseNavigationState
}
from
"@/lib/navigation-state"
;
import
{
AIFocusArea
,
AIRecommendationPriority
,
PriorityLevel
,
SeverityLevel
,
TrendDirection
,
}
from
"@/types/ai"
;
import
{
AISkeleton
}
from
"./AISkeleton"
;
import
{
AIErrorState
}
from
"./AIErrorState"
;
type
AIInsightsTab
=
NonNullable
<
FinWiseNavigationState
[
"insightsTab"
]
>
;
interface
AIInsightsViewProps
{
initialActiveTab
?:
AIInsightsTab
;
}
export
const
AIInsightsView
:
React
.
FC
<
AIInsightsViewProps
>
=
({
initialActiveTab
=
"insights"
,
})
=>
{
const
navigate
=
useNavigate
();
const
{
t
,
formatCurrency
}
=
useI18n
();
const
[
focus
,
setFocus
]
=
useState
<
AIFocusArea
>
(
"ALL"
);
const
[
priority
,
setPriority
]
=
useState
<
AIRecommendationPriority
>
(
"BALANCED"
);
const
[
activeTab
,
setActiveTab
]
=
useState
<
AIInsightsTab
>
(
initialActiveTab
);
const
selectTab
=
(
nextTab
:
AIInsightsTab
)
=>
{
setActiveTab
(
nextTab
);
navigate
(
"/ai-assistant"
,
{
replace
:
true
,
state
:
{
tab
:
"insights"
,
insightsTab
:
nextTab
}
satisfies
FinWiseNavigationState
,
});
};
const
insightsQuery
=
useAIInsights
({
focus
},
activeTab
===
"insights"
);
const
recommendationsQuery
=
useAIRecommendations
(
{
priority
},
activeTab
===
"recommendations"
);
const
isLoading
=
activeTab
===
"insights"
?
insightsQuery
.
isLoading
:
recommendationsQuery
.
isLoading
;
const
isError
=
activeTab
===
"insights"
?
insightsQuery
.
isError
:
recommendationsQuery
.
isError
;
const
error
=
activeTab
===
"insights"
?
insightsQuery
.
error
:
recommendationsQuery
.
error
;
const
insights
=
insightsQuery
.
data
?.
data
;
const
recs
=
recommendationsQuery
.
data
?.
data
;
const
insightsHasItems
=
Boolean
(
insights
&&
(
insights
.
trends
.
length
>
0
||
insights
.
anomalies
.
length
>
0
||
insights
.
recommendations
.
length
>
0
)
);
const
recommendationsHaveItems
=
Boolean
(
recs
&&
(
recs
.
budgetRecommendations
.
length
>
0
||
recs
.
savingRecommendations
.
length
>
0
||
recs
.
actions
.
length
>
0
)
);
const
getTrendBadge
=
(
direction
:
TrendDirection
)
=>
{
return
(
<
span
className=
"shrink-0 rounded-full border border-clay-info/30 bg-clay-info/15 px-2.5 py-0.5 text-xs font-bold text-clay-info"
>
{
t
(
`ai.insights.trendDirection.${direction}`
)
}
</
span
>
);
};
const
getSeverityBadge
=
(
severity
:
SeverityLevel
)
=>
{
switch
(
severity
)
{
case
"HIGH"
:
return
<
span
className=
"shrink-0 rounded-full bg-clay-expense px-2.5 py-0.5 text-xs font-bold text-clay-on-status shadow-clay-raised"
>
{
t
(
"ai.insights.severity.HIGH"
)
}
</
span
>;
case
"MEDIUM"
:
return
<
span
className=
"shrink-0 rounded-full bg-clay-warning px-2.5 py-0.5 text-xs font-bold text-clay-on-status shadow-clay-raised"
>
{
t
(
"ai.insights.severity.MEDIUM"
)
}
</
span
>;
default
:
return
<
span
className=
"shrink-0 rounded-full border border-clay-info/30 bg-clay-info/20 px-2.5 py-0.5 text-xs font-bold text-clay-info"
>
{
t
(
"ai.insights.severity.LOW"
)
}
</
span
>;
}
};
const
getPriorityBadge
=
(
priorityLevel
:
PriorityLevel
)
=>
{
const
className
=
priorityLevel
===
"HIGH"
?
"bg-clay-expense text-clay-on-status"
:
priorityLevel
===
"MEDIUM"
?
"bg-clay-warning text-clay-on-status"
:
"border border-clay-info/30 bg-clay-info/20 text-clay-info"
;
return
(
<
span
className=
{
`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-bold ${className}`
}
>
{
t
(
`ai.insights.priorityLevel.${priorityLevel}`
)
}
</
span
>
);
};
return
(
<
div
className=
"flex flex-col gap-4 pb-8"
>
{
/* Sub-tab switcher */
}
<
div
className=
"flex rounded-clay border border-clay-highlight/30 bg-clay-bg p-1 shadow-clay-pressed"
role=
"tablist"
aria
-
label=
{
t
(
"ai.insights.tabsLabel"
)
}
>
<
button
id=
"ai-insights-tab-analysis"
type=
"button"
role=
"tab"
aria
-
selected=
{
activeTab
===
"insights"
}
aria
-
controls=
"ai-insights-panel"
className=
{
`flex-1 rounded-clay-sm px-2 py-2 text-xs font-bold transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 ${
activeTab === "insights"
? "bg-clay-primary text-clay-on-primary shadow-clay-raised"
: "text-clay-text-muted hover:text-clay-text"
}`
}
onClick=
{
()
=>
selectTab
(
"insights"
)
}
>
{
t
(
"ai.insights.analysisTab"
)
}
</
button
>
<
button
id=
"ai-insights-tab-recommendations"
type=
"button"
role=
"tab"
aria
-
selected=
{
activeTab
===
"recommendations"
}
aria
-
controls=
"ai-recommendations-panel"
className=
{
`flex-1 rounded-clay-sm px-2 py-2 text-xs font-bold transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 ${
activeTab === "recommendations"
? "bg-clay-primary text-clay-on-primary shadow-clay-raised"
: "text-clay-text-muted hover:text-clay-text"
}`
}
onClick=
{
()
=>
selectTab
(
"recommendations"
)
}
>
{
t
(
"ai.insights.recommendationsTab"
)
}
</
button
>
</
div
>
{
/* Filter Select Controls */
}
<
Card
className=
"p-3 border border-clay-highlight/40"
>
{
activeTab
===
"insights"
?
(
<
Select
label=
{
t
(
"ai.insights.focus"
)
}
value=
{
focus
}
onChange=
{
(
e
)
=>
setFocus
(
e
.
target
.
value
as
AIFocusArea
)
}
options=
{
[
{
value
:
"ALL"
,
label
:
t
(
"ai.insights.focusOptions.ALL"
)
},
{
value
:
"SPENDING"
,
label
:
t
(
"ai.insights.focusOptions.SPENDING"
)
},
{
value
:
"INCOME"
,
label
:
t
(
"ai.insights.focusOptions.INCOME"
)
},
{
value
:
"CASH_FLOW"
,
label
:
t
(
"ai.insights.focusOptions.CASH_FLOW"
)
},
]
}
/>
)
:
(
<
Select
label=
{
t
(
"ai.insights.priority"
)
}
value=
{
priority
}
onChange=
{
(
e
)
=>
setPriority
(
e
.
target
.
value
as
AIRecommendationPriority
)
}
options=
{
[
{
value
:
"BALANCED"
,
label
:
t
(
"ai.insights.priorityOptions.BALANCED"
)
},
{
value
:
"REDUCE_SPENDING"
,
label
:
t
(
"ai.insights.priorityOptions.REDUCE_SPENDING"
)
},
{
value
:
"GROW_SAVINGS"
,
label
:
t
(
"ai.insights.priorityOptions.GROW_SAVINGS"
)
},
]
}
/>
)
}
</
Card
>
{
/* Loading State */
}
{
isLoading
&&
<
AISkeleton
rows=
{
4
}
/>
}
{
/* Error State */
}
{
isError
&&
(
<
AIErrorState
error=
{
error
}
onRetry=
{
()
=>
{
if
(
activeTab
===
"insights"
)
insightsQuery
.
refetch
();
else
recommendationsQuery
.
refetch
();
}
}
/>
)
}
{
/* Insights Content */
}
{
!
isLoading
&&
!
isError
&&
activeTab
===
"insights"
&&
insights
&&
(
<
div
id=
"ai-insights-panel"
className=
"flex flex-col gap-4"
role=
"tabpanel"
aria
-
labelledby=
"ai-insights-tab-analysis"
>
{
/* Summary Card */
}
<
Card
className=
"p-5 border-l-4 border-l-clay-primary"
>
<
h3
className=
"mb-1 flex items-center gap-2 font-baloo text-base font-bold text-clay-primary"
>
<
ReportIcon
size=
{
18
}
aria
-
hidden=
"true"
/>
{
t
(
"ai.insights.title"
)
}
</
h3
>
<
p
className=
"font-nunito text-xs text-clay-text leading-relaxed whitespace-pre-wrap"
>
{
insights
.
summary
}
</
p
>
</
Card
>
{
/* Spending Trends Section */
}
{
insights
.
trends
.
length
>
0
&&
(
<
div
className=
"flex flex-col gap-2"
>
<
h4
className=
"font-baloo text-sm font-bold text-clay-text px-1"
>
{
t
(
"ai.insights.sections.trends"
)
}
</
h4
>
<
div
className=
"grid grid-cols-1 gap-3"
>
{
insights
.
trends
.
map
((
trend
,
i
)
=>
(
<
Card
key=
{
i
}
className=
"p-4 border border-clay-highlight/30"
>
<
div
className=
"mb-2 flex flex-wrap items-center justify-between gap-2"
>
<
h5
className=
"min-w-0 break-words font-nunito text-sm font-bold text-clay-text"
>
{
trend
.
title
}
</
h5
>
{
getTrendBadge
(
trend
.
direction
)
}
</
div
>
<
p
className=
"font-nunito text-xs text-clay-text-muted mb-2"
>
{
trend
.
description
}
</
p
>
<
div
className=
"flex items-start gap-2 rounded-clay-sm border border-clay-primary/20 bg-clay-bg p-2 text-[11px] font-semibold text-clay-primary"
>
<
ReportIcon
size=
{
14
}
aria
-
hidden=
"true"
className=
"mt-0.5 shrink-0"
/>
<
span
>
{
trend
.
evidence
}
</
span
>
</
div
>
</
Card
>
))
}
</
div
>
</
div
>
)
}
{
/* Anomalies Section */
}
{
insights
.
anomalies
.
length
>
0
&&
(
<
div
className=
"flex flex-col gap-2"
>
<
h4
className=
"font-baloo text-sm font-bold text-clay-text px-1"
>
{
t
(
"ai.insights.sections.anomalies"
)
}
</
h4
>
<
div
className=
"grid grid-cols-1 gap-3"
>
{
insights
.
anomalies
.
map
((
item
,
i
)
=>
(
<
Card
key=
{
i
}
className=
"p-4 border-l-4 border-l-clay-warning"
>
<
div
className=
"mb-2 flex flex-wrap items-center justify-between gap-2"
>
<
h5
className=
"min-w-0 break-words font-nunito text-sm font-bold text-clay-text"
>
{
item
.
title
}
</
h5
>
{
getSeverityBadge
(
item
.
severity
)
}
</
div
>
<
p
className=
"font-nunito text-xs text-clay-text-muted mb-2"
>
{
item
.
description
}
</
p
>
<
div
className=
"flex items-start gap-2 rounded-clay-sm border border-clay-warning/30 bg-clay-warning/10 p-2 text-[11px] font-semibold text-clay-text"
>
<
NotificationIcon
size=
{
14
}
aria
-
hidden=
"true"
className=
"mt-0.5 shrink-0 text-clay-warning"
/>
<
span
>
{
item
.
evidence
}
</
span
>
</
div
>
</
Card
>
))
}
</
div
>
</
div
>
)
}
{
/* Improvement Recommendations Section */
}
{
insights
.
recommendations
.
length
>
0
&&
(
<
div
className=
"flex flex-col gap-2"
>
<
h4
className=
"flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text"
>
<
AIAssistantIcon
size=
{
17
}
aria
-
hidden=
"true"
className=
"text-clay-primary"
/>
{
t
(
"ai.insights.sections.recommendations"
)
}
</
h4
>
<
div
className=
"grid grid-cols-1 gap-3"
>
{
insights
.
recommendations
.
map
((
recommendation
,
index
)
=>
(
<
Card
key=
{
index
}
className=
"p-4 border border-clay-highlight/30"
>
<
div
className=
"mb-2 flex flex-wrap items-center justify-between gap-2"
>
<
h5
className=
"min-w-0 font-nunito text-sm font-bold text-clay-text"
>
{
recommendation
.
title
}
</
h5
>
{
getPriorityBadge
(
recommendation
.
priority
)
}
</
div
>
<
p
className=
"font-nunito text-xs text-clay-text-muted"
>
{
recommendation
.
description
}
</
p
>
</
Card
>
))
}
</
div
>
</
div
>
)
}
{
!
insightsHasItems
&&
(
<
Card
className=
"p-5 text-center"
>
<
p
className=
"font-nunito text-sm font-bold text-clay-text"
>
{
t
(
"ai.insights.empty"
)
}
</
p
>
<
p
className=
"mt-1 font-nunito text-xs text-clay-text-muted"
>
{
t
(
"ai.insights.emptyHint"
)
}
</
p
>
</
Card
>
)
}
</
div
>
)
}
{
/* Recommendations Content */
}
{
!
isLoading
&&
!
isError
&&
activeTab
===
"recommendations"
&&
recs
&&
(
<
div
id=
"ai-recommendations-panel"
className=
"flex flex-col gap-4"
role=
"tabpanel"
aria
-
labelledby=
"ai-insights-tab-recommendations"
>
{
/* Summary Card */
}
<
Card
className=
"p-5 border-l-4 border-l-clay-income"
>
<
h3
className=
"mb-1 flex items-center gap-2 font-baloo text-base font-bold text-clay-income"
>
<
AIAssistantIcon
size=
{
18
}
aria
-
hidden=
"true"
/>
{
t
(
"ai.insights.sections.recommendations"
)
}
</
h3
>
<
p
className=
"font-nunito text-xs text-clay-text leading-relaxed whitespace-pre-wrap"
>
{
recs
.
summary
}
</
p
>
</
Card
>
{
/* Budget Recommendations */
}
{
recs
.
budgetRecommendations
.
length
>
0
&&
(
<
div
className=
"flex flex-col gap-2"
>
<
h4
className=
"flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text"
>
<
BudgetIcon
size=
{
17
}
aria
-
hidden=
"true"
/>
{
t
(
"ai.insights.sections.budgetRecs"
)
}
</
h4
>
<
div
className=
"grid grid-cols-1 gap-3"
>
{
recs
.
budgetRecommendations
.
map
((
bRec
,
i
)
=>
(
<
Card
key=
{
i
}
className=
"p-4 flex flex-col gap-2"
>
<
div
className=
"flex flex-wrap items-center justify-between gap-2"
>
<
span
className=
"min-w-0 break-words font-nunito text-sm font-bold text-clay-text"
>
{
bRec
.
categoryName
||
t
(
"common.default"
)
}
</
span
>
<
span
className=
"break-words text-right font-baloo text-sm font-bold text-clay-primary"
>
{
formatCurrency
(
Number
(
bRec
.
suggestedLimit
),
bRec
.
currency
)
}
</
span
>
</
div
>
<
p
className=
"font-nunito text-xs text-clay-text-muted"
>
{
bRec
.
rationale
}
</
p
>
<
Button
variant=
"secondary"
className=
"mt-1 gap-1 self-end px-3 py-1 text-xs font-bold"
onClick=
{
()
=>
navigate
(
"/budgets"
,
{
state
:
{
fromAIRecommendations
:
true
}
satisfies
FinWiseNavigationState
,
})
}
>
{
t
(
"ai.insights.applyBudget"
)
}
<
ChevronRightIcon
size=
{
14
}
aria
-
hidden=
"true"
/>
</
Button
>
</
Card
>
))
}
</
div
>
</
div
>
)
}
{
/* Saving Goal Recommendations */
}
{
recs
.
savingRecommendations
.
length
>
0
&&
(
<
div
className=
"flex flex-col gap-2"
>
<
h4
className=
"flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text"
>
<
SavingGoalIcon
size=
{
17
}
aria
-
hidden=
"true"
/>
{
t
(
"ai.insights.sections.savingRecs"
)
}
</
h4
>
<
div
className=
"grid grid-cols-1 gap-3"
>
{
recs
.
savingRecommendations
.
map
((
sRec
,
i
)
=>
(
<
Card
key=
{
i
}
className=
"p-4 flex flex-col gap-2"
>
<
div
className=
"flex flex-wrap items-center justify-between gap-2"
>
<
span
className=
"min-w-0 break-words font-nunito text-sm font-bold text-clay-text"
>
{
sRec
.
goalName
||
t
(
"home.savingGoals"
)
}
</
span
>
<
span
className=
"break-words text-right font-baloo text-sm font-bold text-clay-income"
>
+
{
formatCurrency
(
Number
(
sRec
.
suggestedMonthlyContribution
),
sRec
.
currency
)
}
/
{
t
(
"reminder.frequencyOption.MONTHLY"
).
toLowerCase
()
}
</
span
>
</
div
>
<
p
className=
"font-nunito text-xs text-clay-text-muted"
>
{
sRec
.
rationale
}
</
p
>
<
Button
variant=
"secondary"
className=
"mt-1 gap-1 self-end px-3 py-1 text-xs font-bold"
onClick=
{
()
=>
navigate
(
"/saving-goals"
,
{
state
:
{
fromAIRecommendations
:
true
}
satisfies
FinWiseNavigationState
,
})
}
>
{
t
(
"ai.insights.applySaving"
)
}
<
ChevronRightIcon
size=
{
14
}
aria
-
hidden=
"true"
/>
</
Button
>
</
Card
>
))
}
</
div
>
</
div
>
)
}
{
/* Priority Actions */
}
{
recs
.
actions
.
length
>
0
&&
(
<
div
className=
"flex flex-col gap-2"
>
<
h4
className=
"flex items-center gap-2 px-1 font-baloo text-sm font-bold text-clay-text"
>
<
NotificationIcon
size=
{
17
}
aria
-
hidden=
"true"
className=
"text-clay-primary"
/>
{
t
(
"ai.insights.sections.actionRecs"
)
}
</
h4
>
<
div
className=
"grid grid-cols-1 gap-3"
>
{
recs
.
actions
.
map
((
action
,
index
)
=>
(
<
Card
key=
{
index
}
className=
"p-4 border border-clay-highlight/30"
>
<
div
className=
"mb-2 flex flex-wrap items-center justify-between gap-2"
>
<
h5
className=
"min-w-0 font-nunito text-sm font-bold text-clay-text"
>
{
action
.
title
}
</
h5
>
{
getPriorityBadge
(
action
.
priority
)
}
</
div
>
<
p
className=
"font-nunito text-xs text-clay-text-muted"
>
{
action
.
description
}
</
p
>
</
Card
>
))
}
</
div
>
</
div
>
)
}
{
!
recommendationsHaveItems
&&
(
<
Card
className=
"p-5 text-center"
>
<
p
className=
"font-nunito text-sm font-bold text-clay-text"
>
{
t
(
"ai.insights.empty"
)
}
</
p
>
<
p
className=
"mt-1 font-nunito text-xs text-clay-text-muted"
>
{
t
(
"ai.insights.emptyHint"
)
}
</
p
>
</
Card
>
)
}
</
div
>
)
}
</
div
>
);
};
src/pages/ai-assistant/components/AISkeleton.tsx
0 → 100644
View file @
a1c7ad10
import
React
from
"react"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
useI18n
}
from
"@/i18n"
;
export
const
AISkeleton
:
React
.
FC
<
{
rows
?:
number
}
>
=
({
rows
=
3
})
=>
{
const
{
t
}
=
useI18n
();
return
(
<
div
className=
"flex flex-col gap-4 animate-pulse"
role=
"status"
aria
-
label=
{
t
(
"common.loading"
)
}
>
<
Card
className=
"p-5"
>
<
div
className=
"flex items-center gap-3 mb-4"
>
<
div
className=
"h-10 w-10 rounded-clay bg-clay-primary/20"
/>
<
div
className=
"flex-1 space-y-2"
>
<
div
className=
"h-4 w-1/3 rounded-full bg-clay-text/15"
/>
<
div
className=
"h-3 w-1/2 rounded-full bg-clay-text/10"
/>
</
div
>
</
div
>
<
div
className=
"space-y-3"
>
{
Array
.
from
({
length
:
rows
}).
map
((
_
,
i
)
=>
(
<
div
key=
{
i
}
className=
"h-12 w-full rounded-clay bg-clay-bg/60 border border-clay-highlight/30"
/>
))
}
</
div
>
</
Card
>
</
div
>
);
};
src/pages/ai-assistant/components/ReceiptScannerView.tsx
0 → 100644
View file @
a1c7ad10
import
React
,
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
api
from
"zmp-sdk"
;
import
{
useNavigate
}
from
"zmp-ui"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
useI18n
}
from
"@/i18n"
;
import
{
useExtractReceipt
}
from
"@/hooks/use-ai-assistant"
;
import
{
ExtractReceiptData
}
from
"@/types/ai"
;
import
{
AIErrorState
}
from
"./AIErrorState"
;
export
const
ReceiptScannerView
:
React
.
FC
=
()
=>
{
const
navigate
=
useNavigate
();
const
{
t
}
=
useI18n
();
const
extractMutation
=
useExtractReceipt
();
const
fileInputRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
[
selectedFile
,
setSelectedFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
previewUrl
,
setPreviewUrl
]
=
useState
<
string
|
null
>
(
null
);
const
[
extractedData
,
setExtractedData
]
=
useState
<
ExtractReceiptData
|
null
>
(
null
);
const
[
fileError
,
setFileError
]
=
useState
<
string
|
null
>
(
null
);
useEffect
(()
=>
{
return
()
=>
{
if
(
previewUrl
)
URL
.
revokeObjectURL
(
previewUrl
);
};
},
[
previewUrl
]);
const
handleFileSelected
=
(
file
:
File
)
=>
{
extractMutation
.
reset
();
setFileError
(
null
);
if
(
file
.
size
>
5
*
1024
*
1024
)
{
setFileError
(
t
(
"validation.receiptSizeLimit"
));
return
;
}
const
allowedTypes
=
[
"image/jpeg"
,
"image/png"
,
"image/webp"
,
"application/pdf"
];
if
(
!
allowedTypes
.
includes
(
file
.
type
))
{
setFileError
(
t
(
"validation.receiptTypeInvalid"
));
return
;
}
setSelectedFile
(
file
);
if
(
file
.
type
.
startsWith
(
"image/"
))
{
setPreviewUrl
(
URL
.
createObjectURL
(
file
));
}
else
{
setPreviewUrl
(
null
);
}
setExtractedData
(
null
);
// Auto trigger extraction
extractMutation
.
mutate
(
{
file
},
{
onSuccess
:
(
res
)
=>
{
if
(
res
.
success
&&
res
.
data
)
{
setExtractedData
(
res
.
data
);
}
},
}
);
};
const
handleInputChange
=
(
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
const
file
=
e
.
target
.
files
?.[
0
];
if
(
file
)
handleFileSelected
(
file
);
};
const
handleCameraCapture
=
async
()
=>
{
try
{
if
(
typeof
api
!==
"undefined"
&&
typeof
(
api
as
any
).
openCamera
===
"function"
)
{
const
result
=
await
(
api
as
any
).
openCamera
({
type
:
"photo"
});
if
(
result
?.
imagePath
)
{
const
response
=
await
fetch
(
result
.
imagePath
);
const
blob
=
await
response
.
blob
();
const
file
=
new
File
([
blob
],
`receipt_
${
Date
.
now
()}
.jpg`
,
{
type
:
"image/jpeg"
});
handleFileSelected
(
file
);
return
;
}
}
fileInputRef
.
current
?.
click
();
}
catch
(
error
)
{
fileInputRef
.
current
?.
click
();
}
};
const
handleConfirmTransaction
=
()
=>
{
if
(
!
extractedData
)
return
;
const
prefillData
=
{
amount
:
extractedData
.
totalAmount
||
""
,
type
:
"EXPENSE"
as
const
,
categoryId
:
extractedData
.
category
?.
id
||
""
,
date
:
extractedData
.
transactionDate
||
""
,
description
:
extractedData
.
merchant
?
`
${
extractedData
.
merchant
}${
extractedData
.
lineItems
.
length
>
0
?
` (
${
t
(
"ai.ocr.itemCount"
,
{
count
:
extractedData
.
lineItems
.
length
})}
)`
:
""
}
`
:
""
,
location
:
extractedData
.
merchant
||
""
,
};
navigate
(
"/transactions"
,
{
state
:
{
prefill
:
prefillData
,
receiptFile
:
selectedFile
,
},
});
};
const
resetScan
=
()
=>
{
extractMutation
.
reset
();
setSelectedFile
(
null
);
setPreviewUrl
(
null
);
setExtractedData
(
null
);
setFileError
(
null
);
if
(
fileInputRef
.
current
)
fileInputRef
.
current
.
value
=
""
;
};
const
confidenceClassName
=
extractedData
?
extractedData
.
confidence
>=
0.8
?
"border-clay-income/30 bg-clay-income/20 text-clay-income"
:
extractedData
.
confidence
>=
0.5
?
"border-clay-warning/30 bg-clay-warning/20 text-clay-warning"
:
"border-clay-expense/30 bg-clay-expense/20 text-clay-expense"
:
""
;
return
(
<
div
className=
"flex flex-col gap-4 pb-8"
>
{
/* Upload Header Card */
}
<
Card
className=
"p-5 text-center border border-clay-highlight/40"
>
<
h3
className=
"mb-1 text-center font-baloo text-lg font-bold leading-tight text-clay-text"
>
{
t
(
"ai.ocr.title"
)
}
</
h3
>
<
p
className=
"clay-caption max-w-sm mx-auto mb-4"
>
{
t
(
"ai.ocr.subtitle"
)
}
</
p
>
<
input
type=
"file"
ref=
{
fileInputRef
}
accept=
"image/jpeg,image/png,image/webp,application/pdf"
className=
"hidden"
onChange=
{
handleInputChange
}
disabled=
{
extractMutation
.
isPending
}
/>
{
!
selectedFile
?
(
<
div
className=
"flex flex-col sm:flex-row items-center justify-center gap-3"
>
<
Button
variant=
"primary"
className=
"w-full px-5 py-2.5 text-xs font-bold sm:w-auto"
onClick=
{
handleCameraCapture
}
disabled=
{
extractMutation
.
isPending
}
>
{
t
(
"ai.ocr.takePhoto"
)
}
</
Button
>
<
Button
variant=
"secondary"
className=
"w-full px-5 py-2.5 text-xs font-bold sm:w-auto"
onClick=
{
()
=>
fileInputRef
.
current
?.
click
()
}
disabled=
{
extractMutation
.
isPending
}
>
{
t
(
"ai.ocr.selectFile"
)
}
</
Button
>
</
div
>
)
:
(
<
div
className=
"flex items-center justify-between rounded-clay bg-clay-bg p-3 shadow-clay-pressed border border-clay-highlight/30"
>
<
div
className=
"flex items-center gap-3 min-w-0"
>
{
previewUrl
?
(
<
img
src=
{
previewUrl
}
alt=
{
t
(
"ai.ocr.previewAlt"
)
}
className=
"h-12 w-12 rounded-clay-sm object-cover border border-clay-primary/30"
/>
)
:
(
<
div
className=
"flex h-12 w-12 items-center justify-center rounded-clay-sm bg-clay-primary/10 font-baloo text-xs font-bold text-clay-primary shadow-clay-pressed"
>
PDF
</
div
>
)
}
<
div
className=
"flex flex-col text-left min-w-0"
>
<
span
className=
"font-nunito text-xs font-bold text-clay-text truncate"
>
{
selectedFile
.
name
}
</
span
>
<
span
className=
"clay-caption text-[10px]"
>
{
(
selectedFile
.
size
/
1024
/
1024
).
toFixed
(
2
)
}
MB
</
span
>
</
div
>
</
div
>
<
Button
variant=
"ghost"
className=
"px-2 py-1 text-xs font-bold text-clay-expense"
onClick=
{
resetScan
}
disabled=
{
extractMutation
.
isPending
}
>
{
t
(
"common.delete"
)
}
</
Button
>
</
div
>
)
}
{
fileError
&&
(
<
p
className=
"mt-2 text-center font-nunito text-xs font-semibold text-clay-expense"
>
{
fileError
}
</
p
>
)
}
</
Card
>
{
/* Extracting Loading Indicator */
}
{
extractMutation
.
isPending
&&
(
<
Card
className=
"p-8 text-center animate-pulse border border-clay-primary/30"
>
<
h4
className=
"font-baloo text-base font-bold text-clay-primary mb-1"
>
{
t
(
"ai.ocr.extracting"
)
}
</
h4
>
<
p
className=
"clay-caption text-xs"
>
{
t
(
"ai.ocr.extractingHint"
)
}
</
p
>
</
Card
>
)
}
{
/* Extraction Error */
}
{
extractMutation
.
isError
&&
(
<
AIErrorState
error=
{
extractMutation
.
error
}
showIcon=
{
false
}
onRetry=
{
()
=>
selectedFile
&&
extractMutation
.
mutate
({
file
:
selectedFile
})
}
/>
)
}
{
/* OCR Result View */
}
{
extractedData
&&
(
<
div
className=
"flex flex-col gap-4"
>
{
/* Main Attributes Card */
}
<
Card
className=
"p-5 border-2 border-clay-primary/30 relative"
>
<
div
className=
"mb-4 flex flex-wrap items-center justify-between gap-2"
>
<
h4
className=
"min-w-0 font-baloo text-base font-bold text-clay-text"
>
{
t
(
"ai.ocr.resultTitle"
)
}
</
h4
>
<
span
className=
{
`shrink-0 rounded-full border px-3 py-1 font-baloo text-xs font-bold shadow-clay-pressed ${confidenceClassName}`
}
>
{
t
(
"ai.ocr.confidence"
)
}
:
{
Math
.
round
(
extractedData
.
confidence
*
100
)
}
%
</
span
>
</
div
>
<
div
className=
"mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2"
>
<
div
className=
"min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"
>
<
span
className=
"clay-caption block text-[10px] uppercase font-bold text-clay-text-muted"
>
{
t
(
"ai.ocr.merchant"
)
}
</
span
>
<
span
className=
"break-words font-nunito text-sm font-bold text-clay-text"
>
{
extractedData
.
merchant
||
"—"
}
</
span
>
</
div
>
<
div
className=
"min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"
>
<
span
className=
"clay-caption block text-[10px] uppercase font-bold text-clay-text-muted"
>
{
t
(
"ai.ocr.totalAmount"
)
}
</
span
>
<
span
className=
"break-words font-baloo text-base font-bold text-clay-expense"
>
{
extractedData
.
totalAmount
||
"0"
}{
" "
}
{
extractedData
.
currency
||
"VND"
}
</
span
>
</
div
>
<
div
className=
"rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"
>
<
span
className=
"clay-caption block text-[10px] uppercase font-bold text-clay-text-muted"
>
{
t
(
"ai.ocr.date"
)
}
</
span
>
<
span
className=
"font-nunito text-sm font-bold text-clay-text"
>
{
extractedData
.
transactionDate
||
"—"
}
</
span
>
</
div
>
<
div
className=
"rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"
>
<
span
className=
"clay-caption block text-[10px] uppercase font-bold text-clay-text-muted"
>
{
t
(
"ai.ocr.category"
)
}
</
span
>
<
span
className=
"font-nunito text-sm font-bold text-clay-primary"
>
{
extractedData
.
category
?.
name
||
t
(
"common.default"
)
}
</
span
>
</
div
>
<
div
className=
"rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed sm:col-span-2"
>
<
span
className=
"clay-caption block text-[10px] uppercase font-bold text-clay-text-muted"
>
{
t
(
"ai.ocr.taxAmount"
)
}
</
span
>
<
span
className=
"break-words font-baloo text-sm font-bold text-clay-text"
>
{
extractedData
.
taxAmount
||
"—"
}
{
extractedData
.
taxAmount
?
` ${extractedData.currency || "VND"}`
:
""
}
</
span
>
</
div
>
</
div
>
{
/* Line Items Table */
}
{
extractedData
.
lineItems
.
length
>
0
&&
(
<
div
className=
"mt-2 border-t border-clay-highlight/40 pt-3"
>
<
h5
className=
"mb-2 font-nunito text-xs font-bold text-clay-text"
>
{
t
(
"ai.ocr.lineItems"
)
}
(
{
extractedData
.
lineItems
.
length
}
)
</
h5
>
<
div
className=
"overflow-x-auto"
>
<
table
className=
"w-full text-left text-xs border-collapse"
>
<
thead
>
<
tr
className=
"border-b border-clay-highlight/40 text-clay-text-muted"
>
<
th
className=
"pb-1.5 font-semibold"
>
{
t
(
"ai.ocr.item"
)
}
</
th
>
<
th
className=
"pb-1.5 font-semibold text-center"
>
{
t
(
"ai.ocr.quantity"
)
}
</
th
>
<
th
className=
"pb-1.5 font-semibold text-right"
>
{
t
(
"ai.ocr.price"
)
}
</
th
>
<
th
className=
"pb-1.5 font-semibold text-right"
>
{
t
(
"ai.ocr.amount"
)
}
</
th
>
</
tr
>
</
thead
>
<
tbody
className=
"divide-y divide-clay-highlight/20 font-nunito"
>
{
extractedData
.
lineItems
.
map
((
item
,
i
)
=>
(
<
tr
key=
{
i
}
>
<
td
className=
"py-2 text-clay-text font-medium"
>
{
item
.
name
}
</
td
>
<
td
className=
"py-2 text-center text-clay-text-muted"
>
{
item
.
quantity
??
1
}
</
td
>
<
td
className=
"py-2 text-right text-clay-text-muted"
>
{
item
.
unitPrice
||
"—"
}
</
td
>
<
td
className=
"py-2 text-right font-bold text-clay-text"
>
{
item
.
totalAmount
||
"—"
}
</
td
>
</
tr
>
))
}
</
tbody
>
</
table
>
</
div
>
</
div
>
)
}
{
extractedData
.
rawText
&&
(
<
details
className=
"mt-4 rounded-clay-sm border border-clay-highlight/40 bg-clay-bg p-3 shadow-clay-pressed"
>
<
summary
className=
"cursor-pointer font-nunito text-xs font-bold text-clay-text transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35"
>
{
t
(
"ai.ocr.rawText"
)
}
</
summary
>
<
pre
className=
"mt-3 whitespace-pre-wrap break-words font-nunito text-xs leading-relaxed text-clay-text-muted"
>
{
extractedData
.
rawText
}
</
pre
>
</
details
>
)
}
{
/* Warnings */
}
{
extractedData
.
warnings
.
length
>
0
&&
(
<
div
className=
"mt-4 rounded-clay-sm bg-clay-warning/10 p-3 border border-clay-warning/30 text-xs font-medium text-clay-text space-y-1"
>
<
span
className=
"block font-bold text-clay-warning"
>
{
t
(
"ai.ocr.warnings"
)
}
:
</
span
>
{
extractedData
.
warnings
.
map
((
warn
,
i
)
=>
(
<
p
key=
{
i
}
>
•
{
warn
}
</
p
>
))
}
</
div
>
)
}
{
/* Confirm Create Transaction Action */
}
<
div
className=
"mt-5 pt-3 border-t border-clay-highlight/40 flex flex-col sm:flex-row items-center justify-between gap-3"
>
<
span
className=
"clay-caption text-xs text-left"
>
{
t
(
"ai.ocr.confirmHint"
)
}
</
span
>
<
Button
variant=
"primary"
className=
"w-full px-6 py-2.5 text-xs font-bold shadow-clay-raised sm:w-auto"
onClick=
{
handleConfirmTransaction
}
>
{
t
(
"ai.ocr.confirmTransaction"
)
}
</
Button
>
</
div
>
</
Card
>
</
div
>
)
}
</
div
>
);
};
src/pages/ai-assistant/index.tsx
0 → 100644
View file @
a1c7ad10
import
React
,
{
useState
}
from
"react"
;
import
{
Header
,
Page
,
useLocation
,
useNavigate
}
from
"zmp-ui"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
AIAssistantIcon
,
IconGradients
,
}
from
"@/components/ui/icons"
;
import
{
useI18n
}
from
"@/i18n"
;
import
{
FinWiseNavigationState
}
from
"@/lib/navigation-state"
;
import
{
AIChatView
}
from
"./components/AIChatView"
;
import
{
AIInsightsView
}
from
"./components/AIInsightsView"
;
import
{
ReceiptScannerView
}
from
"./components/ReceiptScannerView"
;
type
AITab
=
"chat"
|
"insights"
|
"ocr"
;
const
AIAssistantPage
:
React
.
FC
=
()
=>
{
const
navigate
=
useNavigate
();
const
location
=
useLocation
();
const
{
t
}
=
useI18n
();
const
navigationState
=
location
.
state
as
FinWiseNavigationState
|
null
;
const
initialTab
:
AITab
=
navigationState
?.
tab
||
"chat"
;
const
[
activeTab
,
setActiveTab
]
=
useState
<
AITab
>
(
initialTab
);
const
[
visitedTabs
,
setVisitedTabs
]
=
useState
<
Set
<
AITab
>>
(()
=>
new
Set
([
initialTab
]));
const
selectTab
=
(
nextTab
:
AITab
)
=>
{
setActiveTab
(
nextTab
);
setVisitedTabs
((
current
)
=>
{
if
(
current
.
has
(
nextTab
))
return
current
;
const
next
=
new
Set
(
current
);
next
.
add
(
nextTab
);
return
next
;
});
navigate
(
"/ai-assistant"
,
{
replace
:
true
,
state
:
{
...
navigationState
,
tab
:
nextTab
}
satisfies
FinWiseNavigationState
,
});
};
return
(
<
Page
className=
"page"
>
<
Header
title=
{
t
(
"ai.shortHeader"
)
}
showBackIcon
onBackClick=
{
()
=>
navigate
(
"/"
)
}
/>
<
IconGradients
/>
<
main
className=
"mx-auto mt-4 flex w-full max-w-4xl flex-col gap-4 px-0 pb-12 sm:px-4"
>
{
/* Claymorphism Hero Card */
}
<
Card
className=
"relative overflow-hidden !bg-clay-primary p-5 !text-clay-on-primary shadow-clay-raised"
>
<
div
className=
"absolute -right-8 -top-8 h-32 w-32 rounded-full bg-clay-highlight/20"
/>
<
div
className=
"absolute -bottom-10 left-1/2 h-28 w-28 rounded-full bg-clay-info/20"
/>
<
div
className=
"relative flex items-center gap-4"
>
<
div
className=
"flex h-12 w-12 shrink-0 items-center justify-center rounded-clay bg-clay-surface text-clay-primary shadow-clay-raised"
>
<
AIAssistantIcon
size=
{
28
}
/>
</
div
>
<
div
>
<
h1
className=
"font-baloo text-xl font-bold leading-tight sm:text-2xl"
>
{
t
(
"ai.header"
)
}
</
h1
>
<
p
className=
"mt-0.5 font-nunito text-xs text-clay-on-primary/80"
>
{
t
(
"ai.subtitle"
)
}
</
p
>
</
div
>
</
div
>
</
Card
>
{
/* Tab Switcher */
}
<
div
className=
"flex rounded-clay bg-clay-surface p-1 shadow-clay-raised border border-clay-highlight/40"
role=
"tablist"
aria
-
label=
{
t
(
"ai.tabs.label"
)
}
>
{
(
[
{
key
:
"chat"
,
label
:
t
(
"ai.tabs.chat"
)
},
{
key
:
"insights"
,
label
:
t
(
"ai.tabs.insights"
)
},
{
key
:
"ocr"
,
label
:
t
(
"ai.tabs.ocr"
)
},
]
as
const
).
map
((
tab
)
=>
(
<
button
key=
{
tab
.
key
}
id=
{
`ai-tab-${tab.key}`
}
type=
"button"
role=
"tab"
aria
-
selected=
{
activeTab
===
tab
.
key
}
aria
-
controls=
{
`ai-panel-${tab.key}`
}
className=
{
`flex min-w-0 flex-1 items-center justify-center rounded-clay-sm px-1 py-2.5 text-xs font-bold transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 ${
activeTab === tab.key
? "bg-clay-primary text-clay-on-primary shadow-clay-raised"
: "text-clay-text-muted hover:text-clay-text"
}`
}
onClick=
{
()
=>
selectTab
(
tab
.
key
)
}
>
<
span
className=
"truncate"
>
{
tab
.
label
}
</
span
>
</
button
>
))
}
</
div
>
{
/* Tab View Container */
}
{
visitedTabs
.
has
(
"chat"
)
&&
(
<
div
id=
"ai-panel-chat"
className=
{
activeTab
===
"chat"
?
"mt-1"
:
"hidden"
}
role=
"tabpanel"
aria
-
labelledby=
"ai-tab-chat"
>
<
AIChatView
/>
</
div
>
)
}
{
visitedTabs
.
has
(
"insights"
)
&&
(
<
div
id=
"ai-panel-insights"
className=
{
activeTab
===
"insights"
?
"mt-1"
:
"hidden"
}
role=
"tabpanel"
aria
-
labelledby=
"ai-tab-insights"
>
<
AIInsightsView
initialActiveTab=
{
navigationState
?.
insightsTab
||
"insights"
}
/>
</
div
>
)
}
{
visitedTabs
.
has
(
"ocr"
)
&&
(
<
div
id=
"ai-panel-ocr"
className=
{
activeTab
===
"ocr"
?
"mt-1"
:
"hidden"
}
role=
"tabpanel"
aria
-
labelledby=
"ai-tab-ocr"
>
<
ReceiptScannerView
/>
</
div
>
)
}
</
main
>
</
Page
>
);
};
export
default
AIAssistantPage
;
src/pages/budgets/index.tsx
View file @
a1c7ad10
import
React
,
{
useDeferredValue
,
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
Header
,
Page
,
useNavigate
,
useSnackbar
}
from
"zmp-ui"
;
import
{
Header
,
Page
,
use
Location
,
use
Navigate
,
useSnackbar
}
from
"zmp-ui"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
Input
}
from
"@/components/ui/Input"
;
...
...
@@ -11,6 +11,7 @@ import { useCategoryTree } from "@/hooks/use-categories";
import
{
useI18n
}
from
"@/i18n"
;
import
{
getCategoryDisplayName
}
from
"@/lib/category-format"
;
import
{
getErrorMessage
}
from
"@/lib/error-message"
;
import
{
isFromAIRecommendations
}
from
"@/lib/navigation-state"
;
import
{
BudgetPeriod
,
BudgetQuery
,
...
...
@@ -49,6 +50,7 @@ function flattenExpenseCategories(nodes: CategoryTreeNode[], depth = 0): Array<{
const
BudgetsPage
:
React
.
FC
=
()
=>
{
const
navigate
=
useNavigate
();
const
location
=
useLocation
();
const
{
openSnackbar
}
=
useSnackbar
();
const
{
t
}
=
useI18n
();
const
[
search
,
setSearch
]
=
useState
(
""
);
...
...
@@ -141,7 +143,11 @@ const BudgetsPage: React.FC = () => {
return
(
<
Page
className=
"page"
>
<
Header
title=
{
t
(
"budget.header"
)
}
showBackIcon
onBackClick=
{
()
=>
navigate
(
"/"
)
}
/>
<
Header
title=
{
t
(
"budget.header"
)
}
showBackIcon
onBackClick=
{
()
=>
isFromAIRecommendations
(
location
.
state
)
?
navigate
(
-
1
)
:
navigate
(
"/"
)
}
/>
<
IconGradients
/>
<
main
className=
"mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12"
>
...
...
src/pages/index.tsx
View file @
a1c7ad10
...
...
@@ -55,6 +55,14 @@ function HomePage() {
<
Button
variant=
"primary"
fullWidth
onClick=
{
()
=>
navigate
(
"/ai-assistant"
)
}
className=
"gap-2 bg-clay-primary shadow-clay-raised hover:scale-[1.02] transition-all"
>
{
t
(
"home.aiAssistant"
)
}
</
Button
>
<
Button
variant=
"secondary"
fullWidth
onClick=
{
()
=>
navigate
(
"/wallets"
)
}
className=
"gap-2"
>
...
...
src/pages/reports/components/AIInsightsPreview.tsx
View file @
a1c7ad10
import
React
from
"react"
;
import
{
useNavigate
}
from
"zmp-ui"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
AIAssistantIcon
}
from
"@/components/ui/icons"
;
import
{
AIAssistantIcon
,
ChevronRightIcon
,
LightbulbIcon
,
NotificationIcon
,
}
from
"@/components/ui/icons"
;
import
{
useI18n
}
from
"@/i18n"
;
import
{
useAIInsights
}
from
"@/hooks/use-ai-assistant"
;
import
{
ReportPeriodPreset
}
from
"@/types/report"
;
export
interface
AIInsightContext
{
...
...
@@ -15,31 +23,114 @@ interface AIInsightsPreviewProps {
}
export
const
AIInsightsPreview
:
React
.
FC
<
AIInsightsPreviewProps
>
=
({
context
})
=>
{
const
navigate
=
useNavigate
();
const
{
t
}
=
useI18n
();
const
insightsQuery
=
useAIInsights
(
{
currency
:
context
.
currency
},
Boolean
(
context
.
currency
)
);
const
insights
=
insightsQuery
.
data
?.
data
;
return
(
<
Card
className=
"relative overflow-hidden p-5"
data
-
period=
{
context
.
period
}
data
-
currency=
{
context
.
currency
}
data
-
wallet
-
id=
{
context
.
walletId
}
>
<
Card
className=
"relative overflow-hidden p-5 border border-clay-primary/30"
data
-
period=
{
context
.
period
}
data
-
currency=
{
context
.
currency
}
data
-
wallet
-
id=
{
context
.
walletId
}
>
<
div
className=
"absolute -right-10 -top-10 h-32 w-32 rounded-full bg-clay-primary/10"
/>
<
div
className=
"relative flex items-start gap-4"
>
<
div
className=
"flex h-12 w-12 shrink-0 items-center justify-center rounded-clay bg-clay-primary/15 shadow-clay-pressed"
>
<
AIAssistantIcon
size=
{
28
}
/>
<
div
className=
"flex h-12 w-12 shrink-0 items-center justify-center rounded-clay bg-clay-primary/15 shadow-clay-pressed
text-clay-primary
"
>
<
AIAssistantIcon
size=
{
28
}
aria
-
hidden=
"true"
/>
</
div
>
<
div
className=
"min-w-0 flex-1"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
h2
className=
"clay-title-h2"
>
{
t
(
"report.ai.title"
)
}
</
h2
>
<
span
className=
"rounded-full bg-clay-primary/15 px-3 py-1 font-nunito text-[10px] font-bold uppercase tracking-wide text-clay-primary"
>
{
t
(
"report.ai.comingSoon"
)
}
</
span
>
<
div
className=
"flex flex-wrap items-center justify-between gap-2"
>
<
div
className=
"flex min-w-0 flex-wrap items-center gap-2"
>
<
h2
className=
"min-w-0 clay-title-h2"
>
{
t
(
"ai.insights.title"
)
}
</
h2
>
<
span
className=
"rounded-full bg-clay-income/20 px-3 py-0.5 font-nunito text-[10px] font-bold uppercase tracking-wide text-clay-income"
>
{
t
(
"ai.liveBadge"
)
}
</
span
>
</
div
>
<
p
className=
"clay-caption mt-1"
>
{
t
(
"report.ai.subtitle"
)
}
</
p
>
<
Button
variant=
"secondary"
className=
"gap-1 px-3 py-1 text-xs font-bold"
onClick=
{
()
=>
navigate
(
"/ai-assistant"
,
{
state
:
{
tab
:
"insights"
}
})
}
>
{
t
(
"ai.shortHeader"
)
}
<
ChevronRightIcon
size=
{
14
}
aria
-
hidden=
"true"
/>
</
Button
>
</
div
>
<
p
className=
"clay-caption mt-1"
>
{
t
(
"ai.insights.subtitle"
)
}
</
p
>
</
div
>
<
div
className=
"relative mt-5 grid grid-cols-1 gap-2 sm:grid-cols-3"
>
{
[
"spending"
,
"cashFlow"
,
"recommendation"
].
map
((
key
)
=>
(
<
div
key=
{
key
}
className=
"rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"
>
<
div
className=
"mb-2 h-2 w-10 rounded-full bg-clay-primary/25"
/>
<
p
className=
"font-nunito text-xs font-bold text-clay-text"
>
{
t
(
`report.ai.${key}`
)
}
</
p
>
<
p
className=
"mt-1 text-[10px] font-semibold leading-relaxed text-clay-text-muted"
>
{
t
(
`report.ai.${key}Hint`
)
}
</
p
>
</
div
>
{
/* Summary Content */
}
<
div
className=
"relative mt-4"
>
{
insightsQuery
.
isLoading
?
(
<
div
className=
"animate-pulse space-y-2 rounded-clay-sm border border-clay-highlight/30 bg-clay-bg/60 p-3"
role=
"status"
aria
-
label=
{
t
(
"common.loading"
)
}
>
<
div
className=
"h-3 w-full rounded-full bg-clay-text/10"
/>
<
div
className=
"h-3 w-4/5 rounded-full bg-clay-text/10"
/>
<
span
className=
"sr-only"
>
{
t
(
"common.loading"
)
}
</
span
>
</
div
>
)
:
insightsQuery
.
isError
?
(
<
div
className=
"rounded-clay-sm border border-clay-expense/30 bg-clay-expense/5 p-3.5 shadow-clay-pressed"
>
<
div
className=
"flex items-start gap-2"
>
<
NotificationIcon
size=
{
18
}
aria
-
hidden=
"true"
className=
"mt-0.5 shrink-0 text-clay-expense"
/>
<
div
className=
"min-w-0 flex-1"
>
<
p
className=
"font-nunito text-xs font-bold text-clay-text"
>
{
t
(
"ai.preview.errorTitle"
)
}
</
p
>
<
p
className=
"mt-1 font-nunito text-[11px] text-clay-text-muted"
>
{
t
(
"ai.errors.unavailable"
)
}
</
p
>
</
div
>
</
div
>
<
Button
variant=
"secondary"
className=
"mt-3 px-4 py-1 text-xs font-bold"
onClick=
{
()
=>
insightsQuery
.
refetch
()
}
>
{
t
(
"common.retry"
)
}
</
Button
>
</
div
>
)
:
insights
?
(
<
div
className=
"rounded-clay-sm bg-clay-bg p-3.5 shadow-clay-pressed border border-clay-highlight/30"
>
<
p
className=
"font-nunito text-xs text-clay-text leading-relaxed line-clamp-3"
>
{
insights
.
summary
}
</
p
>
{
insights
.
anomalies
.
length
>
0
&&
(
<
div
className=
"mt-2 text-[11px] font-bold text-clay-warning flex items-center gap-1"
>
<
NotificationIcon
size=
{
14
}
aria
-
hidden=
"true"
/>
<
span
>
{
insights
.
anomalies
.
length
}
{
t
(
"ai.insights.sections.anomalies"
).
toLowerCase
()
}
</
span
>
</
div
>
)
}
</
div
>
)
:
(
<
div
className=
"grid grid-cols-1 gap-2 sm:grid-cols-3"
>
{
[
"spending"
,
"reduce"
,
"anomalies"
].
map
((
key
)
=>
(
<
button
key=
{
key
}
type=
"button"
className=
"flex items-center gap-2 rounded-clay-sm bg-clay-bg p-3 text-left shadow-clay-pressed transition-all duration-200 ease-in-out hover:bg-clay-primary/5 focus:outline-none focus:ring-2 focus:ring-clay-primary/35"
onClick=
{
()
=>
navigate
(
"/ai-assistant"
,
{
state
:
{
tab
:
"chat"
}
})
}
>
<
LightbulbIcon
size=
{
17
}
aria
-
hidden=
"true"
className=
"shrink-0 text-clay-warning"
/>
<
p
className=
"font-nunito text-xs font-bold text-clay-text"
>
{
t
(
`ai.chat.quickPrompts.${key}`
)
}
</
p
>
</
button
>
))
}
</
div
>
)
}
</
div
>
</
Card
>
);
};
src/pages/saving-goals/index.tsx
View file @
a1c7ad10
import
React
,
{
useDeferredValue
,
useEffect
,
useMemo
,
useState
}
from
"react"
;
import
{
Header
,
Page
,
useNavigate
,
useSnackbar
}
from
"zmp-ui"
;
import
{
Header
,
Page
,
use
Location
,
use
Navigate
,
useSnackbar
}
from
"zmp-ui"
;
import
{
Button
}
from
"@/components/ui/Button"
;
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
Input
}
from
"@/components/ui/Input"
;
...
...
@@ -9,6 +9,7 @@ import { IconGradients, PlusIcon, SavingGoalIcon } from "@/components/ui/icons";
import
{
useCreateSavingGoal
,
useSavingGoals
}
from
"@/hooks/use-saving-goals"
;
import
{
useI18n
}
from
"@/i18n"
;
import
{
getErrorMessage
}
from
"@/lib/error-message"
;
import
{
isFromAIRecommendations
}
from
"@/lib/navigation-state"
;
import
{
toLocalDate
}
from
"@/lib/money-input"
;
import
{
CreateSavingGoalInput
,
...
...
@@ -36,6 +37,7 @@ function startOfLocalDay(value: string): string | undefined {
const
SavingGoalsPage
:
React
.
FC
=
()
=>
{
const
navigate
=
useNavigate
();
const
location
=
useLocation
();
const
{
openSnackbar
}
=
useSnackbar
();
const
{
t
}
=
useI18n
();
const
[
search
,
setSearch
]
=
useState
(
""
);
...
...
@@ -111,7 +113,11 @@ const SavingGoalsPage: React.FC = () => {
return
(
<
Page
className=
"page"
>
<
Header
title=
{
t
(
"savingGoal.header"
)
}
showBackIcon
onBackClick=
{
()
=>
navigate
(
"/"
)
}
/>
<
Header
title=
{
t
(
"savingGoal.header"
)
}
showBackIcon
onBackClick=
{
()
=>
isFromAIRecommendations
(
location
.
state
)
?
navigate
(
-
1
)
:
navigate
(
"/"
)
}
/>
<
IconGradients
/>
<
main
className=
"mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12"
>
...
...
src/pages/transactions/components/TransactionFormModal.tsx
View file @
a1c7ad10
...
...
@@ -79,9 +79,21 @@ const createTransactionSchema = (t: TranslationFunction) => z.object({
type
TransactionFormValues
=
z
.
infer
<
ReturnType
<
typeof
createTransactionSchema
>>
;
export
interface
PrefilledTransactionData
{
amount
?:
string
;
type
?:
TransactionType
;
walletId
?:
string
;
categoryId
?:
string
;
date
?:
string
;
description
?:
string
;
location
?:
string
;
}
interface
TransactionFormModalProps
{
isOpen
:
boolean
;
transaction
?:
Transaction
;
initialValues
?:
PrefilledTransactionData
;
initialReceiptFile
?:
File
|
null
;
isSubmitting
:
boolean
;
onClose
:
()
=>
void
;
onSubmit
:
(
...
...
@@ -112,13 +124,15 @@ const getLocalDateString = (dateInput?: string | Date) => {
export
const
TransactionFormModal
:
React
.
FC
<
TransactionFormModalProps
>
=
({
isOpen
,
transaction
,
initialValues
,
initialReceiptFile
,
isSubmitting
,
onClose
,
onSubmit
,
})
=>
{
const
{
t
,
intlLocale
}
=
useI18n
();
const
fileInputRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
[
selectedFile
,
setSelectedFile
]
=
useState
<
File
|
null
>
(
null
);
const
[
selectedFile
,
setSelectedFile
]
=
useState
<
File
|
null
>
(
initialReceiptFile
||
null
);
const
[
fileError
,
setFileError
]
=
useState
<
string
|
null
>
(
null
);
const
[
deleteCurrentReceipt
,
setDeleteCurrentReceipt
]
=
useState
(
false
);
...
...
@@ -151,15 +165,15 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
};
}
return
{
amount
:
""
,
type
:
"EXPENSE"
,
walletId
:
""
,
categoryId
:
""
,
date
:
getLocalDateString
(),
description
:
""
,
location
:
""
,
amount
:
initialValues
?.
amount
||
""
,
type
:
initialValues
?.
type
||
"EXPENSE"
,
walletId
:
initialValues
?.
walletId
||
""
,
categoryId
:
initialValues
?.
categoryId
||
""
,
date
:
getLocalDateString
(
initialValues
?.
date
),
description
:
initialValues
?.
description
||
""
,
location
:
initialValues
?.
location
||
""
,
};
},
[
transaction
]);
},
[
transaction
,
initialValues
]);
const
{
control
,
...
...
@@ -177,11 +191,11 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
useEffect
(()
=>
{
if
(
isOpen
)
{
reset
(
defaultValues
);
setSelectedFile
(
null
);
setSelectedFile
(
initialReceiptFile
||
null
);
setFileError
(
null
);
setDeleteCurrentReceipt
(
false
);
}
},
[
defaultValues
,
isOpen
,
reset
]);
},
[
defaultValues
,
i
nitialReceiptFile
,
i
sOpen
,
reset
]);
const
selectedType
=
watch
(
"type"
)
as
TransactionType
;
const
selectedWalletId
=
watch
(
"walletId"
);
...
...
src/pages/transactions/index.tsx
View file @
a1c7ad10
...
...
@@ -29,7 +29,8 @@ import {
SortOrder
,
UpdateTransactionInput
,
}
from
"@/types/transaction"
;
import
{
TransactionFormModal
}
from
"./components/TransactionFormModal"
;
import
{
CategoryTreeNode
}
from
"@/types/category"
;
import
{
PrefilledTransactionData
,
TransactionFormModal
}
from
"./components/TransactionFormModal"
;
import
{
TransactionDetailModal
}
from
"./components/TransactionDetailModal"
;
import
{
TransactionSkeleton
}
from
"./components/TransactionSkeleton"
;
import
{
transactionService
}
from
"@/services/transaction.service"
;
...
...
@@ -37,6 +38,28 @@ import { addCalendarDays, instantToBusinessDate, todayInBusinessTime } from "@/l
const
PAGE_SIZE
=
10
;
interface
TransactionPrefillNavigationState
{
prefill
:
PrefilledTransactionData
;
receiptFile
?:
File
;
}
interface
FlatCategoryOption
{
category
:
CategoryTreeNode
;
depth
:
number
;
}
function
getTransactionPrefillState
(
state
:
unknown
):
TransactionPrefillNavigationState
|
null
{
if
(
!
state
||
typeof
state
!==
"object"
||
!
(
"prefill"
in
state
))
return
null
;
const
candidate
=
state
as
{
prefill
?:
unknown
;
receiptFile
?:
unknown
};
if
(
!
candidate
.
prefill
||
typeof
candidate
.
prefill
!==
"object"
)
return
null
;
return
{
prefill
:
candidate
.
prefill
as
PrefilledTransactionData
,
receiptFile
:
candidate
.
receiptFile
instanceof
File
?
candidate
.
receiptFile
:
undefined
,
};
}
const
getLocalDateString
=
(
dateInput
?:
string
|
Date
)
=>
{
if
(
!
dateInput
)
return
todayInBusinessTime
();
if
(
typeof
dateInput
===
"string"
&&
/^
\d{4}
-
\d{2}
-
\d{2}
$/
.
test
(
dateInput
))
return
dateInput
;
...
...
@@ -123,6 +146,20 @@ const TransactionsPage: React.FC = () => {
// Selected transactions
const
[
selectedTransaction
,
setSelectedTransaction
]
=
useState
<
Transaction
|
undefined
>
(
undefined
);
// Prefilled state from OCR / AI Assistant
const
[
prefilledData
,
setPrefilledData
]
=
useState
<
PrefilledTransactionData
|
undefined
>
(
undefined
);
const
[
prefilledFile
,
setPrefilledFile
]
=
useState
<
File
|
null
>
(
null
);
useEffect
(()
=>
{
const
prefillState
=
getTransactionPrefillState
(
location
.
state
);
if
(
!
prefillState
)
return
;
setPrefilledData
(
prefillState
.
prefill
);
setPrefilledFile
(
prefillState
.
receiptFile
||
null
);
setIsCreateOpen
(
true
);
navigate
(
location
.
pathname
,
{
replace
:
true
,
state
:
null
});
},
[
location
.
pathname
,
location
.
state
,
navigate
]);
// Options for filter selects
const
walletsQuery
=
useWallets
({
includeArchived
:
false
,
...
...
@@ -348,6 +385,8 @@ const TransactionsPage: React.FC = () => {
openSnackbar
({
type
:
"success"
,
text
:
t
(
"transaction.createSuccess"
)
});
}
setIsCreateOpen
(
false
);
setPrefilledData
(
undefined
);
setPrefilledFile
(
null
);
},
onError
:
(
error
)
=>
{
openSnackbar
({
...
...
@@ -786,8 +825,14 @@ const TransactionsPage: React.FC = () => {
{
/* Create Transaction Modal */
}
<
TransactionFormModal
isOpen=
{
isCreateOpen
}
initialValues=
{
prefilledData
}
initialReceiptFile=
{
prefilledFile
}
isSubmitting=
{
createMutation
.
isPending
}
onClose=
{
()
=>
setIsCreateOpen
(
false
)
}
onClose=
{
()
=>
{
setIsCreateOpen
(
false
);
setPrefilledData
(
undefined
);
setPrefilledFile
(
null
);
}
}
onSubmit=
{
handleCreate
}
/>
...
...
src/services/ai-assistant.service.ts
0 → 100644
View file @
a1c7ad10
import
{
apiClient
}
from
"@/lib/api-client"
;
import
{
AIChatInput
,
AIChatData
,
AIInsightsInput
,
AIInsightsData
,
AIRecommendationsInput
,
AIRecommendationsData
,
CategorizeTransactionInput
,
CategorizeTransactionData
,
ExtractReceiptInput
,
ExtractReceiptData
,
AIServiceResponse
,
}
from
"@/types/ai"
;
export
class
AIAssistantError
extends
Error
{
public
status
?:
number
;
public
code
?:
string
;
public
retryAfterSeconds
?:
number
;
constructor
(
message
:
string
,
status
?:
number
,
code
?:
string
,
retryAfterSeconds
?:
number
)
{
super
(
message
);
this
.
name
=
"AIAssistantError"
;
this
.
status
=
status
;
this
.
code
=
code
;
this
.
retryAfterSeconds
=
retryAfterSeconds
;
}
}
function
handleAIError
(
error
:
any
):
never
{
if
(
error
.
response
)
{
const
status
=
error
.
response
.
status
;
const
data
=
error
.
response
.
data
;
const
headers
=
error
.
response
.
headers
;
if
(
status
===
429
)
{
const
retryHeader
=
headers
?.[
"retry-after"
];
const
retryAfter
=
retryHeader
?
parseInt
(
retryHeader
,
10
)
:
30
;
throw
new
AIAssistantError
(
data
?.
message
||
"AI request limit exceeded, please try again later"
,
429
,
data
?.
code
||
"AI_RATE_LIMIT_EXCEEDED"
,
isNaN
(
retryAfter
)
?
30
:
retryAfter
);
}
throw
new
AIAssistantError
(
data
?.
message
||
"An error occurred with AI Assistant"
,
status
,
data
?.
code
);
}
throw
new
AIAssistantError
(
error
.
message
||
"Network error while calling AI Assistant"
);
}
export
const
aiAssistantService
=
{
async
chat
(
input
:
AIChatInput
):
Promise
<
AIServiceResponse
<
AIChatData
>>
{
try
{
const
response
=
await
apiClient
.
post
<
AIServiceResponse
<
AIChatData
>>
(
"/ai-assistant/chat"
,
input
,
{
timeout
:
45000
,
}
);
return
response
.
data
;
}
catch
(
error
)
{
return
handleAIError
(
error
);
}
},
async
analyzeInsights
(
input
:
AIInsightsInput
):
Promise
<
AIServiceResponse
<
AIInsightsData
>>
{
try
{
const
response
=
await
apiClient
.
post
<
AIServiceResponse
<
AIInsightsData
>>
(
"/ai-assistant/insights/analyze"
,
input
,
{
timeout
:
45000
}
);
return
response
.
data
;
}
catch
(
error
)
{
return
handleAIError
(
error
);
}
},
async
getRecommendations
(
input
:
AIRecommendationsInput
):
Promise
<
AIServiceResponse
<
AIRecommendationsData
>>
{
try
{
const
response
=
await
apiClient
.
post
<
AIServiceResponse
<
AIRecommendationsData
>>
(
"/ai-assistant/recommendations"
,
input
,
{
timeout
:
45000
}
);
return
response
.
data
;
}
catch
(
error
)
{
return
handleAIError
(
error
);
}
},
async
extractReceipt
(
file
:
File
,
hints
?:
ExtractReceiptInput
):
Promise
<
AIServiceResponse
<
ExtractReceiptData
>>
{
try
{
const
formData
=
new
FormData
();
formData
.
append
(
"receipt"
,
file
);
if
(
hints
?.
languageHint
)
{
formData
.
append
(
"languageHint"
,
hints
.
languageHint
);
}
if
(
hints
?.
currencyHint
)
{
formData
.
append
(
"currencyHint"
,
hints
.
currencyHint
);
}
const
response
=
await
apiClient
.
post
<
AIServiceResponse
<
ExtractReceiptData
>>
(
"/ai-assistant/receipts/extract"
,
formData
,
{
headers
:
{
"Content-Type"
:
"multipart/form-data"
,
},
timeout
:
60000
,
}
);
return
response
.
data
;
}
catch
(
error
)
{
return
handleAIError
(
error
);
}
},
async
categorizeTransaction
(
input
:
CategorizeTransactionInput
):
Promise
<
AIServiceResponse
<
CategorizeTransactionData
>>
{
try
{
const
response
=
await
apiClient
.
post
<
AIServiceResponse
<
CategorizeTransactionData
>>
(
"/ai-assistant/categorize"
,
input
,
{
timeout
:
30000
}
);
return
response
.
data
;
}
catch
(
error
)
{
return
handleAIError
(
error
);
}
},
};
src/stores/ai-chat-store.ts
0 → 100644
View file @
a1c7ad10
import
{
create
}
from
"zustand"
;
import
{
AIChatData
}
from
"@/types/ai"
;
export
interface
AIChatMessage
{
id
:
string
;
sender
:
"user"
|
"ai"
;
text
:
string
;
streamingText
?:
string
;
isStreaming
?:
boolean
;
data
?:
AIChatData
;
timestamp
:
string
;
}
interface
AIChatState
{
messages
:
AIChatMessage
[];
addMessages
:
(...
messages
:
AIChatMessage
[])
=>
void
;
updateMessage
:
(
id
:
string
,
updates
:
Partial
<
AIChatMessage
>
)
=>
void
;
clearMessages
:
()
=>
void
;
}
export
const
useAIChatStore
=
create
<
AIChatState
>
((
set
)
=>
({
messages
:
[],
addMessages
:
(...
messages
)
=>
set
((
state
)
=>
({
messages
:
[...
state
.
messages
,
...
messages
],
})),
updateMessage
:
(
id
,
updates
)
=>
set
((
state
)
=>
({
messages
:
state
.
messages
.
map
((
message
)
=>
(
message
.
id
===
id
?
{
...
message
,
...
updates
}
:
message
)),
})),
clearMessages
:
()
=>
set
({
messages
:
[]
}),
}));
src/stores/auth-store.ts
View file @
a1c7ad10
import
{
create
}
from
"zustand"
;
import
{
User
}
from
"@/types/auth"
;
import
{
useAIChatStore
}
from
"@/stores/ai-chat-store"
;
interface
AuthState
{
user
:
User
|
null
;
...
...
@@ -37,6 +38,7 @@ export const useAuthStore = create<AuthState>((set) => {
clearAuth
:
()
=>
{
localStorage
.
removeItem
(
"accessToken"
);
localStorage
.
removeItem
(
"refreshToken"
);
useAIChatStore
.
getState
().
clearMessages
();
set
({
user
:
null
,
accessToken
:
null
,
...
...
src/types/ai.ts
0 → 100644
View file @
a1c7ad10
export
type
AIFocusArea
=
"ALL"
|
"SPENDING"
|
"INCOME"
|
"CASH_FLOW"
;
export
type
AIRecommendationPriority
=
"BALANCED"
|
"REDUCE_SPENDING"
|
"GROW_SAVINGS"
;
export
type
TrendDirection
=
"UP"
|
"DOWN"
|
"STABLE"
;
export
type
SeverityLevel
=
"LOW"
|
"MEDIUM"
|
"HIGH"
;
export
type
PriorityLevel
=
"LOW"
|
"MEDIUM"
|
"HIGH"
;
export
interface
AIAnalysisScope
{
dateFrom
?:
string
;
dateTo
?:
string
;
currency
?:
string
;
}
export
interface
AIChatInput
extends
AIAnalysisScope
{
question
:
string
;
}
export
interface
AIChatData
{
answer
:
string
;
highlights
:
string
[];
caveats
:
string
[];
suggestedActions
:
string
[];
}
export
interface
AIInsightTrend
{
title
:
string
;
direction
:
TrendDirection
;
description
:
string
;
evidence
:
string
;
}
export
interface
AIInsightAnomaly
{
title
:
string
;
severity
:
SeverityLevel
;
description
:
string
;
evidence
:
string
;
}
export
interface
AIInsightRecommendation
{
title
:
string
;
priority
:
PriorityLevel
;
description
:
string
;
}
export
interface
AIInsightsInput
extends
AIAnalysisScope
{
focus
?:
AIFocusArea
;
}
export
interface
AIInsightsData
{
summary
:
string
;
trends
:
AIInsightTrend
[];
anomalies
:
AIInsightAnomaly
[];
recommendations
:
AIInsightRecommendation
[];
}
export
interface
AIBudgetRecommendation
{
categoryName
:
string
|
null
;
currency
:
string
;
suggestedLimit
:
string
;
rationale
:
string
;
}
export
interface
AISavingRecommendation
{
goalName
:
string
|
null
;
currency
:
string
;
suggestedMonthlyContribution
:
string
;
rationale
:
string
;
}
export
interface
AIActionRecommendation
{
title
:
string
;
priority
:
PriorityLevel
;
description
:
string
;
}
export
interface
AIRecommendationsInput
extends
AIAnalysisScope
{
priority
?:
AIRecommendationPriority
;
}
export
interface
AIRecommendationsData
{
summary
:
string
;
budgetRecommendations
:
AIBudgetRecommendation
[];
savingRecommendations
:
AISavingRecommendation
[];
actions
:
AIActionRecommendation
[];
}
export
interface
CategorizeTransactionInput
{
description
:
string
;
amount
?:
string
;
type
?:
"INCOME"
|
"EXPENSE"
;
merchant
?:
string
;
occurredAt
?:
string
;
}
export
interface
CategorizedCategory
{
id
:
string
;
name
:
string
;
type
:
"INCOME"
|
"EXPENSE"
;
icon
?:
string
|
null
;
color
?:
string
|
null
;
}
export
interface
CategorizeTransactionData
{
category
:
CategorizedCategory
;
confidence
:
number
;
reasoning
:
string
;
}
export
interface
ExtractReceiptInput
{
languageHint
?:
string
;
currencyHint
?:
string
;
}
export
interface
ReceiptLineItem
{
name
:
string
;
quantity
:
number
|
null
;
unitPrice
:
string
|
null
;
totalAmount
:
string
|
null
;
}
export
interface
ExtractReceiptData
{
merchant
:
string
|
null
;
transactionDate
:
string
|
null
;
totalAmount
:
string
|
null
;
currency
:
string
|
null
;
taxAmount
:
string
|
null
;
category
:
CategorizedCategory
|
null
;
lineItems
:
ReceiptLineItem
[];
rawText
:
string
;
confidence
:
number
;
warnings
:
string
[];
}
export
interface
AIResponseMeta
{
provider
:
string
;
model
:
string
;
usage
:
{
promptTokens
:
number
;
completionTokens
:
number
;
totalTokens
:
number
;
};
context
?:
{
from
:
string
;
to
:
string
;
currency
:
string
|
null
;
transactionCount
:
number
;
totalTransactionCount
:
number
;
truncated
:
boolean
;
};
}
export
interface
AIServiceResponse
<
T
>
{
success
:
boolean
;
data
:
T
;
meta
:
AIResponseMeta
;
}
export
interface
AIRateLimitError
{
isRateLimit
:
true
;
message
:
string
;
code
:
string
;
retryAfterSeconds
:
number
;
}
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