Commit 8f1603d9 authored by ThinhNC's avatar ThinhNC

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

parent 0a8faa53
...@@ -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 ...@@ -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 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 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 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 ## Khi cập nhật file này
......
...@@ -37,6 +37,11 @@ import SavingGoalDetailPage from "@/pages/saving-goals/detail"; ...@@ -37,6 +37,11 @@ import SavingGoalDetailPage from "@/pages/saving-goals/detail";
import ReportsPage from "@/pages/reports/index"; import ReportsPage from "@/pages/reports/index";
import NotificationsPage from "@/pages/notifications/index"; import NotificationsPage from "@/pages/notifications/index";
import AIAssistantPage from "@/pages/ai-assistant/index"; 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 AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore(); const { setAuth, clearAuth, setInitialized } = useAuthStore();
...@@ -104,6 +109,11 @@ const Layout = () => { ...@@ -104,6 +109,11 @@ const Layout = () => {
<Route path="/saving-goals" element={<AuthGuard><SavingGoalsPage /></AuthGuard>}></Route> <Route path="/saving-goals" element={<AuthGuard><SavingGoalsPage /></AuthGuard>}></Route>
<Route path="/saving-goals/:id" element={<AuthGuard><SavingGoalDetailPage /></AuthGuard>}></Route> <Route path="/saving-goals/:id" element={<AuthGuard><SavingGoalDetailPage /></AuthGuard>}></Route>
<Route path="/reports" element={<AuthGuard><ReportsPage /></AuthGuard>}></Route> <Route path="/reports" element={<AuthGuard><ReportsPage /></AuthGuard>}></Route>
<Route path="/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="/notifications" element={<AuthGuard><NotificationsPage /></AuthGuard>}></Route>
<Route path="/ai-assistant" element={<AuthGuard><AIAssistantPage /></AuthGuard>}></Route> <Route path="/ai-assistant" element={<AuthGuard><AIAssistantPage /></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route> <Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
......
...@@ -24,6 +24,11 @@ const PAGE_TITLES: ReadonlyArray<{ ...@@ -24,6 +24,11 @@ const PAGE_TITLES: ReadonlyArray<{
{ matches: (pathname) => pathname === "/saving-goals", key: "document.savingGoals" }, { matches: (pathname) => pathname === "/saving-goals", key: "document.savingGoals" },
{ matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" }, { matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" },
{ matches: (pathname) => pathname === "/reports", key: "document.reports" }, { matches: (pathname) => pathname === "/reports", key: "document.reports" },
{ matches: (pathname) => pathname === "/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 === "/notifications", key: "document.notifications" },
{ matches: (pathname) => pathname === "/ai-assistant", key: "document.aiAssistant" }, { matches: (pathname) => pathname === "/ai-assistant", key: "document.aiAssistant" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" }, { 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 }),
]);
},
});
}
This diff is collapsed.
This diff is collapsed.
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"; ...@@ -4,7 +4,14 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store"; 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"; import { useI18n } from "@/i18n";
function HomePage() { function HomePage() {
...@@ -52,14 +59,74 @@ function HomePage() { ...@@ -52,14 +59,74 @@ function HomePage() {
{/* Navigation CTA */} {/* Navigation CTA */}
<div className="px-4 w-full max-w-sm mx-auto flex flex-col gap-3"> <div className="px-4 w-full max-w-sm mx-auto flex flex-col gap-3">
{/* Core AI & Query */}
<Button <Button
variant="primary" variant="primary"
fullWidth fullWidth
onClick={() => navigate("/ai-assistant")} 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")} {t("home.aiAssistant")}
</Button> </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 <Button
variant="secondary" variant="secondary"
fullWidth fullWidth
...@@ -132,6 +199,8 @@ function HomePage() { ...@@ -132,6 +199,8 @@ function HomePage() {
{t("home.styleGuide")} {t("home.styleGuide")}
</Button> </Button>
</div> </div>
</div>
</div>
</Page> </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