Commit a1c7ad10 authored by ThinhNC's avatar ThinhNC

feat(ai-assistant): implement AI Chat UI, quick actions, store, and i18n support

parent 8a15bc17
...@@ -36,6 +36,7 @@ import SavingGoalsPage from "@/pages/saving-goals/index"; ...@@ -36,6 +36,7 @@ import SavingGoalsPage from "@/pages/saving-goals/index";
import SavingGoalDetailPage from "@/pages/saving-goals/detail"; import SavingGoalDetailPage from "@/pages/saving-goals/detail";
import ReportsPage from "@/pages/reports/index"; import ReportsPage from "@/pages/reports/index";
import NotificationsPage from "@/pages/notifications/index"; import NotificationsPage from "@/pages/notifications/index";
import AIAssistantPage from "@/pages/ai-assistant/index";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => { const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore(); const { setAuth, clearAuth, setInitialized } = useAuthStore();
...@@ -104,6 +105,7 @@ const Layout = () => { ...@@ -104,6 +105,7 @@ const Layout = () => {
<Route path="/saving-goals/:id" element={<AuthGuard><SavingGoalDetailPage /></AuthGuard>}></Route> <Route path="/saving-goals/:id" element={<AuthGuard><SavingGoalDetailPage /></AuthGuard>}></Route>
<Route path="/reports" element={<AuthGuard><ReportsPage /></AuthGuard>}></Route> <Route path="/reports" element={<AuthGuard><ReportsPage /></AuthGuard>}></Route>
<Route path="/notifications" element={<AuthGuard><NotificationsPage /></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> <Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes> </AnimationRoutes>
</AuthInitializer> </AuthInitializer>
......
...@@ -25,6 +25,7 @@ const PAGE_TITLES: ReadonlyArray<{ ...@@ -25,6 +25,7 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" }, { matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" },
{ matches: (pathname) => pathname === "/reports", key: "document.reports" }, { matches: (pathname) => pathname === "/reports", key: "document.reports" },
{ matches: (pathname) => pathname === "/notifications", key: "document.notifications" }, { matches: (pathname) => pathname === "/notifications", key: "document.notifications" },
{ matches: (pathname) => pathname === "/ai-assistant", key: "document.aiAssistant" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" }, { matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
]; ];
......
...@@ -185,3 +185,43 @@ export const FoodIcon: React.FC<IconProps> = ({ size = 24, ...props }) => ( ...@@ -185,3 +185,43 @@ export const FoodIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<path d="M12 6v6h6" stroke="rgb(var(--color-clay-text))" /> <path d="M12 6v6h6" stroke="rgb(var(--color-clay-text))" />
</svg> </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>
);
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),
});
}
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
"edit": "Edit", "edit": "Edit",
"saving": "Saving...", "saving": "Saving...",
"delete": "Delete", "delete": "Delete",
"error": "Something went wrong",
"retry": "Try again", "retry": "Try again",
"loading": "Loading...", "loading": "Loading...",
"processing": "Processing...", "processing": "Processing...",
...@@ -52,7 +53,8 @@ ...@@ -52,7 +53,8 @@
"savingGoals": "Saving Goals", "savingGoals": "Saving Goals",
"savingGoalDetail": "Saving Goal Details", "savingGoalDetail": "Saving Goal Details",
"reports": "Financial Reports", "reports": "Financial Reports",
"notifications": "Notifications & Reminders" "notifications": "Notifications & Reminders",
"aiAssistant": "AI Financial Assistant"
}, },
"validation": { "validation": {
"emailRequired": "Email is required", "emailRequired": "Email is required",
...@@ -201,7 +203,8 @@ ...@@ -201,7 +203,8 @@
"budgets": "Manage spending budgets", "budgets": "Manage spending budgets",
"savingGoals": "Manage saving goals", "savingGoals": "Manage saving goals",
"reports": "Financial reports & analytics", "reports": "Financial reports & analytics",
"notifications": "Notifications & reminders" "notifications": "Notifications & reminders",
"aiAssistant": "AI Financial Assistant"
}, },
"profile": { "profile": {
"header": "Account", "header": "Account",
...@@ -1208,5 +1211,126 @@ ...@@ -1208,5 +1211,126 @@
"interval": "Repeat interval must be from 1 to 365", "future": "A one-time reminder must be in the future", "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" "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."
}
} }
} }
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
"edit": "Chỉnh sửa", "edit": "Chỉnh sửa",
"saving": "Đang lưu...", "saving": "Đang lưu...",
"delete": "Xóa", "delete": "Xóa",
"error": "Đã xảy ra lỗi",
"retry": "Thử lại", "retry": "Thử lại",
"loading": "Đang tải...", "loading": "Đang tải...",
"processing": "Đang xử lý...", "processing": "Đang xử lý...",
...@@ -52,7 +53,8 @@ ...@@ -52,7 +53,8 @@
"savingGoals": "Mục tiêu tiết kiệm", "savingGoals": "Mục tiêu tiết kiệm",
"savingGoalDetail": "Chi tiết 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", "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": { "validation": {
"emailRequired": "Email không được để trống", "emailRequired": "Email không được để trống",
...@@ -201,7 +203,8 @@ ...@@ -201,7 +203,8 @@
"budgets": "Quản lý ngân sách chi tiêu", "budgets": "Quản lý ngân sách chi tiêu",
"savingGoals": "Quản lý mục tiêu tiết kiệm", "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", "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": { "profile": {
"header": "Tài Khoản", "header": "Tài Khoản",
...@@ -1217,5 +1220,126 @@ ...@@ -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", "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" "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."
}
} }
} }
export interface FinWiseNavigationState { export interface FinWiseNavigationState {
fromNotifications?: boolean; fromNotifications?: boolean;
fromAIRecommendations?: boolean;
tab?: "chat" | "insights" | "ocr";
insightsTab?: "insights" | "recommendations";
} }
export function isFromNotifications(state: unknown): boolean { export function isFromNotifications(state: unknown): boolean {
...@@ -7,3 +10,7 @@ export function isFromNotifications(state: unknown): boolean { ...@@ -7,3 +10,7 @@ export function isFromNotifications(state: unknown): boolean {
return (state as FinWiseNavigationState).fromNotifications === true; 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;
}
This diff is collapsed.
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>
);
};
This diff is collapsed.
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>
);
};
This diff is collapsed.
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;
import React, { useDeferredValue, useEffect, useMemo, useState } from "react"; import React, { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui"; import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
...@@ -11,6 +11,7 @@ import { useCategoryTree } from "@/hooks/use-categories"; ...@@ -11,6 +11,7 @@ import { useCategoryTree } from "@/hooks/use-categories";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format"; import { getCategoryDisplayName } from "@/lib/category-format";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { isFromAIRecommendations } from "@/lib/navigation-state";
import { import {
BudgetPeriod, BudgetPeriod,
BudgetQuery, BudgetQuery,
...@@ -49,6 +50,7 @@ function flattenExpenseCategories(nodes: CategoryTreeNode[], depth = 0): Array<{ ...@@ -49,6 +50,7 @@ function flattenExpenseCategories(nodes: CategoryTreeNode[], depth = 0): Array<{
const BudgetsPage: React.FC = () => { const BudgetsPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const { t } = useI18n(); const { t } = useI18n();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
...@@ -141,7 +143,11 @@ const BudgetsPage: React.FC = () => { ...@@ -141,7 +143,11 @@ const BudgetsPage: React.FC = () => {
return ( return (
<Page className="page"> <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 /> <IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12"> <main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
......
...@@ -55,6 +55,14 @@ function HomePage() { ...@@ -55,6 +55,14 @@ function HomePage() {
<Button <Button
variant="primary" variant="primary"
fullWidth 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")} onClick={() => navigate("/wallets")}
className="gap-2" className="gap-2"
> >
......
import React from "react"; import React from "react";
import { useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; 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 { useI18n } from "@/i18n";
import { useAIInsights } from "@/hooks/use-ai-assistant";
import { ReportPeriodPreset } from "@/types/report"; import { ReportPeriodPreset } from "@/types/report";
export interface AIInsightContext { export interface AIInsightContext {
...@@ -15,30 +23,113 @@ interface AIInsightsPreviewProps { ...@@ -15,30 +23,113 @@ interface AIInsightsPreviewProps {
} }
export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context }) => { export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context }) => {
const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
const insightsQuery = useAIInsights(
{ currency: context.currency },
Boolean(context.currency)
);
const insights = insightsQuery.data?.data;
return ( 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="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="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"> <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} /> <AIAssistantIcon size={28} aria-hidden="true" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="clay-title-h2">{t("report.ai.title")}</h2> <div className="flex min-w-0 flex-wrap items-center gap-2">
<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> <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>
<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> </div>
<p className="clay-caption mt-1">{t("report.ai.subtitle")}</p> <p className="clay-caption mt-1">{t("ai.insights.subtitle")}</p>
</div> </div>
</div> </div>
<div className="relative mt-5 grid grid-cols-1 gap-2 sm:grid-cols-3">
{["spending", "cashFlow", "recommendation"].map((key) => ( {/* Summary Content */}
<div key={key} className="rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"> <div className="relative mt-4">
<div className="mb-2 h-2 w-10 rounded-full bg-clay-primary/25" /> {insightsQuery.isLoading ? (
<p className="font-nunito text-xs font-bold text-clay-text">{t(`report.ai.${key}`)}</p> <div
<p className="mt-1 text-[10px] font-semibold leading-relaxed text-clay-text-muted">{t(`report.ai.${key}Hint`)}</p> 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>
))} )}
</div> </div>
</Card> </Card>
); );
......
import React, { useDeferredValue, useEffect, useMemo, useState } from "react"; import React, { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui"; import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
...@@ -9,6 +9,7 @@ import { IconGradients, PlusIcon, SavingGoalIcon } from "@/components/ui/icons"; ...@@ -9,6 +9,7 @@ import { IconGradients, PlusIcon, SavingGoalIcon } from "@/components/ui/icons";
import { useCreateSavingGoal, useSavingGoals } from "@/hooks/use-saving-goals"; import { useCreateSavingGoal, useSavingGoals } from "@/hooks/use-saving-goals";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { isFromAIRecommendations } from "@/lib/navigation-state";
import { toLocalDate } from "@/lib/money-input"; import { toLocalDate } from "@/lib/money-input";
import { import {
CreateSavingGoalInput, CreateSavingGoalInput,
...@@ -36,6 +37,7 @@ function startOfLocalDay(value: string): string | undefined { ...@@ -36,6 +37,7 @@ function startOfLocalDay(value: string): string | undefined {
const SavingGoalsPage: React.FC = () => { const SavingGoalsPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const { t } = useI18n(); const { t } = useI18n();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
...@@ -111,7 +113,11 @@ const SavingGoalsPage: React.FC = () => { ...@@ -111,7 +113,11 @@ const SavingGoalsPage: React.FC = () => {
return ( return (
<Page className="page"> <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 /> <IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12"> <main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
......
...@@ -79,9 +79,21 @@ const createTransactionSchema = (t: TranslationFunction) => z.object({ ...@@ -79,9 +79,21 @@ const createTransactionSchema = (t: TranslationFunction) => z.object({
type TransactionFormValues = z.infer<ReturnType<typeof createTransactionSchema>>; 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 { interface TransactionFormModalProps {
isOpen: boolean; isOpen: boolean;
transaction?: Transaction; transaction?: Transaction;
initialValues?: PrefilledTransactionData;
initialReceiptFile?: File | null;
isSubmitting: boolean; isSubmitting: boolean;
onClose: () => void; onClose: () => void;
onSubmit: ( onSubmit: (
...@@ -112,13 +124,15 @@ const getLocalDateString = (dateInput?: string | Date) => { ...@@ -112,13 +124,15 @@ const getLocalDateString = (dateInput?: string | Date) => {
export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
isOpen, isOpen,
transaction, transaction,
initialValues,
initialReceiptFile,
isSubmitting, isSubmitting,
onClose, onClose,
onSubmit, onSubmit,
}) => { }) => {
const { t, intlLocale } = useI18n(); const { t, intlLocale } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null); 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 [fileError, setFileError] = useState<string | null>(null);
const [deleteCurrentReceipt, setDeleteCurrentReceipt] = useState(false); const [deleteCurrentReceipt, setDeleteCurrentReceipt] = useState(false);
...@@ -151,15 +165,15 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -151,15 +165,15 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
}; };
} }
return { return {
amount: "", amount: initialValues?.amount || "",
type: "EXPENSE", type: initialValues?.type || "EXPENSE",
walletId: "", walletId: initialValues?.walletId || "",
categoryId: "", categoryId: initialValues?.categoryId || "",
date: getLocalDateString(), date: getLocalDateString(initialValues?.date),
description: "", description: initialValues?.description || "",
location: "", location: initialValues?.location || "",
}; };
}, [transaction]); }, [transaction, initialValues]);
const { const {
control, control,
...@@ -177,11 +191,11 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -177,11 +191,11 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
reset(defaultValues); reset(defaultValues);
setSelectedFile(null); setSelectedFile(initialReceiptFile || null);
setFileError(null); setFileError(null);
setDeleteCurrentReceipt(false); setDeleteCurrentReceipt(false);
} }
}, [defaultValues, isOpen, reset]); }, [defaultValues, initialReceiptFile, isOpen, reset]);
const selectedType = watch("type") as TransactionType; const selectedType = watch("type") as TransactionType;
const selectedWalletId = watch("walletId"); const selectedWalletId = watch("walletId");
......
...@@ -29,7 +29,8 @@ import { ...@@ -29,7 +29,8 @@ import {
SortOrder, SortOrder,
UpdateTransactionInput, UpdateTransactionInput,
} from "@/types/transaction"; } 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 { TransactionDetailModal } from "./components/TransactionDetailModal";
import { TransactionSkeleton } from "./components/TransactionSkeleton"; import { TransactionSkeleton } from "./components/TransactionSkeleton";
import { transactionService } from "@/services/transaction.service"; import { transactionService } from "@/services/transaction.service";
...@@ -37,6 +38,28 @@ import { addCalendarDays, instantToBusinessDate, todayInBusinessTime } from "@/l ...@@ -37,6 +38,28 @@ import { addCalendarDays, instantToBusinessDate, todayInBusinessTime } from "@/l
const PAGE_SIZE = 10; 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) => { const getLocalDateString = (dateInput?: string | Date) => {
if (!dateInput) return todayInBusinessTime(); if (!dateInput) return todayInBusinessTime();
if (typeof dateInput === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateInput)) return dateInput; if (typeof dateInput === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateInput)) return dateInput;
...@@ -123,6 +146,20 @@ const TransactionsPage: React.FC = () => { ...@@ -123,6 +146,20 @@ const TransactionsPage: React.FC = () => {
// Selected transactions // Selected transactions
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | undefined>(undefined); 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 // Options for filter selects
const walletsQuery = useWallets({ const walletsQuery = useWallets({
includeArchived: false, includeArchived: false,
...@@ -348,6 +385,8 @@ const TransactionsPage: React.FC = () => { ...@@ -348,6 +385,8 @@ const TransactionsPage: React.FC = () => {
openSnackbar({ type: "success", text: t("transaction.createSuccess") }); openSnackbar({ type: "success", text: t("transaction.createSuccess") });
} }
setIsCreateOpen(false); setIsCreateOpen(false);
setPrefilledData(undefined);
setPrefilledFile(null);
}, },
onError: (error) => { onError: (error) => {
openSnackbar({ openSnackbar({
...@@ -786,8 +825,14 @@ const TransactionsPage: React.FC = () => { ...@@ -786,8 +825,14 @@ const TransactionsPage: React.FC = () => {
{/* Create Transaction Modal */} {/* Create Transaction Modal */}
<TransactionFormModal <TransactionFormModal
isOpen={isCreateOpen} isOpen={isCreateOpen}
initialValues={prefilledData}
initialReceiptFile={prefilledFile}
isSubmitting={createMutation.isPending} isSubmitting={createMutation.isPending}
onClose={() => setIsCreateOpen(false)} onClose={() => {
setIsCreateOpen(false);
setPrefilledData(undefined);
setPrefilledFile(null);
}}
onSubmit={handleCreate} onSubmit={handleCreate}
/> />
......
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);
}
},
};
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: [] }),
}));
import { create } from "zustand"; import { create } from "zustand";
import { User } from "@/types/auth"; import { User } from "@/types/auth";
import { useAIChatStore } from "@/stores/ai-chat-store";
interface AuthState { interface AuthState {
user: User | null; user: User | null;
...@@ -37,6 +38,7 @@ export const useAuthStore = create<AuthState>((set) => { ...@@ -37,6 +38,7 @@ export const useAuthStore = create<AuthState>((set) => {
clearAuth: () => { clearAuth: () => {
localStorage.removeItem("accessToken"); localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken"); localStorage.removeItem("refreshToken");
useAIChatStore.getState().clearMessages();
set({ set({
user: null, user: null,
accessToken: null, accessToken: null,
......
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;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment