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";
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>
......
......@@ -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" },
];
......
......@@ -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>
);
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 @@
"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."
}
}
}
......@@ -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."
}
}
}
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;
}
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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 { Header, Page, useNavigate, useSnackbar } from "zmp-ui";
import { Header, Page, useLocation, useNavigate, 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">
......
......@@ -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"
>
......
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>
);
};
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 { 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">
......
......@@ -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, initialReceiptFile, isOpen, reset]);
const selectedType = watch("type") as TransactionType;
const selectedWalletId = watch("walletId");
......
......@@ -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}
/>
......
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 { 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,
......
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