Commit cff98a1d authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/claymorphism-advanced-features-ui' into 'develop'

feat(fe): build full-stack UI for 5 advanced financial modules

See merge request !22
parents ef58916c 8f1603d9
......@@ -64,6 +64,11 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Module mục tiêu tiết kiệm đã có frontend tại `/saving-goals``/saving-goals/:id`, kết nối Saving Goal API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, pause/resume, archive/restore, tìm kiếm, lọc trạng thái/thời hạn, sắp xếp, phân trang và quản lý đầy đủ tạo/sửa/xóa lịch sử đóng góp; có validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
- Module báo cáo tài chính đã có frontend tại `/reports`, kết nối đầy đủ Report API qua TanStack Query, gồm lọc ngày/tuần/tháng/năm/khoảng tùy chọn, ví và tiền tệ; KPI tổng quan, dòng tiền, cơ cấu chi tiêu, hiệu suất ngân sách, mục tiêu tiết kiệm, loading/error/empty state, responsive và bản dịch Việt/Anh. Khối tóm tắt AI đã tích hợp AI Insights, hiển thị nhận định cùng các điểm nổi bật và điều hướng sang phân tích chi tiết với phạm vi báo cáo được giữ nguyên.
- Module thông báo và nhắc nhở đã có frontend tại `/notifications`, kết nối Notification/Reminder API qua TanStack Query, gồm badge chưa đọc toàn cục, danh sách/lọc/phân trang, đánh dấu đọc một/tất cả, xóa, điều hướng tới nguồn, CRUD và bật/tắt lịch nhắc, tùy chọn loại cảnh báo/kênh nhận; có polling gần thời gian thực, loading/error/empty state, xác nhận thao tác và bản dịch Việt/Anh.
- Module dự báo dòng tiền và ngân sách (Upgrade 1) đã có frontend tại `/forecast`, kết nối Forecast API (`/forecast/runway`, `/forecast/budget-depletion`) qua TanStack Query; gồm chọn khoảng dự báo (14, 30, 60, 90 ngày), thước đo số ngày an toàn tài chính (runway) với exponential burn velocity ($\lambda=0.04$), phân tích nguy cơ vỡ ngân sách, hạn mức chi tối đa mỗi ngày, Claymorphism skeleton loading, error/retry state, và bản dịch Việt/Anh.
- Module mô phỏng kịch bản tài chính What-if (Upgrade 2) đã có frontend tại `/simulations`, kết nối Simulation API (`/simulations/presets`, `/simulations/run`) qua TanStack Query; gồm áp dụng kịch bản mẫu có sẵn, thanh trượt thời gian 3-36 tháng đúng contract Backend, cấu hình biến động dòng tiền tùy chỉnh (tăng/giảm thu nhập, chi tiêu), bảng so sánh số dư baseline/simulated, cảnh báo nguy cơ thâm hụt, tác động đến mục tiêu tiết kiệm, diễn biến trajectory từng tháng, skeleton loading và bản dịch Việt/Anh.
- Module phát hiện chi tiêu bất thường (Upgrade 3) đã có frontend tại `/anomalies`, kết nối Anomaly API (`/anomalies/recent`, `/anomalies/evaluate`) qua TanStack Query; gồm công cụ kiểm tra nhanh giao dịch trước khi chi (Pre-Purchase Evaluator) dựa trên Modified Z-Score & MAD, danh sách lịch sử giao dịch bị gắn cờ với mức độ nghiêm trọng và mã lý do, skeleton loading và bản dịch Việt/Anh.
- Module tự động nhận diện dịch vụ định kỳ (Upgrade 4) đã có frontend tại `/subscriptions`, kết nối Subscription API (`/subscriptions/discover`, `/subscriptions/convert-to-reminder`) qua TanStack Query; gồm danh sách các gói cước định kỳ phát hiện được với chu kỳ và độ chắc chắn, cảnh báo tăng giá cước (price drift), nút 1-click chuyển đổi thành nhắc nhở tự động, skeleton loading và bản dịch Việt/Anh.
- Module truy vấn thông minh DSL (Upgrade 5) đã có frontend tại `/query`, kết nối Query API (`/query/parse`, `/query/execute`) qua TanStack Query; gồm thanh nhập liệu ngôn ngữ tự nhiên, chip câu hỏi gợi ý nhanh, bộ kiểm tra cây cú pháp trừu tượng AST, thẻ tổng hợp số liệu thống kê trực tiếp từ DB, phân tích theo nhóm (GroupBy) và danh sách giao dịch chi tiết, skeleton loading và bản dịch Việt/Anh.
## Khi cập nhật file này
......
......@@ -37,6 +37,11 @@ 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";
import ForecastPage from "@/pages/forecast/index";
import SimulationsPage from "@/pages/simulations/index";
import AnomaliesPage from "@/pages/anomalies/index";
import SubscriptionsPage from "@/pages/subscriptions/index";
import QueryPage from "@/pages/query/index";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
......@@ -104,6 +109,11 @@ const Layout = () => {
<Route path="/saving-goals" element={<AuthGuard><SavingGoalsPage /></AuthGuard>}></Route>
<Route path="/saving-goals/:id" element={<AuthGuard><SavingGoalDetailPage /></AuthGuard>}></Route>
<Route path="/reports" element={<AuthGuard><ReportsPage /></AuthGuard>}></Route>
<Route path="/forecast" element={<AuthGuard><ForecastPage /></AuthGuard>}></Route>
<Route path="/simulations" element={<AuthGuard><SimulationsPage /></AuthGuard>}></Route>
<Route path="/anomalies" element={<AuthGuard><AnomaliesPage /></AuthGuard>}></Route>
<Route path="/subscriptions" element={<AuthGuard><SubscriptionsPage /></AuthGuard>}></Route>
<Route path="/query" element={<AuthGuard><QueryPage /></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>
......
......@@ -24,6 +24,11 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/saving-goals", key: "document.savingGoals" },
{ matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" },
{ matches: (pathname) => pathname === "/reports", key: "document.reports" },
{ matches: (pathname) => pathname === "/forecast", key: "document.forecast" },
{ matches: (pathname) => pathname === "/simulations", key: "document.simulations" },
{ matches: (pathname) => pathname === "/anomalies", key: "document.anomalies" },
{ matches: (pathname) => pathname === "/subscriptions", key: "document.subscriptions" },
{ matches: (pathname) => pathname === "/query", key: "document.query" },
{ matches: (pathname) => pathname === "/notifications", key: "document.notifications" },
{ matches: (pathname) => pathname === "/ai-assistant", key: "document.aiAssistant" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
......
import { useMutation, useQuery } from "@tanstack/react-query";
import { anomalyService } from "@/services/anomaly.service";
import { EvaluateAnomalyInput } from "@/types/anomaly";
export const ANOMALY_QUERY_KEYS = {
all: ["anomalies"] as const,
recent: ["anomalies", "recent"] as const,
};
export function useRecentAnomalies() {
return useQuery({
queryKey: ANOMALY_QUERY_KEYS.recent,
queryFn: () => anomalyService.getRecent(),
staleTime: 30 * 1000,
});
}
export function useEvaluateAnomaly() {
return useMutation({
mutationFn: (input: EvaluateAnomalyInput) => anomalyService.evaluate(input),
});
}
import { useQuery } from "@tanstack/react-query";
import { forecastService } from "@/services/forecast.service";
import { ForecastQuery } from "@/types/forecast";
export const FORECAST_QUERY_KEYS = {
all: ["forecast"] as const,
runway: (query?: ForecastQuery) => ["forecast", "runway", query] as const,
budgetDepletion: ["forecast", "budget-depletion"] as const,
};
export function useForecastRunway(query?: ForecastQuery) {
return useQuery({
queryKey: FORECAST_QUERY_KEYS.runway(query),
queryFn: () => forecastService.getRunway(query),
staleTime: 60 * 1000,
});
}
export function useBudgetDepletion() {
return useQuery({
queryKey: FORECAST_QUERY_KEYS.budgetDepletion,
queryFn: () => forecastService.getBudgetDepletion(),
staleTime: 60 * 1000,
});
}
import { useMutation } from "@tanstack/react-query";
import { queryService } from "@/services/query.service";
import { QueryAST } from "@/types/query";
export function useParseQuery() {
return useMutation({
mutationFn: (query: string) => queryService.parse(query),
});
}
export function useExecuteQuery() {
return useMutation({
mutationFn: (params: { query?: string; ast?: QueryAST }) => queryService.execute(params),
});
}
import { useMutation, useQuery } from "@tanstack/react-query";
import { simulationService } from "@/services/simulation.service";
import { RunSimulationInput } from "@/types/simulation";
export const SIMULATION_QUERY_KEYS = {
all: ["simulations"] as const,
presets: ["simulations", "presets"] as const,
};
export function useSimulationPresets() {
return useQuery({
queryKey: SIMULATION_QUERY_KEYS.presets,
queryFn: () => simulationService.getPresets(),
staleTime: 5 * 60 * 1000,
});
}
export function useRunSimulation() {
return useMutation({
mutationFn: (input: RunSimulationInput) => simulationService.runSimulation(input),
});
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { subscriptionService } from "@/services/subscription.service";
import { ConvertSubscriptionInput } from "@/types/subscription";
import { reminderKeys } from "@/hooks/use-reminders";
export const SUBSCRIPTION_QUERY_KEYS = {
all: ["subscriptions"] as const,
discover: ["subscriptions", "discover"] as const,
};
export function useDiscoveredSubscriptions() {
return useQuery({
queryKey: SUBSCRIPTION_QUERY_KEYS.discover,
queryFn: () => subscriptionService.discover(),
staleTime: 60 * 1000,
});
}
export function useConvertToReminder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ConvertSubscriptionInput) => subscriptionService.convertToReminder(input),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: SUBSCRIPTION_QUERY_KEYS.all }),
queryClient.invalidateQueries({ queryKey: reminderKeys.all }),
]);
},
});
}
......@@ -53,6 +53,11 @@
"savingGoals": "Saving Goals",
"savingGoalDetail": "Saving Goal Details",
"reports": "Financial Reports",
"forecast": "Cash Flow Runway & Budget Forecaster",
"simulations": "What-if Financial Simulation Sandbox",
"anomalies": "Multi-dimensional Anomaly Detection",
"subscriptions": "Auto Subscriptions & Recurring Bills",
"query": "Smart Natural Language Query DSL",
"notifications": "Notifications & Reminders",
"aiAssistant": "AI Financial Assistant"
},
......@@ -1340,5 +1345,219 @@
"invalidResponse": "AI response format was invalid. Please try again.",
"extractFailed": "Failed to extract receipt data. Please check the image and try again."
}
},
"home": {
"header": "FinWise Mini App",
"greeting": "Hello, {{name}}!",
"subtitle": "Smart Financial Management & Runway Forecasting",
"account": "Account:",
"authTitle": "Operational Status",
"authDescription": "Secure multi-tenant authentication with isolated data permissions.",
"aiAssistant": "FinWise AI Assistant",
"smartQuery": "Smart DSL Query Engine",
"advancedFeatures": "Advanced Upgrades",
"forecast": "Cash Flow Runway",
"simulations": "What-if Sandbox",
"anomalies": "Anomaly Alerts",
"subscriptions": "Recurring Bills",
"coreModules": "Core Financials",
"wallets": "Wallets & Balances",
"transactions": "Transactions",
"transfers": "Transfers",
"budgets": "Budgets",
"savingGoals": "Saving Goals",
"categories": "Categories",
"reports": "Financial Reports",
"notifications": "Notifications",
"profile": "Profile",
"styleGuide": "Style Guide"
},
"forecast": {
"pageTitle": "Cash Flow Runway & Budget Forecaster",
"selectHorizon": "Forecast Horizon",
"horizonOption": "{{days}} days",
"runwayTitle": "Financial Runway",
"days": "days",
"runwayDays": "{{days}} days",
"daysLeft": "{{days}} days left",
"unlimitedRunway": "Long-term Sustainable (Positive Cash Flow)",
"sustainable": "Healthy Cash Flow",
"currentBalance": "Current Total Balance",
"dailyBurnRate": "Average Net Daily Cash Flow",
"day": "day",
"depletionAlert": "Projected Balance Depletion Date",
"historySummary": "Based on {{transactions}} transactions across the last {{days}} days.",
"dataSufficiency": "Historical Data Confidence",
"sufficiency_ROBUST": "High confidence",
"sufficiency_SPARSE": "Limited history",
"sufficiency_INSUFFICIENT": "Insufficient history",
"budgetDepletionTitle": "Budget Exhaustion Risk Analysis",
"noActiveBudgets": "No active budgets found for the current period.",
"projectedExhaustion": "Projected exhaustion",
"onTrackToEnd": "On track through period end",
"dailyCap": "Max daily spending cap",
"risk_LOW": "Safe",
"risk_MEDIUM": "Warning",
"risk_HIGH": "High Risk",
"risk_CRITICAL": "Over Budget",
"fetchError": "Unable to fetch runway forecast data. Please try again."
},
"simulations": {
"pageTitle": "What-if Financial Simulation Sandbox",
"presetTemplates": "Preset Simulation Scenarios",
"applyTemplate": "Apply Template",
"templateApplied": "Applied template: {{name}}",
"presetsFetchError": "Could not load simulation templates.",
"noPresets": "No simulation templates are available.",
"presets": {
"installment-loan": {
"title": "Installment purchase",
"description": "Model a recurring installment payment, such as a phone or motorbike purchase."
},
"salary-increase": {
"title": "Recurring income increase",
"description": "Model a salary increase or an additional recurring income source."
},
"frugal-budget-cut": {
"title": "Reduce spending",
"description": "Model a 20% reduction in non-essential spending."
},
"one-time-purchase": {
"title": "Large one-time expense",
"description": "Model a future one-time expense, such as travel or tuition."
}
},
"runError": "Could not run this simulation. Please try again.",
"scenarioName": "Simulation Scenario Name",
"namePlaceholder": "e.g., New Job, Car Loan Installment...",
"simulationHorizon": "Simulation Horizon",
"monthCount": "{{months}} months",
"decreaseHorizon": "Decrease simulation horizon",
"increaseHorizon": "Increase simulation horizon",
"months": "months",
"month": "month",
"activeAdjustments": "Active Scenario Adjustments",
"noAdjustments": "No adjustments added yet. Pick a template above or add a custom one below.",
"addQuickAdjustment": "Add Custom Financial Perturbation",
"income": "Add Income",
"expense": "Expense Change",
"adjustmentName": "Adjustment name",
"adjustmentAmount": "Monthly amount",
"percentageChange": "{{percentage}}% change",
"descPlaceholder": "Description (e.g. 20% Salary Raise, New Rent...)",
"amountPlaceholder": "Monthly amount",
"addAdjustmentBtn": "Add to Scenario",
"runSimulationBtn": "Run Scenario Simulation",
"simulationOutcome": "Comparative Simulation Outcome",
"baselineBalance": "Baseline Expected Balance",
"simulatedBalance": "Simulated Ending Balance",
"deficitAlertTitle": "Projected Cash Deficit Risk",
"deficitMonth": "The lowest projected balance occurs in month {{month}}.",
"goalImpactsTitle": "Saving Goal Collisions & Impacts",
"goalDelayed": "Delayed by {{months}} months",
"goalOnTrack": "On Schedule",
"goalStatus": {
"ON_TRACK": "On schedule",
"ACCELERATED": "Ahead of schedule",
"DELAYED": "Delayed",
"UNACHIEVABLE": "Not achievable"
},
"validation": {
"nameRequired": "Enter a name for this adjustment.",
"amountInvalid": "Enter a valid amount greater than zero."
},
"monthlyTrajectoryTitle": "Monthly Balance Trajectory",
"monthNumber": "Month {{month}}"
},
"anomalies": {
"pageTitle": "Multi-dimensional Anomaly Detection",
"realtimeCheckerTitle": "Pre-Purchase Anomaly Evaluator",
"realtimeCheckerDesc": "Modified Z-Score algorithm analyzes prospective spending against category historical medians and wallet balances.",
"selectWallet": "Select Payment Wallet",
"selectCategory": "Select Spending Category",
"amountToTest": "Amount to evaluate",
"amountPlaceholder": "For example: 800,000",
"walletPlaceholder": "Choose a wallet",
"categoryPlaceholder": "Choose a category",
"testExpenseBtn": "Evaluate Spending Anomaly",
"score": "Score: {{score}}%",
"median": "Median: {{amount}}",
"modifiedZScore": "Modified Z-Score: {{score}}",
"optionsFetchError": "Could not load wallets or expense categories.",
"noWallets": "Create an active wallet before evaluating an expense.",
"noCategories": "Create an active expense category before evaluating an expense.",
"evaluateError": "Could not evaluate this expense. Check the details and try again.",
"historyFetchError": "Could not load recent anomaly history.",
"anomalyDetected": "Anomaly Detected!",
"normalExpense": "Normal Spending Pattern",
"flaggedHistoryTitle": "Flagged Unusual Transactions",
"noAnomaliesTitle": "No unusual transactions flagged",
"noAnomaliesDesc": "All recent expenses align within safe category medians.",
"severity": {
"ELEVATED": "Elevated",
"HIGH": "High",
"CRITICAL": "Critical"
},
"validation": {
"walletRequired": "Choose a wallet.",
"categoryRequired": "Choose an expense category.",
"amountInvalid": "Enter a valid amount greater than zero."
},
"code_SPIKE_VS_CATEGORY_MEDIAN": "Category Median Spike",
"code_VELOCITY_BURST": "High Velocity Burst",
"code_OFF_PEAK_SURGE": "Off-Peak Night Surge (02:00-05:00)",
"code_HIGH_PERCENTAGE_OF_WALLET": "Large Wallet Balance Ratio",
"code_FIRST_TIME_HIGH_VALUE": "First-Time High Value Charge"
},
"subscriptions": {
"pageTitle": "Auto Subscriptions & Recurring Bills",
"bannerTitle": "Automatic Subscription Discovery",
"bannerDesc": "Automatically identifies periodic subscriptions (Netflix, Spotify, Internet, Gym) and warns against price increases.",
"discoveredTitle": "Discovered Recurring Services",
"noSubscriptionsTitle": "No subscriptions discovered yet",
"noSubscriptionsDesc": "The engine requires at least 3 regular periodic transactions to confirm a subscription pattern.",
"freq_WEEKLY": "Weekly",
"freq_MONTHLY": "Monthly",
"freq_YEARLY": "Yearly",
"freq_DAILY": "Daily",
"occurrences": "billing cycles",
"confidence": "Confidence",
"priceHikeAlert": "Price Increase Detected!",
"nextBilling": "Next Expected Charge",
"trackedInReminders": "Tracked in Reminders",
"convertToReminderBtn": "Add to Reminders",
"convertSuccess": "Successfully created reminder for {{name}}!",
"fetchError": "Could not discover recurring services. Please try again."
},
"query": {
"pageTitle": "Smart Natural Language Query DSL",
"bannerTitle": "Natural Language Financial Query",
"bannerDesc": "Translates natural language questions into deterministic Abstract Syntax Trees (AST) and compiles to type-safe database queries.",
"inputLabel": "Ask a question or enter a search query",
"inputPlaceholder": "e.g., What is my total dining spending this month?",
"runBtn": "Execute Query",
"executeError": "Could not execute this query. Please try again.",
"suggestedPrompts": "Suggested Prompts",
"prompts": {
"totalExpense": "Total expenses this month",
"foodExpense": "Food expenses this week",
"largeExpense": "List expenses > 500000 in the last 30 days",
"lastMonthIncome": "Total income last month",
"byCategory": "Expenses by category this month",
"byWallet": "Expenses by wallet this month"
},
"validation": {
"required": "Enter a financial question.",
"max": "The question cannot exceed 300 characters."
},
"astType": "Type",
"astAggregation": "Aggregation",
"astGroupBy": "Group by",
"transactionsCount": "transactions",
"totalAmount": "Total Query Value",
"average": "Average",
"tx": "transactions",
"breakdownTitle": "Grouped Breakdown",
"detailedItemsTitle": "Detailed Matching Transactions"
}
}
......@@ -53,6 +53,11 @@
"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",
"forecast": "Dự báo Dòng tiền & Ngân sách",
"simulations": "Mô phỏng Kịch bản Tài chính (What-if)",
"anomalies": "Phát hiện Chi tiêu Bất thường",
"subscriptions": "Hóa đơn & Dịch vụ Định kỳ",
"query": "Truy vấn Thông minh DSL",
"notifications": "Thông báo & nhắc nhở",
"aiAssistant": "Trợ lý tài chính AI"
},
......@@ -1349,5 +1354,219 @@
"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."
}
},
"home": {
"header": "FinWise Mini App",
"greeting": "Xin chào, {{name}}!",
"subtitle": "Quản lý Tài chính Thông minh & Dự báo Dòng tiền",
"account": "Tài khoản:",
"authTitle": "Trạng thái Hoạt động",
"authDescription": "Hệ thống bảo mật xác thực với đa tài khoản và phân quyền dữ liệu độc lập.",
"aiAssistant": "Trợ lý FinWise AI",
"smartQuery": "Truy vấn Thông minh DSL",
"advancedFeatures": "Tính năng Nâng cao",
"forecast": "Dự báo Dòng tiền",
"simulations": "Mô phỏng What-if",
"anomalies": "Cảnh báo Bất thường",
"subscriptions": "Hóa đơn Định kỳ",
"coreModules": "Danh mục Nghiệp vụ",
"wallets": "Ví & Số dư",
"transactions": "Giao dịch",
"transfers": "Chuyển tiền",
"budgets": "Ngân sách",
"savingGoals": "Mục tiêu Tiết kiệm",
"categories": "Danh mục Chi tiêu",
"reports": "Báo cáo",
"notifications": "Thông báo",
"profile": "Cá nhân",
"styleGuide": "Hướng dẫn Giao diện"
},
"forecast": {
"pageTitle": "Dự báo Dòng tiền & Ngân sách",
"selectHorizon": "Khoảng thời gian dự báo",
"horizonOption": "{{days}} ngày",
"runwayTitle": "Thời gian An toàn Tài chính (Runway)",
"days": "ngày",
"runwayDays": "{{days}} ngày",
"daysLeft": "Còn {{days}} ngày",
"unlimitedRunway": "Bền vững dài hạn (Dòng tiền dương)",
"sustainable": "Dòng tiền tốt",
"currentBalance": "Tổng số dư hiện tại",
"dailyBurnRate": "Dòng tiền ròng bình quân mỗi ngày",
"day": "ngày",
"depletionAlert": "Dự kiến cạn kiệt số dư vào ngày",
"historySummary": "Dự báo dựa trên {{transactions}} giao dịch trong {{days}} ngày gần nhất.",
"dataSufficiency": "Độ tin cậy dữ liệu lịch sử",
"sufficiency_ROBUST": "Độ tin cậy cao",
"sufficiency_SPARSE": "Lịch sử còn hạn chế",
"sufficiency_INSUFFICIENT": "Chưa đủ dữ liệu",
"budgetDepletionTitle": "Phân tích Nguy cơ Vỡ Ngân sách",
"noActiveBudgets": "Không có ngân sách nào đang hoạt động trong kỳ này.",
"projectedExhaustion": "Dự kiến hết hạn mức",
"onTrackToEnd": "An toàn đến hết kỳ",
"dailyCap": "Hạn mức chi tối đa mỗi ngày",
"risk_LOW": "An toàn",
"risk_MEDIUM": "Cần chú ý",
"risk_HIGH": "Nguy cơ cao",
"risk_CRITICAL": "Vượt ngân sách",
"fetchError": "Không thể tải dữ liệu dự báo dòng tiền. Vui lòng thử lại sau."
},
"simulations": {
"pageTitle": "Mô phỏng Kịch bản Tài chính (What-if)",
"presetTemplates": "Kịch bản Mẫu có sẵn",
"applyTemplate": "Áp dụng mẫu này",
"templateApplied": "Đã áp dụng mẫu: {{name}}",
"presetsFetchError": "Không thể tải các kịch bản mẫu.",
"noPresets": "Hiện chưa có kịch bản mẫu.",
"presets": {
"installment-loan": {
"title": "Mua hàng trả góp",
"description": "Mô phỏng khoản trả góp định kỳ, chẳng hạn mua điện thoại hoặc xe máy."
},
"salary-increase": {
"title": "Tăng thu nhập định kỳ",
"description": "Mô phỏng tăng lương hoặc có thêm nguồn thu nhập định kỳ."
},
"frugal-budget-cut": {
"title": "Thắt chặt chi tiêu",
"description": "Mô phỏng giảm 20% các khoản chi tiêu không thiết yếu."
},
"one-time-purchase": {
"title": "Khoản chi lớn đột xuất",
"description": "Mô phỏng khoản chi một lần trong tương lai, chẳng hạn du lịch hoặc học phí."
}
},
"runError": "Không thể chạy mô phỏng. Vui lòng thử lại.",
"scenarioName": "Tên kịch bản mô phỏng",
"namePlaceholder": "Ví dụ: Đổi công việc mới, Mua xe trả góp...",
"simulationHorizon": "Thời gian mô phỏng",
"monthCount": "{{months}} tháng",
"decreaseHorizon": "Giảm thời gian mô phỏng",
"increaseHorizon": "Tăng thời gian mô phỏng",
"months": "tháng",
"month": "tháng",
"activeAdjustments": "Các biến động đã cấu hình",
"noAdjustments": "Chưa có biến động nào. Chọn mẫu ở trên hoặc thêm biến động bên dưới.",
"addQuickAdjustment": "Thêm biến động dòng tiền tùy chỉnh",
"income": "Thêm Thu nhập",
"expense": "Thêm Khoản Chi tiêu",
"adjustmentName": "Tên biến động",
"adjustmentAmount": "Số tiền mỗi tháng",
"percentageChange": "Thay đổi {{percentage}}%",
"descPlaceholder": "Mô tả (ví dụ: Tăng lương 20%, Tiền thuê nhà mới...)",
"amountPlaceholder": "Số tiền mỗi tháng",
"addAdjustmentBtn": "Thêm vào kịch bản",
"runSimulationBtn": "Chạy Mô phỏng Kịch bản",
"simulationOutcome": "Kết quả Mô phỏng So sánh",
"baselineBalance": "Số dư kỳ vọng (Thông thường)",
"simulatedBalance": "Số dư sau Mô phỏng",
"deficitAlertTitle": "Cảnh báo Thâm hụt Tài chính",
"deficitMonth": "Số dư dự kiến thấp nhất rơi vào tháng thứ {{month}}.",
"goalImpactsTitle": "Tác động đến Mục tiêu Tiết kiệm",
"goalDelayed": "Bị chậm {{months}} tháng",
"goalOnTrack": "Đúng tiến độ",
"goalStatus": {
"ON_TRACK": "Đúng tiến độ",
"ACCELERATED": "Sớm hơn tiến độ",
"DELAYED": "Chậm tiến độ",
"UNACHIEVABLE": "Không thể đạt"
},
"validation": {
"nameRequired": "Vui lòng nhập tên biến động.",
"amountInvalid": "Vui lòng nhập số tiền hợp lệ lớn hơn 0."
},
"monthlyTrajectoryTitle": "Diễn biến Số dư qua từng tháng",
"monthNumber": "Tháng {{month}}"
},
"anomalies": {
"pageTitle": "Phát hiện Chi tiêu Bất thường",
"realtimeCheckerTitle": "Kiểm tra Nhanh Giao dịch trước khi Chi",
"realtimeCheckerDesc": "Thuật toán Modified Z-Score sẽ phân tích giao dịch so với lịch sử danh mục và số dư ví của bạn.",
"selectWallet": "Chọn Ví thanh toán",
"selectCategory": "Chọn Danh mục chi tiêu",
"amountToTest": "Số tiền muốn kiểm tra",
"amountPlaceholder": "Ví dụ: 800.000",
"walletPlaceholder": "Chọn ví",
"categoryPlaceholder": "Chọn danh mục",
"testExpenseBtn": "Kiểm tra Tính Bất thường",
"score": "Điểm: {{score}}%",
"median": "Trung vị: {{amount}}",
"modifiedZScore": "Modified Z-Score: {{score}}",
"optionsFetchError": "Không thể tải ví hoặc danh mục chi tiêu.",
"noWallets": "Hãy tạo một ví đang hoạt động trước khi kiểm tra khoản chi.",
"noCategories": "Hãy tạo một danh mục chi tiêu đang hoạt động trước khi kiểm tra khoản chi.",
"evaluateError": "Không thể kiểm tra khoản chi này. Vui lòng kiểm tra dữ liệu và thử lại.",
"historyFetchError": "Không thể tải lịch sử giao dịch bất thường.",
"anomalyDetected": "Phát hiện Chi tiêu Bất thường!",
"normalExpense": "Chi tiêu Trong Giới hạn Bình thường",
"flaggedHistoryTitle": "Lịch sử Chi tiêu Đáng chú ý",
"noAnomaliesTitle": "Không có giao dịch bất thường",
"noAnomaliesDesc": "Mọi khoản chi tiêu gần đây đều nằm trong mức trung vị an toàn.",
"severity": {
"ELEVATED": "Đáng chú ý",
"HIGH": "Cao",
"CRITICAL": "Nghiêm trọng"
},
"validation": {
"walletRequired": "Vui lòng chọn ví.",
"categoryRequired": "Vui lòng chọn danh mục chi tiêu.",
"amountInvalid": "Vui lòng nhập số tiền hợp lệ lớn hơn 0."
},
"code_SPIKE_VS_CATEGORY_MEDIAN": "Chi tiêu Đột biến so với Trung vị",
"code_VELOCITY_BURST": "Chuỗi Giao dịch Liên tiếp",
"code_OFF_PEAK_SURGE": "Giao dịch Đêm khuya (02:00-05:00)",
"code_HIGH_PERCENTAGE_OF_WALLET": "Chiếm phần lớn Số dư Ví",
"code_FIRST_TIME_HIGH_VALUE": "Chi tiêu Lớn Lần đầu"
},
"subscriptions": {
"pageTitle": "Hóa đơn & Dịch vụ Định kỳ",
"bannerTitle": "Tự động Nhận diện Đăng ký & Gói cước",
"bannerDesc": "Hệ thống tự động phát hiện các khoản phí định kỳ (Netflix, Spotify, Internet, Gym...) từ lịch sử giao dịch và cảnh báo khi có tăng giá.",
"discoveredTitle": "Dịch vụ Định kỳ Phát hiện được",
"noSubscriptionsTitle": "Chưa tìm thấy dịch vụ định kỳ",
"noSubscriptionsDesc": "Hệ thống cần ít nhất 3 giao dịch lặp lại theo chu kỳ đều đặn để tự động phát hiện.",
"freq_WEEKLY": "Hàng tuần",
"freq_MONTHLY": "Hàng tháng",
"freq_YEARLY": "Hàng năm",
"freq_DAILY": "Hàng ngày",
"occurrences": "lần thanh toán",
"confidence": "Độ chắc chắn",
"priceHikeAlert": "Phát hiện Tăng giá cước!",
"nextBilling": "Dự kiến thu tiếp theo",
"trackedInReminders": "Đã theo dõi trong Nhắc nhở",
"convertToReminderBtn": "Thêm vào Nhắc nhở",
"convertSuccess": "Đã tạo nhắc nhở cho {{name}} thành công!",
"fetchError": "Không thể nhận diện dịch vụ định kỳ. Vui lòng thử lại."
},
"query": {
"pageTitle": "Truy vấn Thông minh DSL",
"bannerTitle": "Hỏi đáp Tài chính bằng Ngôn ngữ Tự nhiên",
"bannerDesc": "Chuyển đổi câu hỏi tiếng Việt sang cấu trúc AST và thực thi truy vấn trực tiếp trên cơ sở dữ liệu với độ chính xác 100%.",
"inputLabel": "Nhập câu hỏi hoặc yêu cầu thống kê",
"inputPlaceholder": "Ví dụ: Tổng chi tiêu ăn uống tháng này là bao nhiêu?",
"runBtn": "Truy vấn",
"executeError": "Không thể thực thi truy vấn. Vui lòng thử lại.",
"suggestedPrompts": "Câu hỏi gợi ý nhanh",
"prompts": {
"totalExpense": "Tổng chi tiêu tháng này",
"foodExpense": "Chi tiêu ăn uống tuần này",
"largeExpense": "Liệt kê các khoản chi trên 500k trong 30 ngày qua",
"lastMonthIncome": "Tổng thu nhập tháng trước",
"byCategory": "Chi tiêu theo danh mục tháng này",
"byWallet": "Chi tiêu theo ví tháng này"
},
"validation": {
"required": "Vui lòng nhập câu hỏi tài chính.",
"max": "Câu hỏi không được vượt quá 300 ký tự."
},
"astType": "Loại",
"astAggregation": "Phép tổng hợp",
"astGroupBy": "Nhóm theo",
"transactionsCount": "giao dịch",
"totalAmount": "Tổng giá trị thống kê",
"average": "Bình quân",
"tx": "giao dịch",
"breakdownTitle": "Phân tích theo Nhóm",
"detailedItemsTitle": "Danh sách Giao dịch Chi tiết"
}
}
import React from "react";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { FlaggedAnomalyTransaction } from "@/types/anomaly";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format";
interface AnomalyAlertCardProps {
item: FlaggedAnomalyTransaction;
}
export const AnomalyAlertCard: React.FC<AnomalyAlertCardProps> = ({ item }) => {
const { t, formatCurrency, intlLocale } = useI18n();
const amountNum = parseFloat(item.amount);
const getSeverityBadge = (score: number) => {
if (score >= 0.85) {
return <Badge type="expense" className="text-[10px] px-2 py-0.5">{t("anomalies.severity.CRITICAL")}</Badge>;
}
if (score >= 0.70) {
return <Badge type="warning" className="text-[10px] px-2 py-0.5">{t("anomalies.severity.HIGH")}</Badge>;
}
return <Badge type="info" className="text-[10px] px-2 py-0.5">{t("anomalies.severity.ELEVATED")}</Badge>;
};
return (
<Card className="space-y-3 p-4 border border-clay-expense/30 bg-clay-expense-soft shadow-clay-raised">
<div className="flex items-start justify-between">
<div>
<span className="text-xs text-clay-text-muted font-medium block">
{formatBusinessDate(item.date, intlLocale, { day: "2-digit", month: "2-digit", year: "numeric" })}
</span>
<h4 className="font-bold text-clay-text text-sm font-baloo mt-0.5">
{getCategoryDisplayName({ name: item.categoryName }, t)}
</h4>
<span className="text-[11px] text-clay-text-muted">{item.walletName}</span>
</div>
<div className="text-right space-y-1">
<p className="font-bold text-clay-expense text-sm">
{formatCurrency(amountNum, item.currency)}
</p>
{getSeverityBadge(item.anomalyScore)}
</div>
</div>
<p className="text-xs text-clay-text leading-relaxed bg-clay-surface p-3 rounded-clay-sm border border-clay-border/40 shadow-clay-pressed">
{item.explanation}
</p>
{item.reasonCodes.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{item.reasonCodes.map((code) => (
<span
key={code}
className="text-[10px] bg-clay-surface px-2.5 py-1 rounded-full border border-clay-border/50 text-clay-text-muted font-semibold shadow-clay-pressed"
>
#{t(`anomalies.code_${code}`)}
</span>
))}
</div>
)}
</Card>
);
};
import React, { useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Select } from "@/components/ui/Select";
import { useEvaluateAnomaly } from "@/hooks/use-anomalies";
import { useCategoryTree } from "@/hooks/use-categories";
import { useWallets } from "@/hooks/use-wallets";
import { TranslationFunction, useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern } from "@/lib/money-input";
import { AnomalyEvaluationResult } from "@/types/anomaly";
import { CategoryTreeNode } from "@/types/category";
const createSchema = (t: TranslationFunction) => z.object({
walletId: z.string().uuid(t("anomalies.validation.walletRequired")),
categoryId: z.string().uuid(t("anomalies.validation.categoryRequired")),
amount: z.string()
.regex(positiveAmountPattern, t("anomalies.validation.amountInvalid"))
.refine((value) => Number(value) > 0, t("anomalies.validation.amountInvalid")),
});
type AnomalyFormValues = z.infer<ReturnType<typeof createSchema>>;
function flattenCategories(nodes: CategoryTreeNode[]): CategoryTreeNode[] {
return nodes.reduce<CategoryTreeNode[]>(
(result, node) => [...result, node, ...flattenCategories(node.children)],
[],
);
}
export const AnomalyChecker: React.FC = () => {
const { t, formatCurrency, intlLocale } = useI18n();
const evaluateMutation = useEvaluateAnomaly();
const walletsQuery = useWallets({
includeArchived: false,
sortBy: "name",
order: "asc",
page: 1,
limit: 100,
});
const categoriesQuery = useCategoryTree({
type: "EXPENSE",
source: "ALL",
includeArchived: false,
});
const [result, setResult] = useState<AnomalyEvaluationResult | null>(null);
const schema = useMemo(() => createSchema(t), [t]);
const categories = flattenCategories(categoriesQuery.data?.data || []);
const wallets = walletsQuery.data?.data || [];
const {
control,
handleSubmit,
register,
watch,
formState: { errors },
} = useForm<AnomalyFormValues>({
resolver: zodResolver(schema),
defaultValues: { walletId: "", categoryId: "", amount: "" },
});
const selectedWalletId = watch("walletId");
const selectedCurrency = wallets.find((wallet) => wallet.id === selectedWalletId)?.currency || "VND";
const optionsLoading = walletsQuery.isLoading || categoriesQuery.isLoading;
const optionsError = walletsQuery.isError || categoriesQuery.isError;
const submitEvaluation = async (values: AnomalyFormValues) => {
setResult(null);
try {
const response = await evaluateMutation.mutateAsync({
...values,
amount: Number(values.amount).toFixed(2),
type: "EXPENSE",
});
setResult(response.data);
} catch {
// The mutation error state is rendered below with a retryable form.
}
};
return (
<Card className="space-y-4 p-5">
<div>
<h3 className="clay-title-h3 text-sm text-clay-text">
{t("anomalies.realtimeCheckerTitle")}
</h3>
<p className="mt-1 text-xs leading-relaxed text-clay-text-muted">
{t("anomalies.realtimeCheckerDesc")}
</p>
</div>
{optionsError ? (
<div className="space-y-3 rounded-clay-sm border border-clay-expense/30 bg-clay-expense-soft p-4 text-center">
<p className="text-xs font-bold text-clay-expense">{t("anomalies.optionsFetchError")}</p>
<Button
type="button"
variant="secondary"
className="px-4 text-sm"
onClick={() => {
void walletsQuery.refetch();
void categoriesQuery.refetch();
}}
>
{t("common.retry")}
</Button>
</div>
) : (
<form className="space-y-3" onSubmit={handleSubmit(submitEvaluation)} noValidate>
<Select
label={t("anomalies.selectWallet")}
options={[
{ value: "", label: t("anomalies.walletPlaceholder") },
...wallets.map((wallet) => ({
value: wallet.id,
label: `${wallet.name} (${formatCurrency(Number(wallet.balance), wallet.currency)})`,
})),
]}
disabled={optionsLoading || evaluateMutation.isPending}
error={errors.walletId?.message}
{...register("walletId")}
/>
<Select
label={t("anomalies.selectCategory")}
options={[
{ value: "", label: t("anomalies.categoryPlaceholder") },
...categories.map((category) => ({
value: category.id,
label: getCategoryDisplayName(category, t),
})),
]}
disabled={optionsLoading || evaluateMutation.isPending}
error={errors.categoryId?.message}
{...register("categoryId")}
/>
{!optionsLoading && (wallets.length === 0 || categories.length === 0) && (
<p className="rounded-clay-sm bg-clay-info-soft p-3 text-xs font-semibold text-clay-text">
{t(wallets.length === 0 ? "anomalies.noWallets" : "anomalies.noCategories")}
</p>
)}
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("anomalies.amountToTest")}
inputMode="decimal"
placeholder={t("anomalies.amountPlaceholder")}
value={formatMoneyInput(field.value, intlLocale)}
disabled={optionsLoading || evaluateMutation.isPending}
error={errors.amount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
<Button
type="submit"
variant="secondary"
fullWidth
disabled={optionsLoading || evaluateMutation.isPending || wallets.length === 0 || categories.length === 0}
className="py-2.5 text-sm"
>
{evaluateMutation.isPending ? t("common.processing") : t("anomalies.testExpenseBtn")}
</Button>
</form>
)}
{evaluateMutation.isError && (
<p role="alert" className="rounded-clay-sm bg-clay-expense-soft p-3 text-xs font-semibold text-clay-expense">
{t("anomalies.evaluateError")}
</p>
)}
{result && (
<div
className={`space-y-2 rounded-clay-sm border p-3.5 text-xs shadow-clay-pressed ${
result.isAnomaly
? "border-clay-expense/40 bg-clay-expense-soft"
: "border-clay-income/40 bg-clay-income-soft"
}`}
>
<div className="flex items-center justify-between gap-3">
<span className={`font-bold ${result.isAnomaly ? "text-clay-expense" : "text-clay-income"}`}>
{t(result.isAnomaly ? "anomalies.anomalyDetected" : "anomalies.normalExpense")}
</span>
<Badge type={result.isAnomaly ? "expense" : "income"} className="text-[10px]">
{t("anomalies.score", { score: Math.round(result.anomalyScore * 100) })}
</Badge>
</div>
<p className="text-[11px] leading-relaxed text-clay-text">{result.explanation}</p>
<div className="grid grid-cols-1 gap-1 border-t border-clay-border/30 pt-1.5 text-[10px] font-medium text-clay-text-muted sm:grid-cols-2">
<span>
{t("anomalies.median", {
amount: formatCurrency(Number(result.metrics.categoryMedian), selectedCurrency),
})}
</span>
<span>{t("anomalies.modifiedZScore", { score: result.metrics.modifiedZScore })}</span>
</div>
</div>
)}
</Card>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const AnomalySkeleton: React.FC = () => (
<div className="space-y-4 animate-pulse" aria-hidden="true">
<Card className="p-5 space-y-3">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-10 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
<div className="h-10 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
<div className="h-10 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
<div className="h-10 rounded-clay-sm bg-clay-primary/20" />
</Card>
<div className="space-y-3 pt-2">
<div className="h-4 w-1/4 rounded-full bg-clay-text-muted/15" />
<Card className="p-4 space-y-3">
<div className="flex justify-between">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-5 w-16 rounded-full bg-clay-expense/20" />
</div>
<div className="h-10 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</Card>
</div>
</div>
);
import React from "react";
import { Header, Page } from "zmp-ui";
import { Card } from "@/components/ui/Card";
import { useRecentAnomalies } from "@/hooks/use-anomalies";
import { useI18n } from "@/i18n";
import { AnomalyAlertCard } from "./components/AnomalyAlertCard";
import { AnomalyChecker } from "./components/AnomalyChecker";
import { AnomalySkeleton } from "./components/AnomalySkeleton";
const AnomaliesPage: React.FC = () => {
const { t } = useI18n();
const recentQuery = useRecentAnomalies();
const items = recentQuery.data?.data || [];
const isLoading = recentQuery.isLoading;
return (
<Page className="page min-h-screen pb-12 bg-clay-bg">
<Header title={t("anomalies.pageTitle")} showBackIcon={true} />
<div className="p-4 space-y-5 max-w-md mx-auto">
{/* Interactive Realtime Evaluator */}
<AnomalyChecker />
{/* Flagged Anomalies Section */}
<div className="space-y-3">
<h3 className="clay-title-h3 text-clay-text text-sm font-baloo">
{t("anomalies.flaggedHistoryTitle")} ({items.length})
</h3>
{isLoading && <AnomalySkeleton />}
{!isLoading && recentQuery.isError && (
<Card className="space-y-3 border border-clay-expense/30 bg-clay-expense-soft p-5 text-center">
<p className="text-xs font-bold text-clay-expense">{t("anomalies.historyFetchError")}</p>
<button
type="button"
onClick={() => void recentQuery.refetch()}
className="rounded-clay-sm bg-clay-surface px-4 py-2 font-baloo text-sm font-bold text-clay-text shadow-clay-raised transition-all duration-200 ease-in-out active:translate-y-[2px] active:shadow-clay-pressed"
>
{t("common.retry")}
</button>
</Card>
)}
{!isLoading && !recentQuery.isError && items.length === 0 && (
<Card className="text-center py-6 text-clay-text-muted space-y-1 p-5">
<p className="text-sm font-bold text-clay-text">{t("anomalies.noAnomaliesTitle")}</p>
<p className="text-xs">{t("anomalies.noAnomaliesDesc")}</p>
</Card>
)}
{!isLoading && !recentQuery.isError && items.length > 0 && (
<div className="space-y-3">
{items.map((item) => (
<AnomalyAlertCard key={item.transactionId} item={item} />
))}
</div>
)}
</div>
</div>
</Page>
);
};
export default AnomaliesPage;
import React from "react";
import { Card } from "@/components/ui/Card";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { BudgetDepletionItem } from "@/types/forecast";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
interface BudgetDepletionListProps {
items: BudgetDepletionItem[];
}
export const BudgetDepletionList: React.FC<BudgetDepletionListProps> = ({ items }) => {
const { t, formatCurrency, intlLocale } = useI18n();
if (items.length === 0) {
return (
<Card className="text-center py-6 text-clay-text-muted p-5">
<p className="text-xs font-medium">{t("forecast.noActiveBudgets")}</p>
</Card>
);
}
const getRiskType = (risk: BudgetDepletionItem["riskLevel"]): BadgeType => {
switch (risk) {
case "CRITICAL":
case "HIGH":
return "expense";
case "MEDIUM":
return "warning";
case "LOW":
return "income";
default:
return "info";
}
};
return (
<div className="space-y-3">
{items.map((budget) => {
const spent = parseFloat(budget.spentAmount);
const limit = parseFloat(budget.budgetAmount);
const percent = limit > 0 ? Math.min(100, Math.round((spent / limit) * 100)) : 0;
const recommendedDailySpend = parseFloat(budget.recommendedDailySpend);
return (
<Card key={budget.budgetId} className="space-y-3 p-4">
<div className="flex items-start justify-between">
<div>
<h4 className="font-bold text-clay-text text-sm font-baloo">{budget.budgetName}</h4>
{budget.categoryName && (
<p className="text-[11px] text-clay-text-muted">{budget.categoryName}</p>
)}
<p className="text-xs text-clay-text-muted mt-0.5">
{formatCurrency(spent, budget.currency)} / {formatCurrency(limit, budget.currency)}
</p>
</div>
<Badge type={getRiskType(budget.riskLevel)} className="text-[11px] px-2.5 py-0.5">
{t(`forecast.risk_${budget.riskLevel}`)}
</Badge>
</div>
<ProgressBar
value={percent}
type={percent >= 90 ? "expense" : percent >= 75 ? "warning" : "primary"}
/>
<div className="grid grid-cols-2 gap-2 text-xs pt-1 text-clay-text-muted">
<div>
<span className="block text-[11px]">{t("forecast.projectedExhaustion")}</span>
<span className="font-bold text-clay-text text-xs">
{budget.projectedExhaustionDate
? formatBusinessDate(budget.projectedExhaustionDate, intlLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
: t("forecast.onTrackToEnd")}
</span>
</div>
<div className="text-right">
<span className="block text-[11px]">{t("forecast.dailyCap")}</span>
<span className="font-bold text-clay-primary text-xs">
{formatCurrency(recommendedDailySpend, budget.currency)}
</span>
</div>
</div>
</Card>
);
})}
</div>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { ForecastRunway } from "@/types/forecast";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
interface ForecastRunwayCardProps {
data: ForecastRunway;
}
export const ForecastRunwayCard: React.FC<ForecastRunwayCardProps> = ({ data }) => {
const { t, formatCurrency, formatNumber, intlLocale } = useI18n();
const isFinite = data.metrics.runwayDays !== null;
const runwayDaysText = isFinite
? t("forecast.runwayDays", { days: data.metrics.runwayDays ?? 0 })
: t("forecast.unlimitedRunway");
const burnRate = Number(data.metrics.netDailyBurnRate);
const currentBal = parseFloat(data.currentBalance);
let badgeType: "income" | "warning" | "expense" = "income";
if (!isFinite) {
badgeType = "income";
} else if ((data.metrics.runwayDays ?? 0) < 14) {
badgeType = "expense";
} else if ((data.metrics.runwayDays ?? 0) < 30) {
badgeType = "warning";
}
return (
<Card className="space-y-4 p-5">
<div className="flex items-center justify-between">
<div>
<span className="clay-caption uppercase tracking-wider text-clay-text-muted font-bold text-[10px]">
{t("forecast.runwayTitle")}
</span>
<h2 className="clay-title-h2 text-clay-primary mt-1 font-baloo">{runwayDaysText}</h2>
</div>
<Badge type={badgeType} className="text-xs px-3 py-1 font-semibold">
{isFinite
? t("forecast.daysLeft", { days: data.metrics.runwayDays ?? 0 })
: t("forecast.sustainable")}
</Badge>
</div>
<p className="text-xs text-clay-text-muted leading-relaxed font-medium">
{t("forecast.historySummary", {
days: formatNumber(data.historicalDaysAnalyzed),
transactions: formatNumber(data.historicalTransactionCount),
})}
</p>
{/* Burn Rate & Current Balance Info Grid */}
<div className="grid grid-cols-2 gap-3 pt-2 border-t border-clay-border/40">
<div className="bg-clay-bg p-3.5 rounded-clay-sm shadow-clay-pressed border border-clay-border/30">
<span className="text-[11px] text-clay-text-muted font-medium block">{t("forecast.currentBalance")}</span>
<p className="font-bold text-clay-text text-sm mt-0.5">
{formatCurrency(currentBal, data.currency)}
</p>
</div>
<div className="bg-clay-bg p-3.5 rounded-clay-sm shadow-clay-pressed border border-clay-border/30">
<span className="text-[11px] text-clay-text-muted font-medium block">{t("forecast.dailyBurnRate")}</span>
<p className="font-bold text-clay-primary text-sm mt-0.5">
{formatCurrency(burnRate, data.currency)}/{t("forecast.day")}
</p>
</div>
</div>
{/* Projected Depletion Date */}
{data.metrics.depletionDate && (
<div className="bg-clay-expense-soft border border-clay-expense/30 p-3.5 rounded-clay-sm">
<span className="text-[11px] text-clay-expense font-bold block">
{t("forecast.depletionAlert")}
</span>
<p className="font-bold text-clay-expense text-sm mt-0.5">
{formatBusinessDate(data.metrics.depletionDate, intlLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
</div>
)}
{/* Trajectory Progress / Data Sufficiency */}
<div className="space-y-1.5 pt-1">
<div className="flex justify-between text-xs text-clay-text-muted">
<span className="font-medium">{t("forecast.dataSufficiency")}</span>
<span className="font-bold text-clay-text">{t(`forecast.sufficiency_${data.dataSufficiency}`)}</span>
</div>
<ProgressBar
value={data.dataSufficiency === "ROBUST" ? 100 : data.dataSufficiency === "SPARSE" ? 65 : 30}
type={data.dataSufficiency === "ROBUST" ? "income" : data.dataSufficiency === "SPARSE" ? "info" : "warning"}
/>
</div>
</Card>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const ForecastSkeleton: React.FC = () => (
<div className="space-y-4 animate-pulse" aria-hidden="true">
<Card className="p-5 space-y-4">
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<div className="h-3 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-7 w-1/2 rounded-full bg-clay-text-muted/20" />
</div>
<div className="h-6 w-20 rounded-full bg-clay-primary/20" />
</div>
<div className="h-10 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
<div className="grid grid-cols-2 gap-3 pt-2">
<div className="h-16 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
<div className="h-16 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</div>
<div className="h-3 rounded-full bg-clay-bg shadow-clay-pressed" />
</Card>
<div className="space-y-3 pt-2">
<div className="h-5 w-1/3 rounded-full bg-clay-text-muted/15" />
<Card className="p-4 space-y-3">
<div className="flex justify-between">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-5 w-16 rounded-full bg-clay-warning/20" />
</div>
<div className="h-3 rounded-full bg-clay-bg shadow-clay-pressed" />
</Card>
</div>
</div>
);
import React, { useState } from "react";
import { Header, Page } from "zmp-ui";
import { Tabs } from "@/components/ui/Tabs";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { useBudgetDepletion, useForecastRunway } from "@/hooks/use-forecast";
import { useI18n } from "@/i18n";
import { ForecastRunwayCard } from "./components/ForecastRunwayCard";
import { BudgetDepletionList } from "./components/BudgetDepletionList";
import { ForecastSkeleton } from "./components/ForecastSkeleton";
const HORIZON_DAYS = [14, 30, 60, 90] as const;
const ForecastPage: React.FC = () => {
const { t } = useI18n();
const [horizonDays, setHorizonDays] = useState("30");
const horizonTabs = HORIZON_DAYS.map((days) => ({
key: String(days),
label: t("forecast.horizonOption", { days }),
}));
const runwayQuery = useForecastRunway({
horizonDays: parseInt(horizonDays, 10),
});
const depletionQuery = useBudgetDepletion();
const isLoading = runwayQuery.isLoading || depletionQuery.isLoading;
const isError = runwayQuery.isError || depletionQuery.isError;
return (
<Page className="page min-h-screen pb-12 bg-clay-bg">
<Header title={t("forecast.pageTitle")} showBackIcon={true} />
<div className="p-4 space-y-5 max-w-md mx-auto">
{/* Horizon Selector Tabs */}
<div className="space-y-2">
<label className="clay-caption uppercase text-clay-text-muted font-bold text-[10px] tracking-wider">
{t("forecast.selectHorizon")}
</label>
<Tabs
tabs={horizonTabs}
activeTab={horizonDays}
onChange={(tab) => setHorizonDays(tab)}
/>
</div>
{/* Loading State */}
{isLoading && <ForecastSkeleton />}
{/* Error State */}
{isError && !isLoading && (
<Card className="p-5 text-center space-y-3 border border-clay-expense/30 bg-clay-expense-soft">
<p className="text-clay-expense font-bold text-sm">{t("common.error")}</p>
<p className="text-xs text-clay-text-muted">{t("forecast.fetchError")}</p>
<Button
variant="secondary"
onClick={() => {
runwayQuery.refetch();
depletionQuery.refetch();
}}
className="mx-auto px-4 text-sm"
>
{t("common.retry")}
</Button>
</Card>
)}
{/* Main Content */}
{!isLoading && !isError && runwayQuery.data?.data && (
<>
<ForecastRunwayCard data={runwayQuery.data.data} />
<div className="space-y-3 pt-2">
<h3 className="clay-title-h3 text-clay-text text-sm font-baloo">
{t("forecast.budgetDepletionTitle")}
</h3>
<BudgetDepletionList items={depletionQuery.data?.data.items || []} />
</div>
</>
)}
</div>
</Page>
);
};
export default ForecastPage;
......@@ -4,7 +4,14 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store";
import { AIAssistantIcon, IconGradients } from "@/components/ui/icons";
import {
AIAssistantIcon,
ClockIcon,
IconGradients,
LightbulbIcon,
NotificationIcon,
ReportIcon,
} from "@/components/ui/icons";
import { useI18n } from "@/i18n";
function HomePage() {
......@@ -52,14 +59,74 @@ function HomePage() {
{/* Navigation CTA */}
<div className="px-4 w-full max-w-sm mx-auto flex flex-col gap-3">
{/* Core AI & Query */}
<Button
variant="primary"
fullWidth
onClick={() => navigate("/ai-assistant")}
className="gap-2 bg-clay-primary shadow-clay-raised hover:scale-[1.02] transition-all"
className="gap-2"
>
<AIAssistantIcon size={20} />
{t("home.aiAssistant")}
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/query")}
className="gap-2 bg-clay-info-soft"
>
<LightbulbIcon size={20} />
{t("home.smartQuery")}
</Button>
{/* Upgraded Advanced Intelligence Suite */}
<div className="pt-2 pb-1">
<span className="text-[11px] font-bold text-clay-text-muted uppercase tracking-wider block px-1 mb-2">
{t("home.advancedFeatures")}
</span>
<div className="grid grid-cols-2 gap-2">
<Button
variant="secondary"
onClick={() => navigate("/forecast")}
className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"
>
<ReportIcon size={20} />
<span>{t("home.forecast")}</span>
</Button>
<Button
variant="secondary"
onClick={() => navigate("/simulations")}
className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"
>
<LightbulbIcon size={20} />
<span>{t("home.simulations")}</span>
</Button>
<Button
variant="secondary"
onClick={() => navigate("/anomalies")}
className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"
>
<NotificationIcon size={20} />
<span>{t("home.anomalies")}</span>
</Button>
<Button
variant="secondary"
onClick={() => navigate("/subscriptions")}
className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"
>
<ClockIcon size={20} />
<span>{t("home.subscriptions")}</span>
</Button>
</div>
</div>
{/* Core Financial Modules */}
<div className="pt-1 pb-1">
<span className="text-[11px] font-bold text-clay-text-muted uppercase tracking-wider block px-1 mb-2">
{t("home.coreModules")}
</span>
<div className="flex flex-col gap-2">
<Button
variant="secondary"
fullWidth
......@@ -132,6 +199,8 @@ function HomePage() {
{t("home.styleGuide")}
</Button>
</div>
</div>
</div>
</Page>
);
}
......
import React from "react";
import { useI18n } from "@/i18n";
interface QueryPromptChipsProps {
onSelectPrompt: (prompt: string) => void;
disabled?: boolean;
}
const SAMPLE_PROMPT_KEYS = ["totalExpense", "foodExpense", "largeExpense", "lastMonthIncome", "byCategory", "byWallet"] as const;
export const QueryPromptChips: React.FC<QueryPromptChipsProps> = ({ onSelectPrompt, disabled }) => {
const { t } = useI18n();
return (
<div className="space-y-2">
<span className="text-[10px] font-bold text-clay-text-muted uppercase tracking-wider block">
{t("query.suggestedPrompts")}
</span>
<div className="flex flex-wrap gap-2">
{SAMPLE_PROMPT_KEYS.map((key) => {
const prompt = t(`query.prompts.${key}`);
return (
<button
key={key}
type="button"
disabled={disabled}
onClick={() => onSelectPrompt(prompt)}
className="rounded-clay-sm border border-clay-border/50 bg-clay-surface px-3 py-1.5 text-xs font-medium text-clay-text shadow-clay-raised transition-all duration-200 ease-in-out hover:bg-clay-primary-soft hover:shadow-clay-hover active:translate-y-[2px] active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-50"
>
{prompt}
</button>
);
})}
</div>
</div>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { ExecuteQueryResult, QueryAST } from "@/types/query";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format";
interface QueryResultCardProps {
result: ExecuteQueryResult;
ast?: QueryAST;
}
export const QueryResultCard: React.FC<QueryResultCardProps> = ({ result, ast }) => {
const { t, formatCurrency, intlLocale } = useI18n();
const total = parseFloat(result.totalValue);
const avg = parseFloat(result.average);
return (
<div className="space-y-4 pt-2">
{/* Summary Header Card */}
<Card className="space-y-4 border-2 border-clay-primary/30 p-5 shadow-clay-raised">
<div className="flex items-center justify-between">
<Badge type="primary" className="text-xs">
{result.timeRangeDescription}
</Badge>
<span className="text-xs text-clay-text-muted font-medium">
{result.count} {t("query.transactionsCount")}
</span>
</div>
<div className="bg-clay-bg p-4 rounded-clay-sm border border-clay-border/40 shadow-clay-pressed space-y-1">
<span className="text-[11px] text-clay-text-muted font-medium block">{t("query.totalAmount")}</span>
<h2 className="clay-title-h2 text-clay-primary font-baloo">
{formatCurrency(total, result.currency)}
</h2>
<p className="text-xs text-clay-text-muted font-medium">
{t("query.average")}: {formatCurrency(avg, result.currency)} / {t("query.tx")}
</p>
</div>
<p className="text-xs text-clay-text leading-relaxed bg-clay-bg p-3.5 rounded-clay-sm border border-clay-border/30 shadow-clay-pressed">
{result.summary}
</p>
{/* AST Filter Tags */}
{ast && (
<div className="pt-2 border-t border-clay-border/30 flex flex-wrap gap-1.5">
<span className="text-[10px] bg-clay-surface px-2.5 py-1 rounded-full border border-clay-border/40 text-clay-text-muted font-mono shadow-clay-pressed">
{t("query.astType")}: {ast.transactionType}
</span>
<span className="text-[10px] bg-clay-surface px-2.5 py-1 rounded-full border border-clay-border/40 text-clay-text-muted font-mono shadow-clay-pressed">
{t("query.astAggregation")}: {ast.aggregation}
</span>
{ast.groupBy !== "NONE" && (
<span className="text-[10px] bg-clay-surface px-2.5 py-1 rounded-full border border-clay-border/40 text-clay-text-muted font-mono shadow-clay-pressed">
{t("query.astGroupBy")}: {ast.groupBy}
</span>
)}
</div>
)}
</Card>
{/* Groups Breakdown (if GroupBy requested) */}
{result.groups && result.groups.length > 0 && (
<Card className="space-y-3 p-4">
<h4 className="font-bold text-clay-text text-xs font-baloo">
{t("query.breakdownTitle")}
</h4>
<div className="space-y-2">
{result.groups.map((g) => (
<div
key={g.key}
className="flex items-center justify-between p-3 rounded-clay-sm bg-clay-bg border border-clay-border/40 text-xs shadow-clay-pressed"
>
<div>
<span className="font-bold text-clay-text block">{g.label}</span>
<span className="text-[10px] text-clay-text-muted">{g.count} {t("query.tx")}</span>
</div>
<span className="font-bold text-clay-primary text-sm">
{formatCurrency(parseFloat(g.total), result.currency)}
</span>
</div>
))}
</div>
</Card>
)}
{/* Items List */}
{result.items && result.items.length > 0 && (
<Card className="space-y-3 p-4">
<h4 className="font-bold text-clay-text text-xs font-baloo">
{t("query.detailedItemsTitle")} ({result.items.length})
</h4>
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
{result.items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-2.5 rounded-clay-sm bg-clay-bg border border-clay-border/30 text-xs shadow-clay-pressed"
>
<div>
<span className="font-semibold text-clay-text block">
{item.description || getCategoryDisplayName({ name: item.categoryName }, t)}
</span>
<span className="text-[10px] text-clay-text-muted">
{formatBusinessDate(item.date, intlLocale, { day: "2-digit", month: "2-digit", year: "numeric" })}{item.walletName}
</span>
</div>
<span className={`font-bold ${item.type === "EXPENSE" ? "text-clay-expense" : "text-clay-income"}`}>
{formatCurrency(parseFloat(item.amount), result.currency)}
</span>
</div>
))}
</div>
</Card>
)}
</div>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const QuerySkeleton: React.FC = () => (
<div className="space-y-4 animate-pulse pt-2" aria-hidden="true">
<Card className="p-5 space-y-4">
<div className="flex justify-between">
<div className="h-5 w-20 rounded-full bg-clay-primary/20" />
<div className="h-4 w-16 rounded-full bg-clay-text-muted/15" />
</div>
<div className="h-16 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
<div className="h-10 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</Card>
<Card className="p-4 space-y-2">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-12 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
<div className="h-12 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</Card>
</div>
);
import React, { useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Header, Page, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { useExecuteQuery, useParseQuery } from "@/hooks/use-query-engine";
import { TranslationFunction, useI18n } from "@/i18n";
import { ExecuteQueryResult, QueryAST } from "@/types/query";
import { QueryPromptChips } from "./components/QueryPromptChips";
import { QueryResultCard } from "./components/QueryResultCard";
import { QuerySkeleton } from "./components/QuerySkeleton";
const createSchema = (t: TranslationFunction) => z.object({
query: z.string().trim().min(1, t("query.validation.required")).max(300, t("query.validation.max")),
});
type QueryFormValues = z.infer<ReturnType<typeof createSchema>>;
const QueryPage: React.FC = () => {
const { t } = useI18n();
const { openSnackbar } = useSnackbar();
const parseMutation = useParseQuery();
const executeMutation = useExecuteQuery();
const schema = useMemo(() => createSchema(t), [t]);
const [currentAst, setCurrentAst] = useState<QueryAST | null>(null);
const [result, setResult] = useState<ExecuteQueryResult | null>(null);
const {
handleSubmit,
register,
setValue,
formState: { errors },
} = useForm<QueryFormValues>({
resolver: zodResolver(schema),
defaultValues: { query: "" },
});
const isLoading = parseMutation.isPending || executeMutation.isPending;
const runQuery = async (query: string) => {
setCurrentAst(null);
setResult(null);
try {
const parseResponse = await parseMutation.mutateAsync(query.trim());
const ast = parseResponse.data.ast;
setCurrentAst(ast);
const executeResponse = await executeMutation.mutateAsync({ ast });
setResult(executeResponse.data);
} catch {
openSnackbar({ type: "error", text: t("query.executeError") });
}
};
const selectPrompt = (prompt: string) => {
setValue("query", prompt, { shouldDirty: true, shouldValidate: true });
void runQuery(prompt);
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t("query.pageTitle")} showBackIcon={true} />
<div className="mx-auto max-w-md space-y-5 p-4">
<Card className="space-y-1 border border-clay-primary/30 bg-clay-surface p-4 shadow-clay-raised">
<h3 className="font-baloo text-sm font-bold text-clay-primary">{t("query.bannerTitle")}</h3>
<p className="text-xs font-medium leading-relaxed text-clay-text-muted">
{t("query.bannerDesc")}
</p>
</Card>
<Card className="p-4 shadow-clay-raised">
<form className="space-y-3" onSubmit={handleSubmit(({ query }) => runQuery(query))} noValidate>
<Input
label={t("query.inputLabel")}
placeholder={t("query.inputPlaceholder")}
maxLength={300}
disabled={isLoading}
error={errors.query?.message}
{...register("query")}
/>
<Button type="submit" variant="primary" fullWidth disabled={isLoading} className="text-sm">
{isLoading ? t("common.processing") : t("query.runBtn")}
</Button>
</form>
</Card>
<QueryPromptChips onSelectPrompt={selectPrompt} disabled={isLoading} />
{isLoading && <QuerySkeleton />}
{!isLoading && result && (
<QueryResultCard result={result} ast={currentAst || undefined} />
)}
</div>
</Page>
);
};
export default QueryPage;
import React, { useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Slider } from "@/components/ui/Slider";
import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern } from "@/lib/money-input";
import { Perturbation } from "@/types/simulation";
const createSchema = (t: TranslationFunction) => z.object({
type: z.enum(["RECURRING_INCOME", "RECURRING_EXPENSE"]),
name: z.string().trim().min(1, t("simulations.validation.nameRequired")).max(100),
amount: z.string()
.regex(positiveAmountPattern, t("simulations.validation.amountInvalid"))
.refine((value) => Number(value) > 0, t("simulations.validation.amountInvalid")),
});
type CustomPerturbationValues = z.infer<ReturnType<typeof createSchema>>;
interface SimulationControlsProps {
months: number;
onMonthsChange: (value: number) => void;
perturbations: Perturbation[];
onAddPerturbation: (perturbation: Perturbation) => void;
onRemovePerturbation: (index: number) => void;
onRun: () => void;
isLoading: boolean;
}
export const SimulationControls: React.FC<SimulationControlsProps> = ({
months,
onMonthsChange,
perturbations,
onAddPerturbation,
onRemovePerturbation,
onRun,
isLoading,
}) => {
const { t, formatNumber, intlLocale } = useI18n();
const schema = useMemo(() => createSchema(t), [t]);
const {
control,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<CustomPerturbationValues>({
resolver: zodResolver(schema),
defaultValues: { type: "RECURRING_EXPENSE", name: "", amount: "" },
});
const adjustmentType = watch("type");
const submitPerturbation = (values: CustomPerturbationValues) => {
onAddPerturbation({
type: values.type,
name: values.name.trim(),
amount: Number(values.amount).toFixed(2),
startMonth: 1,
});
reset({ type: values.type, name: "", amount: "" });
};
const formatPerturbationValue = (perturbation: Perturbation): string => {
if (perturbation.amount) {
return formatNumber(Number(perturbation.amount), { maximumFractionDigits: 2 });
}
return t("simulations.percentageChange", {
percentage: formatNumber(perturbation.percentageDelta ?? 0),
});
};
return (
<Card className="space-y-5 p-5">
<div className="space-y-2">
<div className="flex justify-between gap-3 text-xs font-medium text-clay-text">
<label htmlFor="simulation-horizon">{t("simulations.simulationHorizon")}</label>
<span className="font-bold text-clay-primary">
{t("simulations.monthCount", { months })}
</span>
</div>
<Slider
id="simulation-horizon"
value={months}
min={3}
max={36}
step={1}
decreaseLabel={t("simulations.decreaseHorizon")}
increaseLabel={t("simulations.increaseHorizon")}
onValueChange={onMonthsChange}
disabled={isLoading}
/>
</div>
<div className="space-y-2 border-t border-clay-border/40 pt-4">
<span className="block text-xs font-bold text-clay-text">
{t("simulations.activeAdjustments")} ({perturbations.length})
</span>
{perturbations.length === 0 ? (
<p className="text-[11px] italic text-clay-text-muted">{t("simulations.noAdjustments")}</p>
) : (
<div className="space-y-2">
{perturbations.map((perturbation, index) => (
<div
key={`${perturbation.type}-${perturbation.name}-${index}`}
className="flex items-center justify-between gap-3 rounded-clay-sm border border-clay-border/40 bg-clay-bg p-2.5 text-xs shadow-clay-pressed"
>
<div className="min-w-0">
<span className="block truncate font-bold text-clay-text">{perturbation.name}</span>
<span className={`text-[11px] font-semibold ${
perturbation.type.includes("INCOME") ? "text-clay-income" : "text-clay-expense"
}`}>
{formatPerturbationValue(perturbation)}
</span>
</div>
<Button
type="button"
variant="secondary"
disabled={isLoading}
onClick={() => onRemovePerturbation(index)}
className="flex-none px-3 py-1 text-xs text-clay-expense"
>
{t("common.delete")}
</Button>
</div>
))}
</div>
)}
</div>
<form
className="space-y-3 rounded-clay-sm border border-clay-border/50 bg-clay-bg p-3.5 shadow-clay-pressed"
onSubmit={handleSubmit(submitPerturbation)}
noValidate
>
<span className="block text-[11px] font-bold text-clay-text">
{t("simulations.addQuickAdjustment")}
</span>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={adjustmentType === "RECURRING_INCOME" ? "primary" : "secondary"}
onClick={() => setValue("type", "RECURRING_INCOME")}
className="px-2 py-1.5 text-[11px]"
>
{t("simulations.income")}
</Button>
<Button
type="button"
variant={adjustmentType === "RECURRING_EXPENSE" ? "primary" : "secondary"}
onClick={() => setValue("type", "RECURRING_EXPENSE")}
className="px-2 py-1.5 text-[11px]"
>
{t("simulations.expense")}
</Button>
</div>
<Controller
name="name"
control={control}
render={({ field }) => (
<Input
label={t("simulations.adjustmentName")}
placeholder={t("simulations.descPlaceholder")}
maxLength={100}
disabled={isLoading}
error={errors.name?.message}
{...field}
/>
)}
/>
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
label={t("simulations.adjustmentAmount")}
inputMode="decimal"
placeholder={t("simulations.amountPlaceholder")}
value={formatMoneyInput(field.value, intlLocale)}
disabled={isLoading}
error={errors.amount?.message}
onBlur={field.onBlur}
onChange={(event) => field.onChange(parseMoneyInput(event.target.value, intlLocale))}
/>
)}
/>
<Button type="submit" variant="secondary" fullWidth disabled={isLoading} className="py-2 text-sm">
{t("simulations.addAdjustmentBtn")}
</Button>
</form>
<Button
type="button"
variant="primary"
fullWidth
onClick={onRun}
disabled={isLoading || perturbations.length === 0}
className="py-3"
>
{isLoading ? t("common.processing") : t("simulations.runSimulationBtn")}
</Button>
</Card>
);
};
import React from "react";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { SimulationPreset } from "@/types/simulation";
import { useI18n } from "@/i18n";
interface SimulationPresetsProps {
presets: SimulationPreset[];
onSelect: (preset: SimulationPreset) => void;
}
export const SimulationPresets: React.FC<SimulationPresetsProps> = ({ presets, onSelect }) => {
const { t } = useI18n();
const translatedPresetText = (preset: SimulationPreset, field: "title" | "description") => {
const key = `simulations.presets.${preset.id}.${field}`;
const translated = t(key);
return translated === key ? preset[field] : translated;
};
return (
<div className="space-y-2.5">
<h3 className="clay-title-h3 text-clay-text text-xs font-baloo">
{t("simulations.presetTemplates")}
</h3>
{presets.length === 0 && (
<Card className="p-4 text-center text-xs text-clay-text-muted">
{t("simulations.noPresets")}
</Card>
)}
<div className="grid grid-cols-2 gap-2.5">
{presets.map((preset) => (
<Button
key={preset.id}
onClick={() => onSelect(preset)}
variant="secondary"
className="h-auto min-h-28 flex-col items-stretch justify-between p-3.5 text-left"
>
<div>
<h4 className="line-clamp-2 font-baloo text-xs font-bold text-clay-text">
{translatedPresetText(preset, "title")}
</h4>
<p className="text-[11px] text-clay-text-muted mt-1 line-clamp-2 leading-relaxed">
{translatedPresetText(preset, "description")}
</p>
</div>
<span className="text-[10px] font-bold text-clay-primary mt-2 flex items-center gap-1">
{t("simulations.applyTemplate")} &rarr;
</span>
</Button>
))}
</div>
</div>
);
};
import React from "react";
import { Badge, BadgeType } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { useI18n } from "@/i18n";
import { GoalImpactStatus, SimulationResult } from "@/types/simulation";
interface SimulationResultsProps {
result: SimulationResult;
}
const goalStatusType: Readonly<Record<GoalImpactStatus, BadgeType>> = {
ON_TRACK: "income",
ACCELERATED: "income",
DELAYED: "warning",
UNACHIEVABLE: "expense",
};
export const SimulationResults: React.FC<SimulationResultsProps> = ({ result }) => {
const { t, formatCurrency } = useI18n();
const netDifference = Number(result.summary.netDelta);
const baselineFinal = Number(result.summary.baselineEndBalance);
const simulatedFinal = Number(result.summary.simulatedEndBalance);
return (
<div className="space-y-4 pt-2">
<Card className="space-y-4 border-2 border-clay-primary/30 p-5 shadow-clay-raised">
<div className="flex items-center justify-between gap-3">
<span className="clay-caption text-[10px] font-bold uppercase tracking-wider">
{t("simulations.simulationOutcome")}
</span>
<Badge type={netDifference >= 0 ? "income" : "expense"} className="text-xs">
{formatCurrency(netDifference, result.currency)}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-clay-sm border border-clay-border/40 bg-clay-bg p-3.5 shadow-clay-pressed">
<span className="block text-[11px] font-medium text-clay-text-muted">
{t("simulations.baselineBalance")}
</span>
<p className="mt-0.5 text-sm font-bold text-clay-text">
{formatCurrency(baselineFinal, result.currency)}
</p>
</div>
<div className="rounded-clay-sm border border-clay-border/40 bg-clay-bg p-3.5 shadow-clay-pressed">
<span className="block text-[11px] font-medium text-clay-text-muted">
{t("simulations.simulatedBalance")}
</span>
<p className={`mt-0.5 text-sm font-bold ${simulatedFinal >= 0 ? "text-clay-income" : "text-clay-expense"}`}>
{formatCurrency(simulatedFinal, result.currency)}
</p>
</div>
</div>
{result.summary.isDeficitProjected && (
<div className="rounded-clay-sm border border-clay-expense/30 bg-clay-expense-soft p-3.5">
<span className="block text-xs font-bold text-clay-expense">
{t("simulations.deficitAlertTitle")}
</span>
<p className="mt-0.5 text-[11px] font-medium text-clay-expense">
{t("simulations.deficitMonth", { month: result.summary.minimumBalanceMonth })}
</p>
</div>
)}
{result.goalImpacts.length > 0 && (
<div className="space-y-2 border-t border-clay-border/40 pt-3">
<span className="block text-xs font-bold text-clay-text">
{t("simulations.goalImpactsTitle")}
</span>
<div className="space-y-1.5">
{result.goalImpacts.map((goal) => (
<div
key={goal.goalId}
className="flex items-center justify-between gap-3 rounded-clay-sm border border-clay-border/30 bg-clay-bg p-2.5 text-xs shadow-clay-pressed"
>
<span className="min-w-0 truncate font-medium text-clay-text">{goal.goalName}</span>
<Badge type={goalStatusType[goal.status]} className="flex-none text-[10px]">
{goal.status === "DELAYED" && goal.delayMonths !== null
? t("simulations.goalDelayed", { months: goal.delayMonths })
: t(`simulations.goalStatus.${goal.status}`)}
</Badge>
</div>
))}
</div>
</div>
)}
</Card>
<Card className="space-y-3 p-4">
<h4 className="font-baloo text-xs font-bold text-clay-text">
{t("simulations.monthlyTrajectoryTitle")}
</h4>
<div className="max-h-56 space-y-1.5 overflow-y-auto pr-1">
{result.monthlyComparison.map((point) => {
const netFlow = Number(point.simulatedNetFlow);
return (
<div
key={point.monthDate}
className="flex items-center justify-between rounded-clay-sm border border-clay-border/30 bg-clay-bg px-3 py-2 text-[11px] shadow-clay-pressed"
>
<span className="font-medium text-clay-text-muted">
{t("simulations.monthNumber", { month: point.monthIndex })}
</span>
<div className="text-right">
<span className="font-bold text-clay-text">
{formatCurrency(Number(point.simulatedBalance), result.currency)}
</span>
<span className={`block text-[10px] font-semibold ${netFlow >= 0 ? "text-clay-income" : "text-clay-expense"}`}>
{formatCurrency(netFlow, result.currency)}
</span>
</div>
</div>
);
})}
</div>
</Card>
</div>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const SimulationSkeleton: React.FC = () => (
<div className="space-y-4 animate-pulse" aria-hidden="true">
<div className="space-y-2">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="grid grid-cols-2 gap-2.5">
<div className="h-24 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
<div className="h-24 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</div>
</div>
<Card className="p-5 space-y-4">
<div className="h-4 w-1/4 rounded-full bg-clay-text-muted/15" />
<div className="h-10 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
<div className="h-8 rounded-full bg-clay-bg shadow-clay-pressed" />
<div className="h-11 rounded-clay-sm bg-clay-primary/20" />
</Card>
</div>
);
import React, { useState } from "react";
import { Header, Page, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { useRunSimulation, useSimulationPresets } from "@/hooks/use-simulations";
import { useI18n } from "@/i18n";
import { Perturbation, RunSimulationInput, SimulationPreset, SimulationResult } from "@/types/simulation";
import { SimulationControls } from "./components/SimulationControls";
import { SimulationPresets } from "./components/SimulationPresets";
import { SimulationResults } from "./components/SimulationResults";
import { SimulationSkeleton } from "./components/SimulationSkeleton";
const SimulationsPage: React.FC = () => {
const { t } = useI18n();
const { openSnackbar } = useSnackbar();
const presetsQuery = useSimulationPresets();
const runMutation = useRunSimulation();
const [months, setMonths] = useState(12);
const [perturbations, setPerturbations] = useState<Perturbation[]>([]);
const [simulationResult, setSimulationResult] = useState<SimulationResult | null>(null);
const handleApplyPreset = (preset: SimulationPreset) => {
const presetTitleKey = `simulations.presets.${preset.id}.title`;
const translatedTitle = t(presetTitleKey);
setPerturbations(preset.perturbations);
setSimulationResult(null);
openSnackbar({
type: "info",
text: t("simulations.templateApplied", {
name: translatedTitle === presetTitleKey ? preset.title : translatedTitle,
}),
});
};
const handleRun = async () => {
const input: RunSimulationInput = {
horizonMonths: months,
perturbations,
};
setSimulationResult(null);
try {
const response = await runMutation.mutateAsync(input);
setSimulationResult(response.data);
} catch {
openSnackbar({ type: "error", text: t("simulations.runError") });
}
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t("simulations.pageTitle")} showBackIcon={true} />
<div className="mx-auto max-w-md space-y-5 p-4">
{presetsQuery.isLoading && <SimulationSkeleton />}
{!presetsQuery.isLoading && presetsQuery.isError && (
<Card className="space-y-3 border border-clay-expense/30 bg-clay-expense-soft p-5 text-center">
<p className="text-xs font-bold text-clay-expense">{t("simulations.presetsFetchError")}</p>
<Button
variant="secondary"
className="px-4 text-sm"
onClick={() => void presetsQuery.refetch()}
>
{t("common.retry")}
</Button>
</Card>
)}
{!presetsQuery.isLoading && !presetsQuery.isError && (
<SimulationPresets
presets={presetsQuery.data?.data || []}
onSelect={handleApplyPreset}
/>
)}
{!presetsQuery.isLoading && (
<SimulationControls
months={months}
onMonthsChange={setMonths}
perturbations={perturbations}
onAddPerturbation={(perturbation) => {
setPerturbations((current) => [...current, perturbation]);
setSimulationResult(null);
}}
onRemovePerturbation={(index) => {
setPerturbations((current) => current.filter((_, itemIndex) => itemIndex !== index));
setSimulationResult(null);
}}
onRun={() => void handleRun()}
isLoading={runMutation.isPending}
/>
)}
{simulationResult && <SimulationResults result={simulationResult} />}
</div>
</Page>
);
};
export default SimulationsPage;
import React from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { DiscoveredSubscription } from "@/types/subscription";
import { useI18n } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { getCategoryDisplayName } from "@/lib/category-format";
interface SubscriptionCardProps {
item: DiscoveredSubscription;
onConvertToReminder: (item: DiscoveredSubscription) => void;
isConverting?: boolean;
}
export const SubscriptionCard: React.FC<SubscriptionCardProps> = ({
item,
onConvertToReminder,
isConverting,
}) => {
const { t, formatCurrency, formatNumber, intlLocale } = useI18n();
const latestAmount = parseFloat(item.latestAmount);
return (
<Card className="space-y-3 p-4 shadow-clay-raised">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h4 className="font-bold text-clay-text text-sm font-baloo">{item.merchantName}</h4>
<Badge type="primary" className="text-[10px] px-2 py-0.5">
{t(`subscriptions.freq_${item.frequency}`)}
</Badge>
</div>
<p className="text-xs text-clay-text-muted mt-0.5 font-medium">
{getCategoryDisplayName({ name: item.categoryName }, t)}{item.occurrenceCount} {t("subscriptions.occurrences")}
</p>
</div>
<div className="text-right">
<p className="font-bold text-clay-primary text-sm">
{formatCurrency(latestAmount, item.currency)}
</p>
<span className="text-[10px] text-clay-text-muted block font-medium">
{t("subscriptions.confidence")}: {(item.confidenceScore * 100).toFixed(0)}%
</span>
</div>
</div>
{/* Price Drift Alert */}
{item.isPriceDrift && (
<div className="bg-clay-warning-soft border border-clay-warning/40 px-3 py-1.5 rounded-clay-sm flex items-center justify-between text-xs shadow-clay-pressed">
<span className="text-clay-text font-bold">
⚠️ {t("subscriptions.priceHikeAlert")}
</span>
<span className="font-bold text-clay-warning">
+{formatNumber(item.priceDriftPercentage ?? 0, { maximumFractionDigits: 1 })}%
</span>
</div>
)}
{/* Next Expected Billing Date */}
<div className="flex items-center justify-between pt-2 border-t border-clay-border/40 text-xs">
<span className="text-clay-text-muted font-medium">
{t("subscriptions.nextBilling")}: {" "}
<b className="text-clay-text font-bold">
{formatBusinessDate(item.nextExpectedAt, intlLocale, { day: "2-digit", month: "2-digit", year: "numeric" })}
</b>
</span>
{item.isLinkedToReminder ? (
<Badge type="income" className="text-[10px]">
{t("subscriptions.trackedInReminders")}
</Badge>
) : (
<Button
variant="secondary"
onClick={() => onConvertToReminder(item)}
disabled={isConverting}
className="text-[11px] py-1 px-3"
>
{isConverting ? t("common.processing") : t("subscriptions.convertToReminderBtn")}
</Button>
)}
</div>
</Card>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
export const SubscriptionSkeleton: React.FC = () => (
<div className="space-y-3 animate-pulse" aria-hidden="true">
<Card className="p-4 space-y-3">
<div className="flex justify-between">
<div className="space-y-1.5 flex-1">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-3 w-1/2 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-5 w-20 rounded-full bg-clay-primary/20" />
</div>
<div className="h-8 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</Card>
<Card className="p-4 space-y-3">
<div className="flex justify-between">
<div className="space-y-1.5 flex-1">
<div className="h-4 w-1/3 rounded-full bg-clay-text-muted/15" />
<div className="h-3 w-1/2 rounded-full bg-clay-text-muted/10" />
</div>
<div className="h-5 w-20 rounded-full bg-clay-primary/20" />
</div>
<div className="h-8 rounded-clay-sm bg-clay-bg shadow-clay-pressed" />
</Card>
</div>
);
import React from "react";
import { Header, Page, useSnackbar } from "zmp-ui";
import { Card } from "@/components/ui/Card";
import { useConvertToReminder, useDiscoveredSubscriptions } from "@/hooks/use-subscriptions";
import { useI18n } from "@/i18n";
import { DiscoveredSubscription } from "@/types/subscription";
import { businessWallTimeToIso } from "@/lib/business-time";
import { SubscriptionCard } from "./components/SubscriptionCard";
import { SubscriptionSkeleton } from "./components/SubscriptionSkeleton";
const SubscriptionsPage: React.FC = () => {
const { t } = useI18n();
const { openSnackbar } = useSnackbar();
const discoveryQuery = useDiscoveredSubscriptions();
const convertMutation = useConvertToReminder();
const items = discoveryQuery.data?.data.items || [];
const isLoading = discoveryQuery.isLoading;
const handleConvert = async (sub: DiscoveredSubscription) => {
try {
const remindDate = businessWallTimeToIso(`${sub.nextExpectedAt}T09:00`);
await convertMutation.mutateAsync({
merchantName: sub.merchantName,
amount: sub.latestAmount,
frequency: sub.frequency,
remindAt: remindDate,
categoryId: sub.categoryId,
});
openSnackbar({
type: "success",
text: t("subscriptions.convertSuccess", { name: sub.merchantName }),
});
} catch (e) {
openSnackbar({
type: "error",
text: t("common.error"),
});
}
};
return (
<Page className="page min-h-screen pb-12 bg-clay-bg">
<Header title={t("subscriptions.pageTitle")} showBackIcon={true} />
<div className="p-4 space-y-5 max-w-md mx-auto">
{/* Banner */}
<Card className="p-4 space-y-1 bg-clay-surface border border-clay-primary/30 shadow-clay-raised">
<h3 className="font-bold text-sm text-clay-primary font-baloo">
{t("subscriptions.bannerTitle")}
</h3>
<p className="text-xs text-clay-text-muted leading-relaxed font-medium">
{t("subscriptions.bannerDesc")}
</p>
</Card>
{/* Subscriptions List */}
<div className="space-y-3">
<h3 className="clay-title-h3 text-clay-text text-sm font-baloo">
{t("subscriptions.discoveredTitle")} ({items.length})
</h3>
{isLoading && <SubscriptionSkeleton />}
{!isLoading && discoveryQuery.isError && (
<Card className="space-y-3 border border-clay-expense/30 bg-clay-expense-soft p-5 text-center">
<p className="text-xs font-bold text-clay-expense">{t("subscriptions.fetchError")}</p>
<button
type="button"
onClick={() => void discoveryQuery.refetch()}
className="rounded-clay-sm bg-clay-surface px-4 py-2 font-baloo text-sm font-bold text-clay-text shadow-clay-raised transition-all duration-200 ease-in-out active:translate-y-[2px] active:shadow-clay-pressed"
>
{t("common.retry")}
</button>
</Card>
)}
{!isLoading && !discoveryQuery.isError && items.length === 0 && (
<Card className="text-center py-6 text-clay-text-muted space-y-1 p-5">
<p className="text-sm font-bold text-clay-text">{t("subscriptions.noSubscriptionsTitle")}</p>
<p className="text-xs">{t("subscriptions.noSubscriptionsDesc")}</p>
</Card>
)}
{!isLoading && !discoveryQuery.isError && items.length > 0 && (
<div className="space-y-3">
{items.map((item) => (
<SubscriptionCard
key={`${item.merchantName}-${item.categoryId}-${item.frequency}`}
item={item}
onConvertToReminder={handleConvert}
isConverting={convertMutation.isPending && convertMutation.variables?.merchantName === item.merchantName}
/>
))}
</div>
)}
</div>
</div>
</Page>
);
};
export default SubscriptionsPage;
import { apiClient } from "@/lib/api-client";
import {
AnomalyEvaluationResult,
AnomalyResponse,
EvaluateAnomalyInput,
FlaggedAnomalyTransaction,
} from "@/types/anomaly";
function ensureSuccess<T>(response: AnomalyResponse<T>): AnomalyResponse<T> {
if (response.success === false) {
throw new Error(response.message || "Anomaly request failed");
}
return response;
}
export const anomalyService = {
async evaluate(input: EvaluateAnomalyInput): Promise<AnomalyResponse<AnomalyEvaluationResult>> {
const response = await apiClient.post<AnomalyResponse<AnomalyEvaluationResult>>(
"/anomalies/evaluate",
input,
);
return ensureSuccess(response.data);
},
async getRecent(): Promise<AnomalyResponse<FlaggedAnomalyTransaction[]>> {
const response = await apiClient.get<AnomalyResponse<FlaggedAnomalyTransaction[]>>(
"/anomalies/recent",
);
return ensureSuccess(response.data);
},
};
import { apiClient } from "@/lib/api-client";
import {
BudgetDepletionReport,
ForecastQuery,
ForecastResponse,
ForecastRunway,
} from "@/types/forecast";
function ensureSuccess<T>(response: ForecastResponse<T>): ForecastResponse<T> {
if (response.status === "error" || response.success === false) {
throw new Error(response.message || "Forecast request failed");
}
return response;
}
export const forecastService = {
async getRunway(query?: ForecastQuery): Promise<ForecastResponse<ForecastRunway>> {
const response = await apiClient.get<ForecastResponse<ForecastRunway>>("/forecast/runway", {
params: query,
});
return ensureSuccess(response.data);
},
async getBudgetDepletion(currency?: string): Promise<ForecastResponse<BudgetDepletionReport>> {
const response = await apiClient.get<ForecastResponse<BudgetDepletionReport>>(
"/forecast/budget-depletion",
{
params: currency ? { currency } : undefined,
},
);
return ensureSuccess(response.data);
},
};
import { apiClient } from "@/lib/api-client";
import {
ExecuteQueryResult,
ParseQueryResponse,
QueryAST,
QueryResponse,
} from "@/types/query";
function ensureSuccess<T>(response: QueryResponse<T>): QueryResponse<T> {
if (response.success === false) {
throw new Error(response.message || "Query request failed");
}
return response;
}
export const queryService = {
async parse(query: string): Promise<QueryResponse<ParseQueryResponse>> {
const response = await apiClient.post<QueryResponse<ParseQueryResponse>>(
"/query/parse",
{ query },
);
return ensureSuccess(response.data);
},
async execute(params: {
query?: string;
ast?: QueryAST;
}): Promise<QueryResponse<ExecuteQueryResult>> {
const response = await apiClient.post<QueryResponse<ExecuteQueryResult>>(
"/query/execute",
params,
);
return ensureSuccess(response.data);
},
};
import { apiClient } from "@/lib/api-client";
import {
RunSimulationInput,
SimulationPreset,
SimulationResponse,
SimulationResult,
} from "@/types/simulation";
function ensureSuccess<T>(response: SimulationResponse<T>): SimulationResponse<T> {
if (response.success === false) {
throw new Error(response.message || "Simulation request failed");
}
return response;
}
export const simulationService = {
async runSimulation(input: RunSimulationInput): Promise<SimulationResponse<SimulationResult>> {
const response = await apiClient.post<SimulationResponse<SimulationResult>>(
"/simulations/run",
input,
);
return ensureSuccess(response.data);
},
async getPresets(): Promise<SimulationResponse<SimulationPreset[]>> {
const response = await apiClient.get<SimulationResponse<SimulationPreset[]>>(
"/simulations/presets",
);
return ensureSuccess(response.data);
},
};
import { apiClient } from "@/lib/api-client";
import {
ConvertSubscriptionInput,
DiscoveryReport,
SubscriptionResponse,
} from "@/types/subscription";
import { Reminder } from "@/types/reminder";
function ensureSuccess<T>(response: SubscriptionResponse<T>): SubscriptionResponse<T> {
if (response.success === false) {
throw new Error(response.message || "Subscription request failed");
}
return response;
}
export const subscriptionService = {
async discover(): Promise<SubscriptionResponse<DiscoveryReport>> {
const response = await apiClient.get<SubscriptionResponse<DiscoveryReport>>(
"/subscriptions/discover",
);
return ensureSuccess(response.data);
},
async convertToReminder(input: ConvertSubscriptionInput): Promise<SubscriptionResponse<Reminder>> {
const response = await apiClient.post<SubscriptionResponse<Reminder>>(
"/subscriptions/convert-to-reminder",
input,
);
return ensureSuccess(response.data);
},
};
export type AnomalyReasonCode =
| 'SPIKE_VS_CATEGORY_MEDIAN'
| 'VELOCITY_BURST'
| 'OFF_PEAK_SURGE'
| 'HIGH_PERCENTAGE_OF_WALLET'
| 'FIRST_TIME_HIGH_VALUE';
export type AnomalySeverity = 'NORMAL' | 'ELEVATED' | 'HIGH' | 'CRITICAL';
export interface EvaluateAnomalyInput {
transactionId?: string;
walletId: string;
categoryId: string;
amount: string;
type?: 'INCOME' | 'EXPENSE';
occurredAt?: string;
}
export interface AnomalyEvaluationResult {
isAnomaly: boolean;
anomalyScore: number;
severity: AnomalySeverity;
reasonCodes: AnomalyReasonCode[];
explanation: string;
metrics: {
categoryMedian: string;
categoryMad: string;
modifiedZScore: number;
recentWalletTxnCount: number;
walletBalancePercent: number | null;
};
}
export interface FlaggedAnomalyTransaction {
transactionId: string;
amount: string;
currency: string;
categoryName: string;
walletName: string;
date: string;
anomalyScore: number;
reasonCodes: AnomalyReasonCode[];
explanation: string;
}
export interface AnomalyResponse<T> {
success: boolean;
message?: string;
data: T;
}
export type DataSufficiency = 'INSUFFICIENT' | 'SPARSE' | 'ROBUST';
export type BudgetRiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
export interface ForecastQuery {
walletId?: string;
currency?: string;
horizonDays?: number;
}
export interface ForecastSeriesPoint {
date: string;
dayIndex: number;
projectedBalance: string;
lowerBound95: string;
upperBound95: string;
}
export interface ForecastMetrics {
averageDailyIncome: string;
weightedDailyExpense: string;
netDailyBurnRate: string;
projectedEndBalance: string;
runwayDays: number | null;
depletionDate: string | null;
isDepletionProjected: boolean;
}
export interface ForecastRunway {
currency: string;
walletId: string | null;
currentBalance: string;
horizonDays: number;
dataSufficiency: DataSufficiency;
historicalDaysAnalyzed: number;
historicalTransactionCount: number;
metrics: ForecastMetrics;
series: ForecastSeriesPoint[];
}
export interface BudgetDepletionItem {
budgetId: string;
budgetName: string;
categoryName: string | null;
currency: string;
budgetAmount: string;
spentAmount: string;
remainingAmount: string;
startDate: string;
endDate: string;
totalDays: number;
daysElapsed: number;
daysRemaining: number;
currentDailyBurn: string;
recommendedDailySpend: string;
projectedTotalSpend: string;
projectedExhaustionDate: string | null;
isExhaustionProjected: boolean;
daysEarly: number | null;
riskLevel: BudgetRiskLevel;
}
export interface BudgetDepletionReport {
asOfDate: string;
currency: string | null;
items: BudgetDepletionItem[];
}
export interface ForecastResponse<T> {
success: boolean;
status?: string;
message?: string;
data: T;
}
export type QueryTimeRangeType =
| 'TODAY'
| 'THIS_WEEK'
| 'LAST_WEEK'
| 'THIS_MONTH'
| 'LAST_MONTH'
| 'THIS_YEAR'
| 'LAST_7_DAYS'
| 'LAST_30_DAYS'
| 'CUSTOM';
export type QueryAggregationType = 'SUM' | 'COUNT' | 'AVERAGE' | 'MIN' | 'MAX' | 'LIST';
export type QueryGroupByType = 'CATEGORY' | 'WALLET' | 'DAY' | 'MONTH' | 'NONE';
export interface QueryAST {
rawQuery: string;
timeRange: {
type: QueryTimeRangeType;
dateFrom?: string;
dateTo?: string;
};
transactionType: 'INCOME' | 'EXPENSE' | 'TRANSFER' | 'ALL';
categoryIds?: string[];
categoryNames?: string[];
walletIds?: string[];
walletNames?: string[];
amountFilter?: {
minAmount?: number;
maxAmount?: number;
};
aggregation: QueryAggregationType;
groupBy: QueryGroupByType;
limit?: number;
}
export interface ParseQueryResponse {
ast: QueryAST;
interpretedDescription: string;
}
export interface QueryGroupResult {
key: string;
label: string;
total: string;
count: number;
}
export interface QueryTransactionItem {
id: string;
date: string;
amount: string;
type: string;
description: string | null;
categoryName: string;
walletName: string;
}
export interface ExecuteQueryResult {
summary: string;
timeRangeDescription: string;
aggregation: QueryAggregationType;
totalValue: string;
count: number;
average: string;
minValue: string | null;
maxValue: string | null;
currency: string;
groups?: QueryGroupResult[];
items?: QueryTransactionItem[];
}
export interface QueryResponse<T> {
success: boolean;
message?: string;
data: T;
}
export type PerturbationType =
| 'RECURRING_EXPENSE'
| 'RECURRING_INCOME'
| 'ONE_OFF_EXPENSE'
| 'ONE_OFF_INCOME'
| 'CATEGORY_ADJUSTMENT';
export type GoalImpactStatus = 'ON_TRACK' | 'ACCELERATED' | 'DELAYED' | 'UNACHIEVABLE';
export type SimulationRiskAssessment = 'LOW_IMPACT' | 'MODERATE_IMPACT' | 'HIGH_DEFICIT_RISK';
export interface Perturbation {
type: PerturbationType;
name: string;
amount?: string;
percentageDelta?: number;
categoryId?: string;
startMonth?: number;
durationMonths?: number;
targetMonth?: number;
}
export interface RunSimulationInput {
currency?: string;
horizonMonths?: number;
perturbations: Perturbation[];
}
export interface MonthlyComparisonPoint {
monthIndex: number;
monthDate: string;
baselineBalance: string;
simulatedBalance: string;
monthlyDelta: string;
baselineNetFlow: string;
simulatedNetFlow: string;
}
export interface GoalImpact {
goalId: string;
goalName: string;
targetAmount: string;
currentSaved: string;
targetDate: string;
baselineEstimatedMonth: string | null;
simulatedEstimatedMonth: string | null;
delayMonths: number | null;
status: GoalImpactStatus;
}
export interface SimulationSummary {
baselineEndBalance: string;
simulatedEndBalance: string;
netDelta: string;
minimumSimulatedBalance: string;
minimumBalanceMonth: number;
isDeficitProjected: boolean;
riskAssessment: SimulationRiskAssessment;
}
export interface SimulationResult {
currency: string;
horizonMonths: number;
startingBalance: string;
summary: SimulationSummary;
monthlyComparison: MonthlyComparisonPoint[];
goalImpacts: GoalImpact[];
}
export interface SimulationPreset {
id: string;
title: string;
description: string;
perturbations: Perturbation[];
}
export interface SimulationResponse<T> {
success: boolean;
message?: string;
data: T;
}
export type SubscriptionFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
export interface DiscoveredSubscription {
merchantName: string;
categoryName: string;
categoryId: string;
currency: string;
averageAmount: string;
latestAmount: string;
frequency: SubscriptionFrequency;
occurrenceCount: number;
firstObservedAt: string;
lastObservedAt: string;
nextExpectedAt: string;
confidenceScore: number;
isPriceDrift: boolean;
priceDriftPercentage: number | null;
isLinkedToReminder: boolean;
}
export interface ConvertSubscriptionInput {
merchantName: string;
amount: string;
frequency: SubscriptionFrequency;
remindAt: string;
categoryId?: string;
}
export interface DiscoveryReport {
totalDiscovered: number;
items: DiscoveredSubscription[];
}
export interface SubscriptionResponse<T> {
success: boolean;
message?: string;
data: T;
}
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