Initial commit: MyMidas personal finance tracker

Full-stack self-hosted finance app with FastAPI backend and React frontend.

Features:
- Accounts, transactions, budgets, investments with GBP base currency
- CSV import with auto-detection for 10 UK bank formats
- ML predictions: spending forecast, net worth projection, Monte Carlo
- 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger)
- Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF)
- AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log
- Encrypted nightly backups + key rotation script
- Mobile-responsive layout with bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

11
frontend/Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --frozen-lockfile 2>/dev/null || npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000

16
frontend/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Finance Tracker</title>
<meta name="description" content="Self-hosted personal finance tracker" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Mono:wght@400;500&family=VT323&family=Orbitron:wght@400;600;700&family=Lora:ital,wght@0,400;0,500;0,600;1,400&family=Share+Tech+Mono&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
frontend/nginx.conf Normal file
View file

@ -0,0 +1,28 @@
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
# Proxy API calls to the backend container
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# All other routes index.html (React SPA)
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
}

55
frontend/package.json Normal file
View file

@ -0,0 +1,55 @@
{
"name": "finance-tracker",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext ts,tsx"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-query-devtools": "^5.59.0",
"zustand": "^5.0.0",
"axios": "^1.7.7",
"recharts": "^2.13.0",
"plotly.js-dist-min": "^2.35.0",
"react-plotly.js": "^2.6.0",
"date-fns": "^4.1.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.4",
"lucide-react": "^0.454.0",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-tooltip": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-popover": "^1.1.2",
"react-hook-form": "^7.53.1",
"@hookform/resolvers": "^3.9.1",
"zod": "^3.23.8",
"otpauth": "^9.3.5"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-plotly.js": "^2.6.3",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"tailwindcss": "^3.4.14",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"@typescript-eslint/eslint-plugin": "^8.13.0",
"@typescript-eslint/parser": "^8.13.0",
"eslint": "^9.14.0",
"eslint-plugin-react-hooks": "^5.0.0"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

67
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,67 @@
import { useEffect } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
import { useUiStore } from "@/store/uiStore";
import AppShell from "@/components/layout/AppShell";
import LoginPage from "@/pages/auth/Login";
import TwoFactorSetupPage from "@/pages/auth/TwoFactorSetup";
import Dashboard from "@/pages/dashboard/Dashboard";
import AccountList from "@/pages/accounts/AccountList";
import AccountDetail from "@/pages/accounts/AccountDetail";
import TransactionList from "@/pages/transactions/TransactionList";
import TransactionImport from "@/pages/transactions/TransactionImport";
import BudgetPage from "@/pages/budgets/BudgetPage";
import ReportsPage from "@/pages/reports/ReportsPage";
import PortfolioPage from "@/pages/investments/PortfolioPage";
import AssetDetail from "@/pages/investments/AssetDetail";
import PredictionsPage from "@/pages/predictions/PredictionsPage";
import SettingsPage from "@/pages/settings/SettingsPage";
function PrivateRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
return token ? <>{children}</> : <Navigate to="/login" replace />;
}
export default function App() {
const theme = useUiStore((s) => s.theme);
// Apply theme class to <html> so CSS variables cascade to body and all children
useEffect(() => {
const html = document.documentElement;
html.classList.forEach(c => { if (c.startsWith("theme-")) html.classList.remove(c); });
html.classList.add(`theme-${theme}`);
}, [theme]);
return (
<div className="min-h-screen bg-background text-foreground">
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<PrivateRoute>
<AppShell>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/security/totp" element={<TwoFactorSetupPage />} />
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/:accountId" element={<AccountDetail />} />
<Route path="/transactions" element={<TransactionList />} />
<Route path="/transactions/import" element={<TransactionImport />} />
<Route path="/budgets" element={<BudgetPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/investments" element={<PortfolioPage />} />
<Route path="/investments/:assetId" element={<AssetDetail />} />
<Route path="/predictions" element={<PredictionsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</AppShell>
</PrivateRoute>
}
/>
</Routes>
</BrowserRouter>
</div>
);
}

View file

@ -0,0 +1,107 @@
import { api } from "./client";
export interface Account {
id: string;
name: string;
institution: string | null;
type: string;
currency: string;
current_balance: number;
credit_limit: number | null;
interest_rate: number | null;
is_active: boolean;
include_in_net_worth: boolean;
color: string;
icon: string | null;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface AccountCreate {
name: string;
institution?: string;
type: string;
currency?: string;
credit_limit?: number;
interest_rate?: number;
include_in_net_worth?: boolean;
color?: string;
opening_balance?: number;
notes?: string;
}
export async function getAccounts(): Promise<Account[]> {
const res = await api.get("/accounts");
return res.data;
}
export async function createAccount(data: AccountCreate): Promise<Account> {
const res = await api.post("/accounts", data);
return res.data;
}
export async function updateAccount(id: string, data: Partial<AccountCreate>): Promise<Account> {
const res = await api.put(`/accounts/${id}`, data);
return res.data;
}
export async function deleteAccount(id: string): Promise<void> {
await api.delete(`/accounts/${id}`);
}
export interface CsvMapping {
date: string;
description: string;
amount: string | null;
debit: string | null;
credit: string | null;
balance: string | null;
reference: string | null;
}
export interface ImportPreview {
detected_format: string | null;
headers: string[];
mapping: CsvMapping;
total_rows: number;
preview: {
date_raw: string;
description_raw: string;
amount_raw: number | null;
balance_raw?: string;
}[];
}
export async function previewImport(accountId: string, file: File): Promise<ImportPreview> {
const form = new FormData();
form.append("file", file);
const res = await api.post(`/accounts/${accountId}/import/preview`, form);
return res.data;
}
export async function importCsvToAccount(
accountId: string,
file: File,
mapping: CsvMapping
): Promise<{ imported: number; skipped: number }> {
const form = new FormData();
form.append("file", file);
form.append("date_col", mapping.date);
form.append("description_col", mapping.description);
form.append("amount_col", mapping.amount ?? "");
form.append("debit_col", mapping.debit ?? "");
form.append("credit_col", mapping.credit ?? "");
const res = await api.post(`/accounts/${accountId}/import`, form);
return res.data;
}
export async function getNetWorth(): Promise<{
total_assets: number;
total_liabilities: number;
net_worth: number;
base_currency: string;
}> {
const res = await api.get("/accounts/net-worth");
return res.data;
}

78
frontend/src/api/auth.ts Normal file
View file

@ -0,0 +1,78 @@
import { api } from "./client";
export interface LoginResponse {
access_token?: string;
token_type?: string;
expires_in?: number;
totp_required?: boolean;
challenge_token?: string;
}
export async function login(email: string, password: string): Promise<LoginResponse> {
const res = await api.post("/auth/login", { email, password });
return res.data;
}
export async function loginTotp(challengeToken: string, totpCode: string): Promise<LoginResponse> {
const res = await api.post("/auth/login/totp", {
challenge_token: challengeToken,
totp_code: totpCode,
});
return res.data;
}
export async function logout(): Promise<void> {
await api.post("/auth/logout");
}
export async function getMe() {
const res = await api.get("/users/me");
return res.data;
}
export async function getTotpSetup() {
const res = await api.get("/auth/totp/setup");
return res.data as { secret: string; qr_code_png_b64: string; backup_codes: string[] };
}
export async function enableTotp(secret: string, code: string): Promise<void> {
await api.post("/auth/totp/enable", { secret, code });
}
export async function getSessions() {
const res = await api.get("/auth/sessions");
return res.data;
}
export async function revokeSession(sessionId: string): Promise<void> {
await api.delete(`/auth/sessions/${sessionId}`);
}
export async function revokeAllSessions(): Promise<void> {
await api.post("/auth/logout-all");
}
export async function disableTotp(password: string): Promise<void> {
await api.delete("/auth/totp", { data: { password } });
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
await api.post("/users/me/password", {
current_password: currentPassword,
new_password: newPassword,
});
}
export async function updateProfile(data: { display_name?: string; base_currency?: string }): Promise<void> {
await api.put("/users/me", data);
}
export async function exportData(): Promise<void> {
const res = await api.get("/users/me/export", { responseType: "blob" });
const url = URL.createObjectURL(res.data);
const a = document.createElement("a");
a.href = url;
a.download = `transactions_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,65 @@
import { api } from "./client";
export interface Budget {
id: string;
category_id: string;
name: string;
amount: number;
currency: string;
period: string;
start_date: string;
end_date: string | null;
rollover: boolean;
alert_threshold: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface BudgetSummaryItem {
budget_id: string;
budget_name: string;
category_id: string;
category_name: string;
period: string;
budget_amount: number;
spent_amount: number;
remaining_amount: number;
percent_used: number;
is_over_budget: boolean;
alert_triggered: boolean;
currency: string;
period_start: string;
period_end: string;
}
export interface BudgetCreate {
category_id: string;
name: string;
amount: number;
currency?: string;
period: "weekly" | "monthly" | "quarterly" | "yearly";
start_date: string;
end_date?: string | null;
rollover?: boolean;
alert_threshold?: number;
}
export async function getBudgets(activeOnly = true): Promise<Budget[]> {
const r = await api.get("/api/v1/budgets", { params: { active_only: activeOnly } });
return r.data;
}
export async function getBudgetSummary(): Promise<BudgetSummaryItem[]> {
const r = await api.get("/api/v1/budgets/summary");
return r.data;
}
export async function createBudget(data: BudgetCreate): Promise<Budget> {
const r = await api.post("/api/v1/budgets", data);
return r.data;
}
export async function deleteBudget(id: string): Promise<void> {
await api.delete(`/api/v1/budgets/${id}`);
}

View file

@ -0,0 +1,56 @@
import axios from "axios";
import { useAuthStore } from "@/store/authStore";
export const api = axios.create({
baseURL: "/api/v1",
withCredentials: true,
});
// Attach bearer token + CSRF header to every request
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// CSRF double-submit: read cookie and send as header
const csrfCookie = document.cookie
.split("; ")
.find((row) => row.startsWith("csrf_token="))
?.split("=")[1];
if (csrfCookie && config.method !== "get") {
config.headers["X-CSRF-Token"] = csrfCookie;
}
return config;
});
// Auto-refresh on 401
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
try {
const res = await axios.post(
"/api/v1/auth/refresh",
{},
{ withCredentials: true }
);
const { access_token } = res.data;
// Update store - we need to get current user info from the existing token
const store = useAuthStore.getState();
if (store.userId && store.displayName) {
store.setToken(access_token, store.userId, store.displayName);
}
original.headers.Authorization = `Bearer ${access_token}`;
return api(original);
} catch {
useAuthStore.getState().clearAuth();
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);

View file

@ -0,0 +1,111 @@
import { api } from "./client";
export interface AssetSearchResult {
id: string;
symbol: string;
name: string;
type: string;
currency: string;
exchange: string | null;
last_price: number | null;
price_change_24h: number | null;
data_source: string;
}
export interface HoldingResponse {
id: string;
account_id: string;
asset_id: string;
symbol: string;
asset_name: string;
asset_type: string;
quantity: number;
avg_cost_basis: number;
current_price: number | null;
current_value: number | null;
cost_basis_total: number;
unrealised_gain: number | null;
unrealised_gain_pct: number | null;
currency: string;
price_change_24h: number | null;
}
export interface PortfolioSummary {
total_value: number;
total_cost: number;
total_gain: number;
total_gain_pct: number;
currency: string;
holdings: HoldingResponse[];
}
export interface InvestmentTxn {
id: string;
holding_id: string;
type: string;
quantity: number;
price: number;
fees: number;
total_amount: number;
currency: string;
date: string;
created_at: string;
}
export interface PricePoint {
date: string;
open: number | null;
high: number | null;
low: number | null;
close: number;
volume: number | null;
}
export async function getPortfolio(): Promise<PortfolioSummary> {
const r = await api.get("/api/v1/investments/portfolio");
return r.data;
}
export async function searchAssets(q: string): Promise<AssetSearchResult[]> {
const r = await api.get("/api/v1/assets/search", { params: { q } });
return r.data;
}
export async function getPriceHistory(assetId: string, days = 365): Promise<PricePoint[]> {
const r = await api.get(`/api/v1/assets/${assetId}/prices`, { params: { days } });
return r.data;
}
export async function createHolding(data: {
account_id: string;
asset_id: string;
quantity: number;
avg_cost_basis: number;
currency?: string;
}): Promise<HoldingResponse> {
const r = await api.post("/api/v1/investments/holdings", data);
return r.data;
}
export async function deleteHolding(id: string): Promise<void> {
await api.delete(`/api/v1/investments/holdings/${id}`);
}
export async function addInvestmentTransaction(data: {
holding_id: string;
type: string;
quantity: number;
price: number;
fees?: number;
currency?: string;
date: string;
notes?: string;
}): Promise<InvestmentTxn> {
const r = await api.post("/api/v1/investments/transactions", data);
return r.data;
}
export async function getHoldingTransactions(holdingId: string): Promise<InvestmentTxn[]> {
const r = await api.get(`/api/v1/investments/holdings/${holdingId}/transactions`);
return r.data;
}

View file

@ -0,0 +1,109 @@
import { api } from "./client";
export interface CategoryForecast {
category_id: string;
category_name: string;
monthly_avg: number;
actuals: { date: string; amount: number }[];
forecast: { date: string; amount: number; lower: number; upper: number }[];
}
export interface SpendingForecastResponse {
categories: CategoryForecast[];
}
export interface NetWorthProjectionResponse {
history: { date: string; value: number }[];
projections: {
conservative: { date: string; value: number }[];
base: { date: string; value: number }[];
optimistic: { date: string; value: number }[];
};
insufficient_data: boolean;
}
export interface PercentilePath {
date: string;
value: number;
}
export interface MonteCarloResponse {
dates: string[];
percentiles: {
p10: PercentilePath[];
p25: PercentilePath[];
p50: PercentilePath[];
p75: PercentilePath[];
p90: PercentilePath[];
};
current_value: number;
expected_value: number;
probability_of_gain: number;
insufficient_data: boolean;
}
export interface BudgetForecastItem {
category_id: string;
category_name: string;
budget_amount: number;
spent_so_far: number;
forecast_month_total: number;
daily_velocity: number;
probability_overspend: number;
days_remaining: number;
}
export interface BudgetForecastResponse {
forecasts: BudgetForecastItem[];
message?: string;
}
export interface CashFlowDay {
date: string;
balance: number;
avg_inflow: number;
avg_outflow: number;
negative_risk: boolean;
}
export interface CashFlowResponse {
current_balance: number;
avg_daily_inflow: number;
avg_daily_outflow: number;
forecast: CashFlowDay[];
negative_risk_days: string[];
history_days: number;
}
export async function getSpendingForecast(): Promise<SpendingForecastResponse> {
const res = await api.get("/predictions/spending");
return res.data;
}
export async function getNetWorthProjection(years = 5): Promise<NetWorthProjectionResponse> {
const res = await api.get("/predictions/net-worth", { params: { years } });
return res.data;
}
export async function postMonteCarlo(params: {
years?: number;
n_simulations?: number;
annual_contribution?: number;
}): Promise<MonteCarloResponse> {
const res = await api.post("/predictions/monte-carlo", {
years: params.years ?? 5,
n_simulations: params.n_simulations ?? 1000,
annual_contribution: params.annual_contribution ?? 0,
});
return res.data;
}
export async function getBudgetForecast(): Promise<BudgetForecastResponse> {
const res = await api.get("/predictions/budget-forecast");
return res.data;
}
export async function getCashFlowForecast(): Promise<CashFlowResponse> {
const res = await api.get("/predictions/cashflow");
return res.data;
}

123
frontend/src/api/reports.ts Normal file
View file

@ -0,0 +1,123 @@
import { api } from "./client";
export interface NetWorthPoint {
date: string;
total_assets: number;
total_liabilities: number;
net_worth: number;
base_currency: string;
}
export interface NetWorthReport {
points: NetWorthPoint[];
current_net_worth: number;
change_30d: number;
change_30d_pct: number;
base_currency: string;
}
export interface IncomeExpensePoint {
month: string;
income: number;
expenses: number;
net: number;
}
export interface IncomeExpenseReport {
points: IncomeExpensePoint[];
total_income: number;
total_expenses: number;
avg_monthly_income: number;
avg_monthly_expenses: number;
currency: string;
}
export interface CashFlowPoint {
date: string;
inflow: number;
outflow: number;
net: number;
running_balance: number;
}
export interface CashFlowReport {
points: CashFlowPoint[];
total_inflow: number;
total_outflow: number;
currency: string;
}
export interface CategoryBreakdownItem {
category_id: string | null;
category_name: string;
amount: number;
percent: number;
transaction_count: number;
}
export interface CategoryBreakdownReport {
items: CategoryBreakdownItem[];
total: number;
currency: string;
date_from: string;
date_to: string;
}
export interface BudgetVsActualReport {
items: Array<{
budget_id: string;
budget_name: string;
category_name: string;
budgeted: number;
actual: number;
variance: number;
percent_used: number;
}>;
total_budgeted: number;
total_actual: number;
currency: string;
}
export interface SpendingTrendsReport {
points: Array<{ month: string; category_name: string; amount: number }>;
categories: string[];
currency: string;
}
export async function getNetWorthReport(months = 12): Promise<NetWorthReport> {
const r = await api.get("/api/v1/reports/net-worth", { params: { months } });
return r.data;
}
export async function getIncomeExpenseReport(months = 12): Promise<IncomeExpenseReport> {
const r = await api.get("/api/v1/reports/income-vs-expense", { params: { months } });
return r.data;
}
export async function getCashFlowReport(dateFrom?: string, dateTo?: string): Promise<CashFlowReport> {
const r = await api.get("/api/v1/reports/cash-flow", {
params: { date_from: dateFrom, date_to: dateTo },
});
return r.data;
}
export async function getCategoryBreakdown(
dateFrom?: string,
dateTo?: string,
type = "expense"
): Promise<CategoryBreakdownReport> {
const r = await api.get("/api/v1/reports/category-breakdown", {
params: { date_from: dateFrom, date_to: dateTo, type },
});
return r.data;
}
export async function getBudgetVsActual(): Promise<BudgetVsActualReport> {
const r = await api.get("/api/v1/reports/budget-vs-actual");
return r.data;
}
export async function getSpendingTrends(months = 6): Promise<SpendingTrendsReport> {
const r = await api.get("/api/v1/reports/spending-trends", { params: { months } });
return r.data;
}

View file

@ -0,0 +1,128 @@
import { api } from "./client";
export interface AttachmentRef {
id: string;
filename: string;
mime_type: string;
size: number;
stored_name: string;
}
export interface Transaction {
id: string;
account_id: string;
transfer_account_id: string | null;
category_id: string | null;
type: "income" | "expense" | "transfer" | "investment";
status: "pending" | "cleared" | "reconciled" | "void";
amount: number;
amount_base: number | null;
currency: string;
base_currency: string;
date: string;
description: string;
merchant: string | null;
notes: string | null;
tags: string[];
is_recurring: boolean;
attachment_refs: AttachmentRef[];
created_at: string;
updated_at: string;
}
export interface TransactionCreate {
account_id: string;
transfer_account_id?: string;
category_id?: string;
type: Transaction["type"];
status?: Transaction["status"];
amount: number;
currency?: string;
date: string;
description: string;
merchant?: string;
notes?: string;
tags?: string[];
}
export interface TransactionPage {
items: Transaction[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface TransactionFilters {
account_id?: string;
category_id?: string;
type?: string;
status?: string;
date_from?: string;
date_to?: string;
search?: string;
page?: number;
page_size?: number;
}
export async function getTransactions(filters: TransactionFilters = {}): Promise<TransactionPage> {
const params = Object.fromEntries(
Object.entries(filters).filter(([, v]) => v !== undefined && v !== "")
);
const res = await api.get("/transactions", { params });
return res.data;
}
export async function createTransaction(data: TransactionCreate): Promise<Transaction> {
const res = await api.post("/transactions", data);
return res.data;
}
export async function updateTransaction(id: string, data: Partial<TransactionCreate>): Promise<Transaction> {
const res = await api.put(`/transactions/${id}`, data);
return res.data;
}
export async function deleteTransaction(id: string): Promise<void> {
await api.delete(`/transactions/${id}`);
}
export async function getCategories(): Promise<{ id: string; name: string; type: string; icon: string | null; color: string | null }[]> {
const res = await api.get("/categories");
return res.data;
}
export async function importCsv(
file: File,
accountId: string,
colMap: { date: string; description: string; amount: string }
): Promise<{ imported: number; skipped: number }> {
const form = new FormData();
form.append("file", file);
form.append("account_id", accountId);
form.append("date_col", colMap.date);
form.append("description_col", colMap.description);
form.append("amount_col", colMap.amount);
const res = await api.post("/transactions/import", form, {
headers: { "Content-Type": "multipart/form-data" },
});
return res.data;
}
export async function uploadAttachment(txnId: string, file: File): Promise<AttachmentRef> {
const form = new FormData();
form.append("file", file);
const res = await api.post(`/transactions/${txnId}/attachments`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
return res.data;
}
export function getAttachmentUrl(txnId: string, attachmentId: string): string {
const base = (api.defaults.baseURL ?? "").replace(/\/$/, "");
return `${base}/transactions/${txnId}/attachments/${attachmentId}`;
}
export async function deleteAttachment(txnId: string, attachmentId: string): Promise<void> {
await api.delete(`/transactions/${txnId}/attachments/${attachmentId}`);
}

View file

@ -0,0 +1,36 @@
import { useUiStore } from "@/store/uiStore";
import Sidebar from "./Sidebar";
import TopBar from "./TopBar";
import MobileNav from "./MobileNav";
interface AppShellProps {
children: React.ReactNode;
}
export default function AppShell({ children }: AppShellProps) {
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Sidebar — desktop only */}
<div className="hidden lg:block">
<Sidebar />
</div>
<div
className={`flex flex-col flex-1 min-w-0 transition-all duration-200 ${
sidebarOpen ? "lg:ml-64" : "lg:ml-16"
}`}
>
<TopBar />
{/* Extra bottom padding on mobile so content clears the nav bar */}
<main className="flex-1 overflow-y-auto p-4 md:p-6 lg:p-8 pb-24 lg:pb-8">
{children}
</main>
</div>
{/* Bottom nav — mobile only */}
<MobileNav />
</div>
);
}

View file

@ -0,0 +1,44 @@
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/utils/cn";
import {
LayoutDashboard, CreditCard, ArrowLeftRight,
PiggyBank, TrendingUp, BarChart3, Sparkles, Settings,
} from "lucide-react";
const NAV = [
{ href: "/", icon: LayoutDashboard, label: "Home" },
{ href: "/accounts", icon: CreditCard, label: "Accounts" },
{ href: "/transactions",icon: ArrowLeftRight, label: "Txns" },
{ href: "/budgets", icon: PiggyBank, label: "Budgets" },
{ href: "/investments", icon: TrendingUp, label: "Invest" },
{ href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/predictions", icon: Sparkles, label: "Predict" },
{ href: "/settings", icon: Settings, label: "Settings" },
];
export default function MobileNav() {
const location = useLocation();
return (
<nav className="fixed bottom-0 left-0 right-0 z-40 lg:hidden bg-card border-t border-border">
<div className="flex overflow-x-auto scrollbar-none">
{NAV.map(({ href, icon: Icon, label }) => {
const active = location.pathname === href || (href !== "/" && location.pathname.startsWith(href));
return (
<Link
key={href}
to={href}
className={cn(
"flex flex-col items-center justify-center gap-0.5 px-3 py-2.5 min-w-[4rem] flex-1 transition-colors",
active ? "text-primary" : "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-5 h-5 shrink-0" />
<span className="text-[10px] font-medium leading-none">{label}</span>
</Link>
);
})}
</div>
</nav>
);
}

View file

@ -0,0 +1,84 @@
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/utils/cn";
import { useUiStore } from "@/store/uiStore";
import {
LayoutDashboard,
CreditCard,
ArrowLeftRight,
PiggyBank,
TrendingUp,
BarChart3,
Sparkles,
Settings,
ChevronLeft,
ChevronRight,
DollarSign,
} from "lucide-react";
const navItems = [
{ href: "/", icon: LayoutDashboard, label: "Dashboard" },
{ href: "/accounts", icon: CreditCard, label: "Accounts" },
{ href: "/transactions", icon: ArrowLeftRight, label: "Transactions" },
{ href: "/budgets", icon: PiggyBank, label: "Budgets" },
{ href: "/investments", icon: TrendingUp, label: "Investments" },
{ href: "/reports", icon: BarChart3, label: "Reports" },
{ href: "/predictions", icon: Sparkles, label: "Predictions" },
{ href: "/settings", icon: Settings, label: "Settings" },
];
export default function Sidebar() {
const location = useLocation();
const { sidebarOpen, setSidebarOpen } = useUiStore();
return (
<aside
className={cn(
"fixed left-0 top-0 h-full z-30 bg-card border-r border-border transition-all duration-200 flex flex-col",
sidebarOpen ? "w-64" : "w-16"
)}
>
{/* Logo */}
<div className="flex items-center h-16 px-4 border-b border-border shrink-0">
<DollarSign className="w-7 h-7 text-primary shrink-0" />
{sidebarOpen && (
<span className="ml-2 font-semibold text-lg truncate">Finance</span>
)}
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-4 space-y-1 px-2">
{navItems.map(({ href, icon: Icon, label }) => {
const active = location.pathname === href || (href !== "/" && location.pathname.startsWith(href));
return (
<Link
key={href}
to={href}
className={cn(
"flex items-center gap-3 px-2 py-2 rounded-md text-sm font-medium transition-colors",
active
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
)}
title={!sidebarOpen ? label : undefined}
>
<Icon className="w-5 h-5 shrink-0" />
{sidebarOpen && <span className="truncate">{label}</span>}
</Link>
);
})}
</nav>
{/* Toggle */}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center justify-center h-10 w-full border-t border-border text-muted-foreground hover:text-foreground transition-colors"
>
{sidebarOpen ? (
<ChevronLeft className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
</aside>
);
}

View file

@ -0,0 +1,157 @@
import { useState, useRef, useEffect } from "react";
import { useUiStore, type Theme } from "@/store/uiStore";
import { Palette } from "lucide-react";
import { cn } from "@/utils/cn";
const THEMES: {
id: Theme;
name: string;
description: string;
swatches: string[];
dark: boolean;
}[] = [
{
id: "obsidian",
name: "Obsidian",
description: "Deep navy · Indigo",
swatches: ["#111827", "#1e2a3b", "#6366f1"],
dark: true,
},
{
id: "arctic",
name: "Arctic",
description: "Clean white · Crisp",
swatches: ["#f8fafc", "#ffffff", "#6d28d9"],
dark: false,
},
{
id: "midnight",
name: "Midnight",
description: "True black · OLED",
swatches: ["#0a0a0a", "#111111", "#7c3aed"],
dark: true,
},
{
id: "vault",
name: "Vault",
description: "Warm dark · Gold",
swatches: ["#100c08", "#16120e", "#d97706"],
dark: true,
},
{
id: "terminal",
name: "Terminal",
description: "CRT green · Phosphor",
swatches: ["#040c04", "#071007", "#00ff41"],
dark: true,
},
{
id: "synthwave",
name: "Synthwave",
description: "80s neon · Purple",
swatches: ["#0d0221", "#130330", "#ff2d78"],
dark: true,
},
{
id: "ledger",
name: "Ledger",
description: "Aged paper · Serif",
swatches: ["#f0ebe0", "#f7f4ee", "#8b1a1a"],
dark: false,
},
];
export default function ThemePicker() {
const { theme, setTheme } = useUiStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const current = THEMES.find(t => t.id === theme) ?? THEMES[0];
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(o => !o)}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border border-border",
open ? "bg-secondary text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
title="Change theme"
>
{/* Mini swatch preview */}
<span className="flex gap-0.5 items-center">
{current.swatches.map((c, i) => (
<span
key={i}
className="inline-block rounded-full"
style={{
width: i === 2 ? 8 : 6,
height: i === 2 ? 8 : 6,
backgroundColor: c,
outline: "1px solid rgba(255,255,255,0.15)",
}}
/>
))}
</span>
<Palette className="w-4 h-4" />
<span className="hidden sm:inline">{current.name}</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-2 z-50 bg-card border border-border rounded-xl shadow-2xl p-3 w-72">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1 mb-2">
Choose Theme
</p>
<div className="space-y-1">
{THEMES.map(t => (
<button
key={t.id}
onClick={() => { setTheme(t.id); setOpen(false); }}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left",
theme === t.id
? "bg-primary/15 text-foreground"
: "hover:bg-secondary text-muted-foreground hover:text-foreground"
)}
>
{/* Colour preview card */}
<div
className="w-10 h-7 rounded-md flex-shrink-0 flex items-end overflow-hidden"
style={{ backgroundColor: t.swatches[0], border: "1px solid rgba(255,255,255,0.1)" }}
>
<div
className="w-full h-3"
style={{ backgroundColor: t.swatches[1] }}
/>
<div
className="absolute w-2.5 h-2.5 rounded-full m-0.5"
style={{ backgroundColor: t.swatches[2], boxShadow: `0 0 4px ${t.swatches[2]}` }}
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold leading-none">{t.name}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t.description}</p>
</div>
{theme === t.id && (
<span className="w-2 h-2 rounded-full bg-primary shrink-0" />
)}
</button>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,38 @@
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
import { logout } from "@/api/auth";
import { LogOut, User } from "lucide-react";
import ThemePicker from "./ThemePicker";
export default function TopBar() {
const { displayName, clearAuth } = useAuthStore();
const navigate = useNavigate();
async function handleLogout() {
try {
await logout();
} finally {
clearAuth();
navigate("/login");
}
}
return (
<header className="h-16 border-b border-border bg-card flex items-center justify-end px-4 md:px-6 gap-3 shrink-0">
<ThemePicker />
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary text-sm border border-border">
<User className="w-4 h-4 text-muted-foreground" />
<span className="text-foreground font-medium">{displayName ?? "User"}</span>
</div>
<button
onClick={handleLogout}
className="p-2 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors border border-transparent hover:border-destructive/20"
title="Logout"
>
<LogOut className="w-4 h-4" />
</button>
</header>
);
}

348
frontend/src/index.css Normal file
View file

@ -0,0 +1,348 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ─── Fallback variables (obsidian) — prevents flash of unstyled text ───────── */
:root {
--background: 220 28% 9%;
--foreground: 214 32% 92%;
--card: 220 28% 12%;
--card-foreground: 214 32% 92%;
--primary: 252 87% 67%;
--primary-foreground:0 0% 100%;
--secondary: 220 28% 17%;
--secondary-foreground: 214 32% 92%;
--muted: 220 28% 17%;
--muted-foreground: 215 16% 56%;
--accent: 252 87% 67%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 28% 20%;
--input: 220 28% 20%;
--ring: 252 87% 67%;
--radius: 0.6rem;
--success: 142 71% 45%;
}
/* ─── Base font ─────────────────────────────────────────────────────────────── */
@layer base {
body {
font-family: 'DM Sans', system-ui, sans-serif;
font-feature-settings: "rlig" 1, "calt" 1;
}
.tabular-nums {
font-family: 'DM Mono', 'DM Sans', monospace;
font-variant-numeric: tabular-nums;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: hsl(var(--muted)); }
::-webkit-scrollbar-thumb { background: hsl(var(--muted-foreground) / 0.3); border-radius: 9999px; }
::-webkit-scrollbar-thumb:hover { background: hsl(var(--muted-foreground) / 0.5); }
* { @apply border-border; }
html, body { @apply bg-background text-foreground antialiased; }
}
/*
THEME 1 OBSIDIAN (default dark)
Deep navy, indigo accent, refined depth
*/
.theme-obsidian {
--background: 220 28% 9%;
--foreground: 214 32% 92%;
--card: 220 28% 12%;
--card-foreground: 214 32% 92%;
--primary: 252 87% 67%;
--primary-foreground:0 0% 100%;
--secondary: 220 28% 17%;
--secondary-foreground: 214 32% 92%;
--muted: 220 28% 17%;
--muted-foreground: 215 16% 56%;
--accent: 252 87% 67%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 28% 20%;
--input: 220 28% 20%;
--ring: 252 87% 67%;
--radius: 0.6rem;
--success: 142 71% 45%;
}
/*
THEME 2 ARCTIC (clean light)
Pure white, deep slate, bank-grade crispness
*/
.theme-arctic {
--background: 210 20% 98%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--primary: 252 87% 55%;
--primary-foreground:0 0% 100%;
--secondary: 210 20% 94%;
--secondary-foreground: 222 47% 20%;
--muted: 210 20% 94%;
--muted-foreground: 215 16% 46%;
--accent: 252 87% 55%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 55%;
--destructive-foreground: 0 0% 100%;
--border: 220 13% 88%;
--input: 220 13% 88%;
--ring: 252 87% 55%;
--radius: 0.6rem;
--success: 142 71% 40%;
}
/*
THEME 3 MIDNIGHT (OLED black)
True black, charcoal cards, maximum contrast, accent only on data
*/
.theme-midnight {
--background: 0 0% 4%;
--foreground: 0 0% 93%;
--card: 0 0% 7%;
--card-foreground: 0 0% 93%;
--primary: 252 87% 70%;
--primary-foreground:0 0% 100%;
--secondary: 0 0% 11%;
--secondary-foreground: 0 0% 93%;
--muted: 0 0% 11%;
--muted-foreground: 0 0% 48%;
--accent: 252 87% 70%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 14%;
--input: 0 0% 14%;
--ring: 252 87% 70%;
--radius: 0.5rem;
--success: 142 71% 45%;
}
/*
THEME 4 VAULT (dark luxury)
Warm charcoal, gold accent private banking, wealth management
*/
.theme-vault {
--background: 30 18% 6%;
--foreground: 38 20% 88%;
--card: 30 18% 9%;
--card-foreground: 38 20% 88%;
--primary: 38 92% 50%;
--primary-foreground:30 18% 6%;
--secondary: 30 18% 14%;
--secondary-foreground: 38 20% 88%;
--muted: 30 18% 14%;
--muted-foreground: 38 12% 52%;
--accent: 38 92% 50%;
--accent-foreground: 30 18% 6%;
--destructive: 0 72% 55%;
--destructive-foreground: 0 0% 100%;
--border: 30 18% 18%;
--input: 30 18% 18%;
--ring: 38 92% 50%;
--radius: 0.4rem;
--success: 142 55% 42%;
}
/*
THEME 5 TERMINAL (green phosphor CRT)
Near-black, phosphor green, monospace everywhere, scanlines
*/
.theme-terminal {
--background: 120 60% 3%;
--foreground: 120 100% 72%;
--card: 120 60% 5%;
--card-foreground: 120 100% 72%;
--primary: 120 100% 50%;
--primary-foreground:120 60% 3%;
--secondary: 120 60% 9%;
--secondary-foreground: 120 100% 72%;
--muted: 120 60% 9%;
--muted-foreground: 120 50% 38%;
--accent: 120 100% 50%;
--accent-foreground: 120 60% 3%;
--destructive: 0 84% 55%;
--destructive-foreground: 0 0% 100%;
--border: 120 60% 14%;
--input: 120 60% 9%;
--ring: 120 100% 50%;
--radius: 0.2rem;
--success: 120 100% 50%;
}
.theme-terminal body,
.theme-terminal * {
font-family: 'Share Tech Mono', 'VT323', monospace !important;
letter-spacing: 0.02em;
}
.theme-terminal h1,
.theme-terminal h2,
.theme-terminal h3 {
font-family: 'VT323', monospace !important;
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* CRT scanline overlay */
.theme-terminal::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.08) 2px,
rgba(0, 0, 0, 0.08) 4px
);
pointer-events: none;
z-index: 9999;
}
/* Phosphor glow on text */
.theme-terminal .text-foreground,
.theme-terminal p,
.theme-terminal span:not(.text-muted-foreground):not(.text-destructive) {
text-shadow: 0 0 8px hsl(120 100% 50% / 0.4);
}
/* Glow on cards */
.theme-terminal .bg-card {
box-shadow: 0 0 0 1px hsl(120 100% 50% / 0.15), inset 0 0 20px hsl(120 100% 50% / 0.03);
}
/*
THEME 6 SYNTHWAVE (80s neon)
Deep purple, hot pink & cyan, neon glow effects
*/
.theme-synthwave {
--background: 268 90% 6%;
--foreground: 280 30% 92%;
--card: 268 90% 9%;
--card-foreground: 280 30% 92%;
--primary: 330 100% 62%;
--primary-foreground:0 0% 100%;
--secondary: 268 90% 14%;
--secondary-foreground: 280 30% 92%;
--muted: 268 90% 14%;
--muted-foreground: 270 20% 56%;
--accent: 190 100% 55%;
--accent-foreground: 268 90% 6%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 268 90% 20%;
--input: 268 90% 14%;
--ring: 330 100% 62%;
--radius: 0.5rem;
--success: 165 100% 45%;
}
.theme-synthwave h1,
.theme-synthwave h2 {
font-family: 'Orbitron', sans-serif !important;
letter-spacing: 0.05em;
}
/* Neon glow on primary elements */
.theme-synthwave .bg-primary {
box-shadow: 0 0 20px hsl(330 100% 62% / 0.5), 0 0 40px hsl(330 100% 62% / 0.2);
}
.theme-synthwave .text-primary {
text-shadow: 0 0 10px hsl(330 100% 62% / 0.6);
}
.theme-synthwave .text-success {
color: hsl(165 100% 45%) !important;
text-shadow: 0 0 8px hsl(165 100% 45% / 0.5);
}
.theme-synthwave .bg-card {
border-top: 1px solid hsl(330 100% 62% / 0.2);
box-shadow: 0 4px 24px hsl(268 90% 4% / 0.8), 0 0 0 1px hsl(268 90% 20%);
}
/* Horizontal grid lines (retro grid floor effect on backgrounds) */
.theme-synthwave .bg-background {
background-image:
linear-gradient(hsl(268 90% 6%), hsl(268 90% 6%)),
repeating-linear-gradient(
0deg,
transparent,
transparent 40px,
hsl(330 100% 62% / 0.04) 40px,
hsl(330 100% 62% / 0.04) 41px
);
}
/*
THEME 7 LEDGER (vintage paper accounting book)
Cream/manila, dark ink, serif typography, aged paper feel
*/
.theme-ledger {
--background: 40 35% 93%;
--foreground: 30 25% 15%;
--card: 40 35% 97%;
--card-foreground: 30 25% 15%;
--primary: 0 65% 38%;
--primary-foreground:0 0% 100%;
--secondary: 40 30% 87%;
--secondary-foreground: 30 25% 25%;
--muted: 40 30% 87%;
--muted-foreground: 30 15% 46%;
--accent: 0 65% 38%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 40%;
--destructive-foreground: 0 0% 100%;
--border: 40 25% 78%;
--input: 40 25% 85%;
--ring: 0 65% 38%;
--radius: 0.2rem;
--success: 142 50% 32%;
}
.theme-ledger body,
.theme-ledger * {
font-family: 'Lora', Georgia, serif !important;
}
.theme-ledger h1,
.theme-ledger h2,
.theme-ledger h3 {
font-family: 'Lora', Georgia, serif !important;
font-weight: 600;
letter-spacing: -0.01em;
}
/* Tabular numbers in DM Mono even in ledger */
.theme-ledger .tabular-nums {
font-family: 'DM Mono', monospace !important;
}
/* Paper texture via subtle noise */
.theme-ledger .bg-card {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
}
/* Ruled lines on card-like elements — classic ledger lines */
.theme-ledger .bg-secondary {
background-image: repeating-linear-gradient(
transparent,
transparent 27px,
hsl(40 25% 72% / 0.6) 27px,
hsl(40 25% 72% / 0.6) 28px
);
}
/* Red ink for primary, green for success (classic bookkeeping colors) */
.theme-ledger .text-success {
color: hsl(142 50% 32%) !important;
}

22
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,22 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);

View file

@ -0,0 +1,448 @@
import { useState, useRef } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getAccounts, previewImport, importCsvToAccount, type CsvMapping } from "@/api/accounts";
import { getTransactions } from "@/api/transactions";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
ArrowLeft, Upload, FileText, XCircle, Loader2, CheckCircle,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, ChevronLeft, ChevronRight,
} from "lucide-react";
export default function AccountDetail() {
const { accountId } = useParams<{ accountId: string }>();
const qc = useQueryClient();
const [showImport, setShowImport] = useState(false);
const [page, setPage] = useState(1);
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const account = accounts.find(a => a.id === accountId);
const { data: txnData, isLoading: txnLoading } = useQuery({
queryKey: ["transactions", { account_id: accountId, page, page_size: 25 }],
queryFn: () => getTransactions({ account_id: accountId, page, page_size: 25 }),
enabled: !!accountId,
});
if (!account) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
<p>Account not found</p>
</div>
);
}
const isLiability = ["credit_card", "loan", "mortgage"].includes(account.type);
const utilPct = account.credit_limit && account.credit_limit > 0
? Math.min(100, (Math.abs(account.current_balance) / account.credit_limit) * 100)
: null;
return (
<div className="space-y-6">
{/* Back + header */}
<div className="flex items-center gap-3">
<Link to="/accounts" className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold">{account.name}</h1>
<p className="text-sm text-muted-foreground">
{account.institution && `${account.institution} · `}
{account.type.replace(/_/g, " ")} · {account.currency}
</p>
</div>
<button
onClick={() => setShowImport(true)}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Upload className="w-4 h-4" />
Import CSV
</button>
</div>
{/* Account stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Current Balance</p>
<p className={cn("text-xl font-bold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
{formatCurrency(account.current_balance, account.currency)}
</p>
</div>
{account.credit_limit != null && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Credit Limit</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(account.credit_limit, account.currency)}</p>
</div>
)}
{account.interest_rate != null && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Interest Rate</p>
<p className="text-xl font-bold">{Number(account.interest_rate).toFixed(2)}% p.a.</p>
</div>
)}
{txnData && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Total Transactions</p>
<p className="text-xl font-bold tabular-nums">{txnData.total}</p>
</div>
)}
</div>
{/* Credit utilisation */}
{utilPct !== null && (
<div className="bg-card border border-border rounded-xl p-4">
<div className="flex justify-between text-sm mb-2">
<span className="font-medium">Credit Utilisation</span>
<span className={cn("font-semibold", utilPct > 80 ? "text-destructive" : utilPct > 50 ? "text-yellow-500" : "text-success")}>
{utilPct.toFixed(0)}%
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", utilPct > 80 ? "bg-destructive" : utilPct > 50 ? "bg-yellow-500" : "bg-success")}
style={{ width: `${utilPct}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(Math.abs(account.current_balance), account.currency)} used of {formatCurrency(account.credit_limit!, account.currency)}
</p>
</div>
)}
{account.notes && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Notes</p>
<p className="text-sm">{account.notes}</p>
</div>
)}
{/* Transactions */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<p className="font-semibold">Transactions</p>
{txnData && txnData.pages > 1 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Page {page} of {txnData.pages}</span>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="p-1 rounded hover:bg-secondary disabled:opacity-40">
<ChevronLeft className="w-4 h-4" />
</button>
<button onClick={() => setPage(p => Math.min(txnData.pages, p + 1))} disabled={page === txnData.pages} className="p-1 rounded hover:bg-secondary disabled:opacity-40">
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
{txnLoading ? (
<div className="space-y-px">
{[1, 2, 3, 4, 5].map(i => <div key={i} className="h-14 bg-secondary/20 animate-pulse" />)}
</div>
) : !txnData?.items.length ? (
<div className="py-16 text-center text-muted-foreground text-sm">
No transactions yet.{" "}
<button onClick={() => setShowImport(true)} className="text-primary hover:underline">Import from CSV</button>
</div>
) : (
<div>
{txnData.items.map(txn => (
<div key={txn.id} className="flex items-center gap-3 px-5 py-3 border-b border-border/50 hover:bg-secondary/20 transition-colors">
<div className={cn("p-1.5 rounded-lg shrink-0",
txn.type === "income" ? "bg-success/10" :
txn.type === "transfer" ? "bg-primary/10" : "bg-destructive/10"
)}>
{txn.type === "income"
? <ArrowUpCircle className="w-3.5 h-3.5 text-success" />
: txn.type === "transfer"
? <ArrowLeftRight className="w-3.5 h-3.5 text-primary" />
: <ArrowDownCircle className="w-3.5 h-3.5 text-destructive" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{txn.description}</p>
<p className="text-xs text-muted-foreground">{format(new Date(txn.date), "dd MMM yyyy")}</p>
</div>
<p className={cn("text-sm font-semibold tabular-nums shrink-0",
txn.type === "income" ? "text-success" :
txn.type === "expense" ? "text-destructive" : "text-muted-foreground"
)}>
{Number(txn.amount) > 0 ? "+" : ""}{formatCurrency(txn.amount, txn.currency)}
</p>
</div>
))}
</div>
)}
</div>
{showImport && account && (
<ImportModal
accountId={account.id}
accountName={account.name}
onClose={() => setShowImport(false)}
onSuccess={() => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["accounts"] });
qc.invalidateQueries({ queryKey: ["net-worth"] });
}}
/>
)}
</div>
);
}
// ─── Import Modal ─────────────────────────────────────────────────────────────
type ImportStep = "upload" | "preview" | "done";
function ImportModal({
accountId, accountName, onClose, onSuccess,
}: {
accountId: string;
accountName: string;
onClose: () => void;
onSuccess: () => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<ImportStep>("upload");
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<Awaited<ReturnType<typeof previewImport>> | null>(null);
const [mapping, setMapping] = useState<CsvMapping | null>(null);
const [result, setResult] = useState<{ imported: number; skipped: number } | null>(null);
const [detecting, setDetecting] = useState(false);
const [detectError, setDetectError] = useState<string | null>(null);
const importMutation = useMutation({
mutationFn: () => importCsvToAccount(accountId, file!, mapping!),
onSuccess: (data) => {
setResult(data);
setStep("done");
onSuccess();
},
});
async function handleFileSelect(f: File) {
setFile(f);
setDetecting(true);
setDetectError(null);
try {
const p = await previewImport(accountId, f);
setPreview(p);
setMapping(p.mapping);
setStep("preview");
} catch (e: any) {
setDetectError(e?.response?.data?.detail ?? "Failed to read file");
} finally {
setDetecting(false);
}
}
function onDrop(e: React.DragEvent) {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f?.name.toLowerCase().endsWith(".csv")) handleFileSelect(f);
}
const isSplit = mapping ? (!!mapping.debit && !!mapping.credit) : false;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-card border border-border rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-xl">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-border">
<div>
<h2 className="font-semibold text-lg">Import CSV</h2>
<p className="text-xs text-muted-foreground mt-0.5">into {accountName}</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<XCircle className="w-5 h-5" />
</button>
</div>
<div className="p-5 space-y-5">
{/* Step: upload */}
{step === "upload" && (
<>
<div
onDrop={onDrop}
onDragOver={e => e.preventDefault()}
onClick={() => fileRef.current?.click()}
className="border-2 border-dashed border-border rounded-xl p-10 text-center cursor-pointer hover:border-primary/50 transition-colors"
>
{detecting ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-sm">Detecting format</p>
</div>
) : (
<div className="text-muted-foreground">
<Upload className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm font-medium">Drop your bank CSV here</p>
<p className="text-xs mt-1 opacity-60">Supports Monzo, Starling, Revolut, Barclays, Lloyds, NatWest, HSBC, Santander, Nationwide</p>
</div>
)}
<input
ref={fileRef}
type="file"
accept=".csv"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileSelect(f); }}
/>
</div>
{detectError && <p className="text-destructive text-sm text-center">{detectError}</p>}
</>
)}
{/* Step: preview + mapping */}
{step === "preview" && preview && mapping && (
<>
{/* Detected format badge */}
<div className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm",
preview.detected_format ? "bg-success/10 text-success" : "bg-yellow-500/10 text-yellow-600"
)}>
{preview.detected_format ? (
<><CheckCircle className="w-4 h-4 shrink-0" /> Detected: <strong>{preview.detected_format}</strong></>
) : (
<><FileText className="w-4 h-4 shrink-0" /> Unknown format please verify column mapping below</>
)}
<span className="ml-auto text-xs opacity-70">{preview.total_rows} rows</span>
</div>
{/* Column mapping */}
<div>
<p className="text-sm font-semibold mb-3">Column Mapping</p>
<div className="grid grid-cols-2 gap-3">
<ColSelect label="Date column *" value={mapping.date} headers={preview.headers}
onChange={v => setMapping(m => m ? { ...m, date: v } : m)} />
<ColSelect label="Description column *" value={mapping.description} headers={preview.headers}
onChange={v => setMapping(m => m ? { ...m, description: v } : m)} />
{isSplit ? (
<>
<ColSelect label="Debit column (money out)" value={mapping.debit ?? ""} headers={["", ...preview.headers]}
onChange={v => setMapping(m => m ? { ...m, debit: v || null } : m)} />
<ColSelect label="Credit column (money in)" value={mapping.credit ?? ""} headers={["", ...preview.headers]}
onChange={v => setMapping(m => m ? { ...m, credit: v || null } : m)} />
</>
) : (
<ColSelect label="Amount column *" value={mapping.amount ?? ""} headers={preview.headers}
onChange={v => setMapping(m => m ? { ...m, amount: v || null } : m)} />
)}
<div className="flex items-end gap-2">
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
<input
type="checkbox"
checked={isSplit}
onChange={e => {
if (e.target.checked) {
setMapping(m => m ? { ...m, amount: null, debit: preview.headers[0] ?? "", credit: preview.headers[1] ?? "" } : m);
} else {
setMapping(m => m ? { ...m, debit: null, credit: null, amount: preview.headers[0] ?? "" } : m);
}
}}
className="rounded"
/>
Separate debit/credit columns
</label>
</div>
</div>
</div>
{/* Preview table */}
<div>
<p className="text-sm font-semibold mb-2">Preview (first {preview.preview.length} rows)</p>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-xs">
<thead className="bg-secondary/40">
<tr>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Date</th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Description</th>
<th className="text-right px-3 py-2 text-muted-foreground font-medium">Amount</th>
</tr>
</thead>
<tbody>
{preview.preview.map((row, i) => (
<tr key={i} className="border-t border-border/50">
<td className="px-3 py-2 text-muted-foreground">{row.date_raw}</td>
<td className="px-3 py-2 truncate max-w-xs">{row.description_raw}</td>
<td className={cn("px-3 py-2 text-right tabular-nums font-medium",
row.amount_raw == null ? "text-muted-foreground" :
row.amount_raw >= 0 ? "text-success" : "text-destructive"
)}>
{row.amount_raw != null ? formatCurrency(row.amount_raw, "GBP") : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{importMutation.isError && (
<p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">
{(importMutation.error as any)?.response?.data?.detail ?? "Import failed"}
</p>
)}
<div className="flex gap-3">
<button onClick={() => { setStep("upload"); setFile(null); setPreview(null); }}
className="flex-1 border border-border rounded-lg py-2.5 text-sm hover:bg-secondary transition-colors">
Back
</button>
<button
onClick={() => importMutation.mutate()}
disabled={importMutation.isPending || !mapping.date || !mapping.description || (!isSplit && !mapping.amount) || (isSplit && (!mapping.debit || !mapping.credit))}
className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{importMutation.isPending ? <><Loader2 className="w-4 h-4 animate-spin" /> Importing</> : `Import ${preview.total_rows} rows`}
</button>
</div>
</>
)}
{/* Step: done */}
{step === "done" && result && (
<div className="text-center py-8 space-y-4">
<CheckCircle className="w-14 h-14 text-success mx-auto" />
<div>
<p className="text-xl font-bold">{result.imported} transaction{result.imported !== 1 ? "s" : ""} imported</p>
{result.skipped > 0 && (
<p className="text-sm text-muted-foreground mt-1">{result.skipped} duplicate{result.skipped !== 1 ? "s" : ""} skipped</p>
)}
</div>
<div className="flex gap-3 justify-center">
<button onClick={() => { setStep("upload"); setFile(null); setPreview(null); setResult(null); }}
className="border border-border rounded-lg px-5 py-2 text-sm hover:bg-secondary transition-colors">
Import another
</button>
<button onClick={onClose}
className="bg-primary text-primary-foreground rounded-lg px-5 py-2 text-sm font-medium hover:bg-primary/90 transition-colors">
Done
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function ColSelect({ label, value, headers, onChange }: {
label: string; value: string; headers: string[]; onChange: (v: string) => void;
}) {
return (
<div>
<label className="text-xs text-muted-foreground block mb-1">{label}</label>
<select
value={value}
onChange={e => onChange(e.target.value)}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{headers.map(h => <option key={h} value={h}>{h || "— none —"}</option>)}
</select>
</div>
);
}

View file

@ -0,0 +1,218 @@
import { useState } from "react";
import { X, Loader2 } from "lucide-react";
import { type Account, type AccountCreate } from "@/api/accounts";
const ACCOUNT_TYPES = [
{ value: "checking", label: "Checking / Current" },
{ value: "savings", label: "Savings" },
{ value: "cash_isa", label: "Cash ISA" },
{ value: "stocks_shares_isa", label: "Stocks & Shares ISA" },
{ value: "credit_card", label: "Credit Card" },
{ value: "investment", label: "Investment" },
{ value: "cash", label: "Cash" },
{ value: "crypto_wallet", label: "Crypto Wallet" },
{ value: "loan", label: "Loan" },
{ value: "mortgage", label: "Mortgage" },
{ value: "pension", label: "Pension" },
{ value: "other", label: "Other" },
];
const COLORS = ["#6366f1", "#22c55e", "#0ea5e9", "#f59e0b", "#ec4899", "#ef4444", "#a855f7", "#10b981", "#64748b"];
interface Props {
account?: Account;
onClose: () => void;
onSubmit: (data: AccountCreate) => void;
isLoading: boolean;
}
export default function AccountFormModal({ account, onClose, onSubmit, isLoading }: Props) {
const isEdit = !!account;
const [form, setForm] = useState({
name: account?.name ?? "",
institution: account?.institution ?? "",
type: account?.type ?? "checking",
currency: account?.currency ?? "GBP",
opening_balance: account ? String(account.current_balance) : "0",
credit_limit: account?.credit_limit != null ? String(account.credit_limit) : "",
interest_rate: account?.interest_rate != null ? String(account.interest_rate) : "",
include_in_net_worth: account?.include_in_net_worth ?? true,
color: account?.color ?? "#6366f1",
notes: account?.notes ?? "",
});
const [error, setError] = useState<string | null>(null);
const showCreditFields = form.type === "credit_card";
const showInterestFields = ["loan", "mortgage", "credit_card", "savings", "cash_isa", "pension"].includes(form.type);
function set(key: string, value: string | boolean) {
setForm(f => ({ ...f, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) { setError("Account name is required"); return; }
setError(null);
onSubmit({
name: form.name.trim(),
institution: form.institution || undefined,
type: form.type,
currency: form.currency || "GBP",
opening_balance: parseFloat(form.opening_balance) || 0,
credit_limit: form.credit_limit ? parseFloat(form.credit_limit) : undefined,
interest_rate: form.interest_rate ? parseFloat(form.interest_rate) : undefined,
include_in_net_worth: form.include_in_net_worth,
color: form.color,
notes: form.notes || undefined,
});
}
const inputCls = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-semibold">{isEdit ? "Edit Account" : "Add Account"}</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} noValidate className="p-6 space-y-4">
{/* Name */}
<div>
<label className="text-sm font-medium block mb-1.5">Account Name *</label>
<input
value={form.name}
onChange={e => set("name", e.target.value)}
className={inputCls}
placeholder="e.g. Barclays Current"
/>
</div>
{/* Type (only for create) */}
{!isEdit && (
<div>
<label className="text-sm font-medium block mb-1.5">Account Type *</label>
<select value={form.type} onChange={e => set("type", e.target.value)} className={inputCls}>
{ACCOUNT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
)}
{isEdit && (
<div className="text-sm text-muted-foreground">
Type: <span className="text-foreground font-medium">{ACCOUNT_TYPES.find(t => t.value === form.type)?.label ?? form.type}</span>
</div>
)}
{/* Institution + Currency */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Institution</label>
<input value={form.institution} onChange={e => set("institution", e.target.value)} className={inputCls} placeholder="e.g. Barclays" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Currency</label>
<input value={form.currency} onChange={e => set("currency", e.target.value)} className={inputCls} placeholder="GBP" />
</div>
</div>
{/* Balance (label changes based on edit vs create) */}
<div>
<label className="text-sm font-medium block mb-1.5">{isEdit ? "Current Balance" : "Opening Balance"}</label>
<input
type="number" step="0.01"
value={form.opening_balance}
onChange={e => set("opening_balance", e.target.value)}
className={inputCls}
placeholder="0.00"
/>
</div>
{/* Credit limit */}
{showCreditFields && (
<div>
<label className="text-sm font-medium block mb-1.5">Credit Limit</label>
<input
type="number" step="0.01"
value={form.credit_limit}
onChange={e => set("credit_limit", e.target.value)}
className={inputCls}
placeholder="e.g. 5000"
/>
</div>
)}
{/* Interest rate */}
{showInterestFields && (
<div>
<label className="text-sm font-medium block mb-1.5">Interest Rate (% p.a.)</label>
<input
type="number" step="0.01"
value={form.interest_rate}
onChange={e => set("interest_rate", e.target.value)}
className={inputCls}
placeholder="e.g. 3.99"
/>
</div>
)}
{/* Color picker */}
<div>
<label className="text-sm font-medium block mb-1.5">Colour</label>
<div className="flex gap-2 flex-wrap">
{COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => set("color", c)}
className="w-7 h-7 rounded-full border-2 transition-all"
style={{ backgroundColor: c, borderColor: form.color === c ? "white" : "transparent" }}
/>
))}
</div>
</div>
{/* Include in net worth */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.include_in_net_worth}
onChange={e => set("include_in_net_worth", e.target.checked)}
className="rounded"
/>
<span className="text-sm">Include in net worth</span>
</label>
{/* Notes */}
<div>
<label className="text-sm font-medium block mb-1.5">Notes</label>
<textarea
value={form.notes}
onChange={e => set("notes", e.target.value)}
rows={2}
className={`${inputCls} resize-none`}
placeholder="Optional notes about this account"
/>
</div>
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors">
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{isEdit ? "Save Changes" : "Create Account"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,295 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getAccounts, createAccount, updateAccount, deleteAccount, getNetWorth, type Account } from "@/api/accounts";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import {
Plus, Trash2, Pencil, TrendingUp, Wallet,
CreditCard, PiggyBank, Building2, Coins, Bitcoin, Landmark, ShieldCheck, Sprout
} from "lucide-react";
import AccountFormModal from "./AccountFormModal";
const TYPE_ICONS: Record<string, React.ElementType> = {
checking: Wallet,
savings: PiggyBank,
cash_isa: Sprout,
stocks_shares_isa: TrendingUp,
credit_card: CreditCard,
investment: TrendingUp,
cash: Coins,
crypto_wallet: Bitcoin,
loan: Building2,
mortgage: Landmark,
pension: ShieldCheck,
other: Wallet,
};
const TYPE_LABELS: Record<string, string> = {
checking: "Checking",
savings: "Savings",
cash_isa: "Cash ISA",
stocks_shares_isa: "S&S ISA",
credit_card: "Credit Card",
investment: "Investment",
cash: "Cash",
crypto_wallet: "Crypto",
loan: "Loan",
mortgage: "Mortgage",
pension: "Pension",
other: "Other",
};
const LIABILITY_TYPES = new Set(["credit_card", "loan", "mortgage"]);
export default function AccountList() {
const qc = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const [editing, setEditing] = useState<Account | null>(null);
const { data: accounts = [], isLoading } = useQuery({
queryKey: ["accounts"],
queryFn: getAccounts,
});
const { data: nw } = useQuery({
queryKey: ["net-worth"],
queryFn: getNetWorth,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["accounts"] });
qc.invalidateQueries({ queryKey: ["net-worth"] });
};
const deleteMutation = useMutation({
mutationFn: deleteAccount,
onSuccess: invalidate,
});
const createMutation = useMutation({
mutationFn: createAccount,
onSuccess: () => { invalidate(); setShowCreate(false); },
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof updateAccount>[1] }) =>
updateAccount(id, data),
onSuccess: () => { invalidate(); setEditing(null); },
});
const assets = accounts.filter(a => !LIABILITY_TYPES.has(a.type) && a.is_active);
const liabilities = accounts.filter(a => LIABILITY_TYPES.has(a.type) && a.is_active);
const inactive = accounts.filter(a => !a.is_active);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Accounts</h1>
<p className="text-sm text-muted-foreground mt-1">Manage your financial accounts</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Add Account
</button>
</div>
{nw && (
<div className="grid grid-cols-3 gap-4">
{[
{ label: "Total Assets", value: nw.total_assets, positive: true },
{ label: "Total Liabilities", value: nw.total_liabilities, positive: nw.total_liabilities === 0 },
{ label: "Net Worth", value: nw.net_worth, positive: nw.net_worth >= 0 },
].map(({ label, value, positive }) => (
<div key={label} className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={cn("text-xl font-bold", positive ? "text-success" : "text-destructive")}>
{formatCurrency(value, nw.base_currency)}
</p>
</div>
))}
</div>
)}
{isLoading && (
<div className="space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-20 bg-card border border-border rounded-xl animate-pulse" />
))}
</div>
)}
{assets.length > 0 && (
<AccountGroup
title="Assets"
accounts={assets}
onEdit={setEditing}
onDelete={id => deleteMutation.mutate(id)}
/>
)}
{liabilities.length > 0 && (
<AccountGroup
title="Liabilities"
accounts={liabilities}
onEdit={setEditing}
onDelete={id => deleteMutation.mutate(id)}
/>
)}
{inactive.length > 0 && (
<AccountGroup
title="Inactive"
accounts={inactive}
onEdit={setEditing}
onDelete={id => deleteMutation.mutate(id)}
muted
/>
)}
{accounts.length === 0 && !isLoading && (
<div className="text-center py-16 text-muted-foreground">
<Wallet className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="font-medium">No accounts yet</p>
<p className="text-sm mt-1">Add your first account to get started</p>
</div>
)}
{showCreate && (
<AccountFormModal
onClose={() => setShowCreate(false)}
onSubmit={data => createMutation.mutate(data)}
isLoading={createMutation.isPending}
/>
)}
{editing && (
<AccountFormModal
account={editing}
onClose={() => setEditing(null)}
onSubmit={data => updateMutation.mutate({ id: editing.id, data })}
isLoading={updateMutation.isPending}
/>
)}
</div>
);
}
function AccountGroup({
title,
accounts,
onEdit,
onDelete,
muted = false,
}: {
title: string;
accounts: Account[];
onEdit: (a: Account) => void;
onDelete: (id: string) => void;
muted?: boolean;
}) {
return (
<div>
<h2 className={cn("text-sm font-semibold uppercase tracking-wider mb-3", muted ? "text-muted-foreground" : "text-foreground")}>
{title}
</h2>
<div className="space-y-2">
{accounts.map(account => {
const Icon = TYPE_ICONS[account.type] || Wallet;
const isLiability = LIABILITY_TYPES.has(account.type);
const utilPct = account.credit_limit && account.credit_limit > 0
? Math.min(100, (Math.abs(account.current_balance) / account.credit_limit) * 100)
: null;
return (
<div
key={account.id}
className="bg-card border border-border rounded-xl p-4 hover:border-primary/30 transition-colors group"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-lg shrink-0" style={{ backgroundColor: account.color + "20" }}>
<Icon className="w-5 h-5" style={{ color: account.color }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link to={`/accounts/${account.id}`} className="font-medium truncate hover:text-primary transition-colors">
{account.name}
</Link>
<span className="text-xs text-muted-foreground bg-secondary px-1.5 py-0.5 rounded shrink-0">
{TYPE_LABELS[account.type] || account.type}
</span>
{!account.include_in_net_worth && (
<span className="text-xs text-muted-foreground italic">excluded from net worth</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{account.institution && (
<p className="text-xs text-muted-foreground">{account.institution}</p>
)}
{account.interest_rate != null && (
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{Number(account.interest_rate).toFixed(2)}%</span> p.a.
</p>
)}
{account.notes && (
<p className="text-xs text-muted-foreground truncate max-w-xs">{account.notes}</p>
)}
</div>
</div>
<div className="text-right shrink-0">
<p className={cn("font-semibold tabular-nums", isLiability ? "text-destructive" : "text-foreground")}>
{formatCurrency(account.current_balance, account.currency)}
</p>
{account.credit_limit != null && (
<p className="text-xs text-muted-foreground">
limit {formatCurrency(account.credit_limit, account.currency)}
</p>
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={() => onEdit(account)}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
title="Edit account"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(account.id)}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Delete account"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Credit utilisation bar */}
{utilPct !== null && (
<div className="mt-3">
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>Credit used</span>
<span>{utilPct.toFixed(0)}%</span>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", utilPct > 80 ? "bg-destructive" : utilPct > 50 ? "bg-yellow-500" : "bg-success")}
style={{ width: `${utilPct}%` }}
/>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,188 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { login, loginTotp, getMe } from "@/api/auth";
import { useAuthStore } from "@/store/authStore";
import { DollarSign, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react";
export default function LoginPage() {
const navigate = useNavigate();
const { setToken, setTotpEnabled } = useAuthStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [challengeToken, setChallengeToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleLogin(e: React.FormEvent) {
e.preventDefault();
if (!email || !password) {
setError("Please enter your email and password.");
return;
}
setError(null);
setLoading(true);
try {
const res = await login(email, password);
if (res.totp_required && res.challenge_token) {
setChallengeToken(res.challenge_token);
return;
}
if (res.access_token) {
// Set token first so getMe() has the Authorization header
setToken(res.access_token, "", "");
const me = await getMe();
setToken(res.access_token, me.id, me.display_name ?? me.email);
setTotpEnabled(me.totp_enabled);
navigate("/");
}
} catch (e: unknown) {
const detail = (e as { response?: { data?: { detail?: unknown } } }).response?.data?.detail;
if (typeof detail === "string") {
setError(detail);
} else if (Array.isArray(detail)) {
setError((detail[0] as { msg?: string })?.msg ?? "Login failed");
} else {
setError("Login failed. Check your credentials and try again.");
}
} finally {
setLoading(false);
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault();
if (!challengeToken) return;
setError(null);
setLoading(true);
try {
const res = await loginTotp(challengeToken, totpCode);
if (res.access_token) {
setToken(res.access_token, "", "");
const me = await getMe();
setToken(res.access_token, me.id, me.display_name ?? me.email);
setTotpEnabled(me.totp_enabled);
navigate("/");
}
} catch {
setError("Invalid TOTP code. Try again.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="flex items-center justify-center gap-2 mb-8">
<div className="p-2 rounded-xl bg-primary/20">
<DollarSign className="w-8 h-8 text-primary" />
</div>
<span className="text-2xl font-bold">Finance Tracker</span>
</div>
<div className="bg-card border border-border rounded-xl p-8 shadow-xl">
{!challengeToken ? (
<>
<h1 className="text-xl font-semibold mb-6">Sign in</h1>
<form onSubmit={handleLogin} className="space-y-4" noValidate>
<div>
<label className="text-sm font-medium text-foreground block mb-1.5">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
autoFocus
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="you@example.com"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground block mb-1.5">Password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{error && (
<p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2 font-medium">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-md py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
</>
) : (
<>
<div className="flex items-center gap-2 mb-4">
<ShieldCheck className="w-5 h-5 text-primary" />
<h1 className="text-xl font-semibold">Two-factor authentication</h1>
</div>
<p className="text-sm text-muted-foreground mb-6">
Enter the 6-digit code from your authenticator app.
</p>
<form onSubmit={handleTotp} className="space-y-4" noValidate>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
autoComplete="one-time-code"
autoFocus
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-center tracking-widest text-lg font-mono focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="000000"
/>
{error && (
<p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2 font-medium">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-md py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Verifying…" : "Verify"}
</button>
<button
type="button"
onClick={() => setChallengeToken(null)}
className="w-full text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Back to login
</button>
</form>
</>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,159 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { getTotpSetup, enableTotp } from "@/api/auth";
import { useAuthStore } from "@/store/authStore";
import { useQuery, useMutation } from "@tanstack/react-query";
import { ShieldCheck, Copy, CheckCircle, Loader2 } from "lucide-react";
const schema = z.object({ code: z.string().length(6, "6-digit code required") });
type Form = z.infer<typeof schema>;
export default function TwoFactorSetupPage() {
const navigate = useNavigate();
const { setTotpEnabled } = useAuthStore();
const [copied, setCopied] = useState(false);
const [secret, setSecret] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["totp-setup"],
queryFn: async () => {
const res = await getTotpSetup();
setSecret(res.secret);
return res;
},
});
const { register, handleSubmit, formState } = useForm<Form>({
resolver: zodResolver(schema),
});
const enableMutation = useMutation({
mutationFn: ({ code }: { code: string }) => enableTotp(secret!, code),
onSuccess: () => {
setTotpEnabled(true);
navigate("/settings");
},
onError: () => setError("Invalid code — try again"),
});
function copySecret() {
if (data?.secret) {
navigator.clipboard.writeText(data.secret);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-md mx-auto mt-8">
<div className="bg-card border border-border rounded-xl p-8">
<div className="flex items-center gap-2 mb-6">
<ShieldCheck className="w-6 h-6 text-primary" />
<h1 className="text-xl font-semibold">Set up two-factor authentication</h1>
</div>
{/* QR code */}
<div className="flex justify-center mb-6">
<div className="p-3 bg-white rounded-lg">
{data?.qr_code_png_b64 && (
<img
src={`data:image/png;base64,${data.qr_code_png_b64}`}
alt="TOTP QR code"
className="w-48 h-48"
/>
)}
</div>
</div>
<p className="text-sm text-muted-foreground mb-2 text-center">
Scan with your authenticator app (Authy, Google Authenticator, etc.)
</p>
{/* Manual secret */}
<div className="mb-6">
<p className="text-xs text-muted-foreground mb-1">Or enter the secret manually:</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-secondary px-3 py-2 rounded font-mono break-all">
{data?.secret}
</code>
<button onClick={copySecret} className="text-muted-foreground hover:text-foreground">
{copied ? <CheckCircle className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
{/* Backup codes */}
{data?.backup_codes && (
<div className="mb-6">
<p className="text-xs font-medium text-warning mb-2">
Save these backup codes you can only see them once:
</p>
<div className="grid grid-cols-2 gap-1">
{data.backup_codes.map((code) => (
<code key={code} className="text-xs bg-secondary px-2 py-1 rounded font-mono text-center">
{code}
</code>
))}
</div>
</div>
)}
{/* Verify */}
<form
onSubmit={handleSubmit(({ code }) => enableMutation.mutate({ code }))}
className="space-y-3"
>
<div>
<label className="text-sm font-medium block mb-1.5">
Enter code to confirm setup
</label>
<input
{...register("code")}
type="text"
inputMode="numeric"
maxLength={6}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-center tracking-widest font-mono focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="000000"
/>
{formState.errors.code && (
<p className="text-destructive text-xs mt-1">{formState.errors.code.message}</p>
)}
</div>
{error && (
<p className="text-destructive text-sm bg-destructive/10 rounded px-3 py-2">{error}</p>
)}
<button
type="submit"
disabled={enableMutation.isPending}
className="w-full flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-md py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{enableMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Enable 2FA
</button>
<button
type="button"
onClick={() => navigate("/")}
className="w-full text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Skip for now
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,164 @@
import { useState } from "react";
import { X } from "lucide-react";
import { BudgetCreate } from "@/api/budgets";
import { format } from "date-fns";
interface Category {
id: string;
name: string;
type: string;
}
interface Props {
categories: Category[];
onClose: () => void;
onSubmit: (data: BudgetCreate) => void;
isLoading: boolean;
}
export default function BudgetFormModal({ categories, onClose, onSubmit, isLoading }: Props) {
const today = format(new Date(), "yyyy-MM-dd");
const [form, setForm] = useState<BudgetCreate>({
category_id: "",
name: "",
amount: 0,
currency: "GBP",
period: "monthly",
start_date: today,
end_date: null,
rollover: false,
alert_threshold: 80,
});
const expenseCategories = categories.filter((c) => c.type === "expense" || c.type === "system");
function set<K extends keyof BudgetCreate>(key: K, value: BudgetCreate[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.category_id || !form.name || !form.amount) return;
onSubmit(form);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-md shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border">
<h2 className="font-semibold text-lg">New Budget</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="text-sm font-medium block mb-1.5">Budget name *</label>
<input
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. Monthly Groceries"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
required
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Category *</label>
<select
value={form.category_id}
onChange={(e) => set("category_id", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
required
>
<option value="">Select category...</option>
{expenseCategories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Amount *</label>
<input
type="number"
min="0.01"
step="0.01"
value={form.amount || ""}
onChange={(e) => set("amount", parseFloat(e.target.value) || 0)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
required
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Period</label>
<select
value={form.period}
onChange={(e) => set("period", e.target.value as BudgetCreate["period"])}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Start date *</label>
<input
type="date"
value={form.start_date}
onChange={(e) => set("start_date", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
required
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Alert at (%)</label>
<input
type="number"
min="0"
max="100"
value={form.alert_threshold}
onChange={(e) => set("alert_threshold", parseFloat(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={form.rollover}
onChange={(e) => set("rollover", e.target.checked)}
className="rounded border-input"
/>
Roll over unused budget to next period
</label>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{isLoading ? "Creating..." : "Create Budget"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,197 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getBudgetSummary, createBudget, deleteBudget } from "@/api/budgets";
import { getCategories } from "@/api/transactions";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { Plus, Trash2, AlertTriangle, CheckCircle } from "lucide-react";
import BudgetFormModal from "./BudgetFormModal";
function RadialGauge({ percent, size = 80 }: { percent: number; size?: number }) {
const r = size / 2 - 8;
const circumference = 2 * Math.PI * r;
const clamped = Math.min(percent, 100);
const offset = circumference - (clamped / 100) * circumference;
const color = percent >= 100 ? "#ef4444" : percent >= 80 ? "#f97316" : "#22c55e";
return (
<svg width={size} height={size} className="shrink-0">
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeWidth={6}
className="text-secondary"
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={6}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{ transition: "stroke-dashoffset 0.4s ease" }}
/>
<text
x={size / 2}
y={size / 2 + 4}
textAnchor="middle"
fontSize={12}
fontWeight="600"
fill={color}
>
{Math.round(percent)}%
</text>
</svg>
);
}
export default function BudgetPage() {
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const { data: summary = [], isLoading } = useQuery({
queryKey: ["budget-summary"],
queryFn: getBudgetSummary,
refetchInterval: 60_000,
});
const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: getCategories });
const createMutation = useMutation({
mutationFn: createBudget,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["budget-summary"] });
qc.invalidateQueries({ queryKey: ["budgets"] });
setShowForm(false);
},
});
const deleteMutation = useMutation({
mutationFn: deleteBudget,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["budget-summary"] });
qc.invalidateQueries({ queryKey: ["budgets"] });
},
});
const overBudget = summary.filter((s) => s.is_over_budget).length;
const alerted = summary.filter((s) => s.alert_triggered && !s.is_over_budget).length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Budgets</h1>
<p className="text-sm text-muted-foreground mt-1">
{summary.length} active budget{summary.length !== 1 ? "s" : ""}
{overBudget > 0 && (
<span className="ml-2 text-destructive font-medium">· {overBudget} over budget</span>
)}
{alerted > 0 && (
<span className="ml-2 text-orange-500 font-medium">· {alerted} near limit</span>
)}
</p>
</div>
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Add Budget
</button>
</div>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 bg-card border border-border rounded-xl animate-pulse" />
))}
</div>
) : summary.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<p className="font-medium">No budgets yet</p>
<p className="text-sm mt-1">Create a budget to start tracking your spending</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{summary.map((item) => (
<div
key={item.budget_id}
className={cn(
"bg-card border rounded-xl p-5 relative group",
item.is_over_budget
? "border-destructive/50"
: item.alert_triggered
? "border-orange-500/50"
: "border-border"
)}
>
<button
onClick={() => deleteMutation.mutate(item.budget_id)}
className="absolute top-3 right-3 p-1 rounded text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<div className="flex items-start gap-4">
<RadialGauge percent={Number(item.percent_used)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
{item.is_over_budget ? (
<AlertTriangle className="w-3.5 h-3.5 text-destructive shrink-0" />
) : item.alert_triggered ? (
<AlertTriangle className="w-3.5 h-3.5 text-orange-500 shrink-0" />
) : (
<CheckCircle className="w-3.5 h-3.5 text-success shrink-0" />
)}
<p className="font-semibold text-sm truncate">{item.budget_name}</p>
</div>
<p className="text-xs text-muted-foreground mb-2">{item.category_name} · {item.period}</p>
<div className="space-y-0.5">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Spent</span>
<span className={cn("font-medium", item.is_over_budget ? "text-destructive" : "")}>
{formatCurrency(item.spent_amount, item.currency)}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Budget</span>
<span className="font-medium">{formatCurrency(item.budget_amount, item.currency)}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Remaining</span>
<span className={cn("font-medium", item.remaining_amount < 0 ? "text-destructive" : "text-success")}>
{formatCurrency(Math.abs(item.remaining_amount), item.currency)}
{item.remaining_amount < 0 ? " over" : ""}
</span>
</div>
</div>
</div>
</div>
<div className="mt-3 text-xs text-muted-foreground">
{item.period_start} {item.period_end}
</div>
</div>
))}
</div>
)}
{showForm && (
<BudgetFormModal
categories={categories}
onClose={() => setShowForm(false)}
onSubmit={(data) => createMutation.mutate(data)}
isLoading={createMutation.isPending}
/>
)}
</div>
);
}

View file

@ -0,0 +1,244 @@
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@/store/authStore";
import { getNetWorth, getAccounts } from "@/api/accounts";
import { getTransactions } from "@/api/transactions";
import { getNetWorthReport, getIncomeExpenseReport, getCategoryBreakdown } from "@/api/reports";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
TrendingUp, CreditCard, PiggyBank, ArrowLeftRight, ShieldAlert,
ArrowUpCircle, ArrowDownCircle,
} from "lucide-react";
import {
AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell,
XAxis, YAxis, Tooltip, ResponsiveContainer,
} from "recharts";
import { Link } from "react-router-dom";
const COLORS = ["#6366f1","#22c55e","#f97316","#ec4899","#14b8a6","#f59e0b","#8b5cf6","#ef4444"];
const TYPE_COLORS: Record<string, string> = {
income: "text-success",
expense: "text-destructive",
transfer: "text-muted-foreground",
investment: "text-primary",
};
export default function Dashboard() {
const displayName = useAuthStore((s) => s.displayName);
const totpEnabled = useAuthStore((s) => s.totpEnabled);
const { data: nw } = useQuery({ queryKey: ["net-worth"], queryFn: getNetWorth });
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const { data: nwReport } = useQuery({ queryKey: ["report-net-worth"], queryFn: () => getNetWorthReport(6) });
const { data: ieReport } = useQuery({ queryKey: ["report-income-expense"], queryFn: () => getIncomeExpenseReport(6) });
const { data: catReport } = useQuery({ queryKey: ["report-categories"], queryFn: () => getCategoryBreakdown() });
const { data: txnData } = useQuery({
queryKey: ["transactions", { page: 1, page_size: 5 }],
queryFn: () => getTransactions({ page: 1, page_size: 5 }),
});
const currentMonth = ieReport?.points[ieReport.points.length - 1];
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">
Welcome back{displayName ? `, ${displayName}` : ""}
</h1>
<p className="text-muted-foreground text-sm mt-1">Here's your financial overview</p>
</div>
{/* 2FA nudge */}
{!totpEnabled && (
<div className="flex items-center gap-3 bg-yellow-500/10 border border-yellow-500/30 rounded-xl px-4 py-3">
<ShieldAlert className="w-5 h-5 text-yellow-500 shrink-0" />
<p className="flex-1 text-sm">
<span className="font-medium text-yellow-500">Enable two-factor authentication</span>
<span className="text-muted-foreground ml-1">to secure your account.</span>
</p>
<Link to="/security/totp" className="text-xs text-yellow-500 underline underline-offset-2 shrink-0">
Set up 2FA
</Link>
</div>
)}
{/* KPI cards */}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<KpiCard
title="Net Worth"
value={nw ? formatCurrency(nw.net_worth, nw.base_currency) : "—"}
subtitle={`${accounts.filter(a => a.is_active).length} active accounts`}
icon={TrendingUp}
positive={nw ? nw.net_worth >= 0 : undefined}
/>
<KpiCard
title="Total Assets"
value={nw ? formatCurrency(nw.total_assets, nw.base_currency) : "—"}
subtitle="Cash + investments"
icon={PiggyBank}
positive
/>
<KpiCard
title="Total Liabilities"
value={nw ? formatCurrency(nw.total_liabilities, nw.base_currency) : "—"}
subtitle="Loans, mortgages, credit"
icon={CreditCard}
positive={nw ? nw.total_liabilities === 0 : undefined}
/>
<KpiCard
title="This Month"
value={currentMonth
? formatCurrency(Number(currentMonth.net), "GBP")
: "—"}
subtitle={currentMonth
? `${formatCurrency(Number(currentMonth.income), "GBP")}${formatCurrency(Number(currentMonth.expenses), "GBP")}`
: "No transactions yet"}
icon={ArrowLeftRight}
positive={currentMonth ? Number(currentMonth.net) >= 0 : undefined}
/>
</div>
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Net worth trend */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-4">Net Worth Trend</p>
{nwReport && nwReport.points.length > 0 ? (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={nwReport.points.map(p => ({ date: p.date, value: Number(p.net_worth) }))}>
<defs>
<linearGradient id="nwGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v/1000).toFixed(0)}k`} width={45} />
<Tooltip formatter={(v: number) => formatCurrency(v, nwReport.base_currency)} />
<Area type="monotone" dataKey="value" stroke="#6366f1" fill="url(#nwGrad)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-44 flex flex-col items-center justify-center text-muted-foreground text-sm gap-1">
<TrendingUp className="w-8 h-8 opacity-20 mb-1" />
<p>Snapshots taken nightly</p>
<p className="text-xs">Check back tomorrow for your trend</p>
</div>
)}
</div>
{/* Monthly income vs expenses */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-4">Income vs Expenses</p>
{ieReport && ieReport.points.length > 0 ? (
<ResponsiveContainer width="100%" height={180}>
<BarChart data={ieReport.points.map(p => ({ month: p.month, income: Number(p.income), expenses: Number(p.expenses) }))}>
<XAxis dataKey="month" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v/1000).toFixed(0)}k`} width={45} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Bar dataKey="income" fill="#22c55e" radius={[2,2,0,0]} name="Income" />
<Bar dataKey="expenses" fill="#ef4444" radius={[2,2,0,0]} name="Expenses" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-44 flex flex-col items-center justify-center text-muted-foreground text-sm gap-1">
<ArrowLeftRight className="w-8 h-8 opacity-20 mb-1" />
<p>No transactions yet</p>
<Link to="/transactions" className="text-xs text-primary hover:underline">Add your first transaction</Link>
</div>
)}
</div>
</div>
{/* Bottom row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Spending by category */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-4">Spending This Month</p>
{catReport && catReport.items.length > 0 ? (
<div className="flex gap-4 items-center">
<ResponsiveContainer width={140} height={140}>
<PieChart>
<Pie data={catReport.items.slice(0,8).map(i => ({ name: i.category_name, value: Number(i.amount) }))}
cx="50%" cy="50%" innerRadius={42} outerRadius={65} dataKey="value" paddingAngle={2}>
{catReport.items.slice(0,8).map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-1.5 min-w-0">
{catReport.items.slice(0,6).map((item, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div className="w-2 h-2 rounded-full shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
<span className="flex-1 truncate text-muted-foreground">{item.category_name}</span>
<span className="font-medium tabular-nums">{formatCurrency(Number(item.amount), "GBP")}</span>
</div>
))}
</div>
</div>
) : (
<div className="h-36 flex items-center justify-center text-muted-foreground text-sm">
No expenses this month
</div>
)}
</div>
{/* Recent transactions */}
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold">Recent Transactions</p>
<Link to="/transactions" className="text-xs text-primary hover:underline">View all</Link>
</div>
{txnData && txnData.items.length > 0 ? (
<div className="space-y-2">
{txnData.items.map((txn) => (
<div key={txn.id} className="flex items-center gap-3">
<div className={cn("p-1.5 rounded-lg shrink-0", txn.type === "income" ? "bg-success/10" : "bg-destructive/10")}>
{txn.type === "income"
? <ArrowUpCircle className="w-3.5 h-3.5 text-success" />
: <ArrowDownCircle className="w-3.5 h-3.5 text-destructive" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate font-medium">{txn.description}</p>
<p className="text-xs text-muted-foreground">{format(new Date(txn.date), "dd MMM")}</p>
</div>
<p className={cn("text-sm font-semibold tabular-nums shrink-0", TYPE_COLORS[txn.type])}>
{Number(txn.amount) >= 0 ? "+" : ""}{formatCurrency(txn.amount, txn.currency)}
</p>
</div>
))}
</div>
) : (
<div className="h-36 flex items-center justify-center text-muted-foreground text-sm">
<Link to="/transactions" className="text-primary hover:underline">Add your first transaction</Link>
</div>
)}
</div>
</div>
</div>
);
}
function KpiCard({ title, value, subtitle, icon: Icon, positive }: {
title: string; value: string; subtitle?: string; icon: React.ElementType; positive?: boolean;
}) {
return (
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">{title}</p>
<div className="p-1.5 bg-primary/10 rounded-lg">
<Icon className="w-4 h-4 text-primary" />
</div>
</div>
<p className={cn("text-2xl font-bold tabular-nums",
positive === true ? "text-success" : positive === false ? "text-destructive" : "text-foreground"
)}>
{value}
</p>
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
</div>
);
}

View file

@ -0,0 +1,208 @@
import { useState, useEffect } from "react";
import { X, Search, Loader2 } from "lucide-react";
import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments";
import { format } from "date-fns";
interface Account { id: string; name: string; type: string; }
interface Props {
accounts: Account[];
onClose: () => void;
onSuccess: () => void;
}
export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<AssetSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<AssetSearchResult | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const investAccounts = accounts.filter(a =>
["investment", "pension", "savings", "other"].includes(a.type)
);
const [form, setForm] = useState({
account_id: investAccounts[0]?.id ?? "",
quantity: "",
price: "",
fees: "0",
date: format(new Date(), "yyyy-MM-dd"),
});
useEffect(() => {
const t = setTimeout(async () => {
if (query.length < 1) { setResults([]); return; }
setSearching(true);
try {
const r = await searchAssets(query);
setResults(r);
} finally {
setSearching(false);
}
}, 400);
return () => clearTimeout(t);
}, [query]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selected || !form.account_id || !form.quantity || !form.price) return;
setSaving(true);
setError(null);
try {
const qty = parseFloat(form.quantity);
const price = parseFloat(form.price);
const holding = await createHolding({
account_id: form.account_id,
asset_id: selected.id,
quantity: qty,
avg_cost_basis: price,
currency: selected.currency,
});
await addInvestmentTransaction({
holding_id: holding.id,
type: "buy",
quantity: qty,
price: price,
fees: parseFloat(form.fees) || 0,
currency: selected.currency,
date: form.date,
});
onSuccess();
} catch (e: any) {
setError(e?.response?.data?.detail ?? "Failed to add holding");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-md shadow-xl">
<div className="flex items-center justify-between p-5 border-b border-border">
<h2 className="font-semibold text-lg">Add Holding</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-4">
{/* Asset search */}
<div>
<label className="text-sm font-medium block mb-1.5">Search asset *</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
value={query}
onChange={(e) => { setQuery(e.target.value); setSelected(null); }}
placeholder="e.g. AAPL, Vanguard, BTC..."
className="w-full pl-9 pr-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{searching && <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />}
</div>
{results.length > 0 && !selected && (
<div className="mt-1 border border-border rounded-lg overflow-hidden shadow-lg bg-card">
{results.map((r) => (
<button
key={r.id}
type="button"
onClick={() => { setSelected(r); setResults([]); setQuery(`${r.symbol}${r.name}`); }}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-secondary transition-colors text-left"
>
<div>
<span className="font-semibold text-sm">{r.symbol}</span>
<span className="text-muted-foreground text-sm ml-2">{r.name}</span>
</div>
<div className="text-right">
<span className="text-xs text-muted-foreground">{r.type} · {r.currency}</span>
{r.last_price && <p className="text-xs font-medium">{r.last_price}</p>}
</div>
</button>
))}
</div>
)}
{selected && (
<p className="text-xs text-success mt-1"> Selected: {selected.symbol} ({selected.name})</p>
)}
</div>
{/* Account */}
<div>
<label className="text-sm font-medium block mb-1.5">Account *</label>
<select
value={form.account_id}
onChange={(e) => setForm(f => ({ ...f, account_id: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{investAccounts.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
{investAccounts.length === 0 && <option value="">No investment accounts add one first</option>}
</select>
</div>
{/* Quantity / Price / Fees */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs font-medium block mb-1">Quantity *</label>
<input
type="number" min="0" step="any"
value={form.quantity}
onChange={(e) => setForm(f => ({ ...f, quantity: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="10"
/>
</div>
<div>
<label className="text-xs font-medium block mb-1">Price paid *</label>
<input
type="number" min="0" step="any"
value={form.price}
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="150.00"
/>
</div>
<div>
<label className="text-xs font-medium block mb-1">Fees</label>
<input
type="number" min="0" step="any"
value={form.fees}
onChange={(e) => setForm(f => ({ ...f, fees: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="0"
/>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Purchase date *</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm(f => ({ ...f, date: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={handleSubmit}
disabled={saving || !selected || !form.quantity || !form.price || !form.account_id}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? "Adding…" : "Add Holding"}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import { useParams, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getPriceHistory, getPortfolio } from "@/api/investments";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react";
import Plot from "react-plotly.js";
export default function AssetDetail() {
const { assetId } = useParams<{ assetId: string }>();
const { data: portfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio });
const holding = portfolio?.holdings.find(h => h.asset_id === assetId);
const { data: prices = [], isLoading } = useQuery({
queryKey: ["prices", assetId],
queryFn: () => getPriceHistory(assetId!, 365),
enabled: !!assetId,
});
const dates = prices.map(p => p.date);
const opens = prices.map(p => p.open ?? p.close);
const highs = prices.map(p => p.high ?? p.close);
const lows = prices.map(p => p.low ?? p.close);
const closes = prices.map(p => p.close);
const volumes = prices.map(p => p.volume ?? 0);
const latestPrice = closes[closes.length - 1];
const prevPrice = closes[closes.length - 2];
const change = latestPrice && prevPrice ? latestPrice - prevPrice : 0;
const changePct = prevPrice && prevPrice !== 0 ? (change / prevPrice) * 100 : 0;
const isUp = change >= 0;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link to="/investments" className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground">
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold">{holding?.symbol ?? "Asset"}</h1>
<p className="text-sm text-muted-foreground">{holding?.asset_name}</p>
</div>
</div>
{/* Price header */}
{latestPrice != null && (
<div className="flex items-end gap-4">
<p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? "GBP")}</p>
<div className={cn("flex items-center gap-1 pb-1 text-sm font-medium", isUp ? "text-success" : "text-destructive")}>
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? "GBP")} ({changePct.toFixed(2)}%)
</div>
</div>
)}
{/* Your position */}
{holding && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Shares held", value: Number(holding.quantity).toLocaleString() },
{ label: "Avg cost", value: formatCurrency(holding.avg_cost_basis, holding.currency) },
{ label: "Current value", value: holding.current_value != null ? formatCurrency(holding.current_value, holding.currency) : "—" },
{ label: "Unrealised gain", value: holding.unrealised_gain != null ? formatCurrency(holding.unrealised_gain, holding.currency) : "—", color: holding.unrealised_gain != null ? (holding.unrealised_gain >= 0 ? "text-success" : "text-destructive") : "" },
].map(({ label, value, color }) => (
<div key={label} className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={cn("font-semibold tabular-nums", color)}>{value}</p>
</div>
))}
</div>
)}
{/* Candlestick chart */}
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Price History (1 Year)</p>
{isLoading ? (
<div className="h-80 animate-pulse bg-secondary/30 rounded-lg" />
) : prices.length === 0 ? (
<div className="h-80 flex items-center justify-center text-muted-foreground text-sm">No price data available</div>
) : (
<Plot
data={[
{
type: "candlestick",
x: dates,
open: opens as number[],
high: highs as number[],
low: lows as number[],
close: closes as number[],
increasing: { line: { color: "#22c55e" } },
decreasing: { line: { color: "#ef4444" } },
name: holding?.symbol ?? "Price",
},
{
type: "bar",
x: dates,
y: volumes as number[],
yaxis: "y2",
marker: { color: "rgba(99,102,241,0.3)" },
name: "Volume",
},
]}
layout={{
paper_bgcolor: "transparent",
plot_bgcolor: "transparent",
font: { color: "var(--muted-foreground)", size: 11 },
xaxis: { rangeslider: { visible: false }, gridcolor: "var(--border)", showgrid: true },
yaxis: { gridcolor: "var(--border)", showgrid: true, domain: [0.25, 1] },
yaxis2: { domain: [0, 0.2], showgrid: false },
margin: { t: 10, r: 10, b: 40, l: 60 },
showlegend: false,
dragmode: "pan",
}}
config={{ responsive: true, displayModeBar: false, scrollZoom: true }}
style={{ width: "100%", height: "360px" }}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,208 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getPortfolio, deleteHolding } from "@/api/investments";
import { getAccounts } from "@/api/accounts";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight } from "lucide-react";
import AddHoldingModal from "./AddHoldingModal";
import { Link } from "react-router-dom";
const COLORS = [
"#6366f1","#22c55e","#f97316","#ec4899","#14b8a6",
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
];
export default function PortfolioPage() {
const qc = useQueryClient();
const [showAdd, setShowAdd] = useState(false);
const { data: portfolio, isLoading } = useQuery({
queryKey: ["portfolio"],
queryFn: getPortfolio,
refetchInterval: 60_000,
});
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const deleteMutation = useMutation({
mutationFn: deleteHolding,
onSuccess: () => qc.invalidateQueries({ queryKey: ["portfolio"] }),
});
const treemapData = portfolio?.holdings
.filter((h) => (h.current_value ?? h.cost_basis_total) > 0)
.map((h, i) => ({
name: h.symbol,
size: Number(h.current_value ?? h.cost_basis_total),
fill: COLORS[i % COLORS.length],
})) ?? [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Investments</h1>
<p className="text-sm text-muted-foreground mt-1">
{portfolio ? `${portfolio.holdings.length} holding${portfolio.holdings.length !== 1 ? "s" : ""}` : ""}
</p>
</div>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Add Holding
</button>
</div>
{/* Summary cards */}
{portfolio && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Portfolio Value", value: portfolio.total_value, positive: true },
{ label: "Total Cost", value: portfolio.total_cost, positive: true },
{ label: "Unrealised Gain", value: portfolio.total_gain, positive: portfolio.total_gain >= 0 },
{ label: "Return", value: portfolio.total_gain_pct, positive: portfolio.total_gain_pct >= 0, isPercent: true },
].map(({ label, value, positive, isPercent }) => (
<div key={label} className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={cn("text-xl font-bold tabular-nums", positive ? "text-success" : "text-destructive")}>
{isPercent ? `${Number(value).toFixed(2)}%` : formatCurrency(value, portfolio.currency)}
</p>
</div>
))}
</div>
)}
{/* Treemap */}
{treemapData.length > 1 && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Allocation</p>
<div className="flex gap-1 flex-wrap">
{(() => {
const total = treemapData.reduce((s, d) => s + d.size, 0);
return treemapData.map((d, i) => (
<div
key={d.name}
style={{ width: `${Math.max(d.size / total * 100, 4)}%`, backgroundColor: COLORS[i % COLORS.length] }}
className="h-16 rounded flex items-center justify-center text-white text-xs font-bold overflow-hidden"
title={`${d.name}: ${formatCurrency(d.size, "GBP")}`}
>
{d.size / total > 0.06 ? d.name : ""}
</div>
));
})()}
</div>
<div className="flex flex-wrap gap-3 mt-3">
{treemapData.map((d, i) => (
<div key={d.name} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="w-2.5 h-2.5 rounded-sm shrink-0" style={{ backgroundColor: COLORS[i % COLORS.length] }} />
{d.name} {formatCurrency(d.size, "GBP")}
</div>
))}
</div>
</div>
)}
{/* Holdings table */}
{isLoading ? (
<div className="space-y-2">
{[1,2,3].map(i => <div key={i} className="h-16 bg-card border border-border rounded-xl animate-pulse" />)}
</div>
) : !portfolio || portfolio.holdings.length === 0 ? (
<div className="bg-card border border-border rounded-xl py-16 text-center text-muted-foreground">
<TrendingUp className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">No holdings yet</p>
<p className="text-sm mt-1">Add your first investment holding to get started</p>
</div>
) : (
<div className="bg-card border border-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border">
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
<th className="text-left px-4 py-3">Asset</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Quantity</th>
<th className="text-right px-4 py-3 hidden md:table-cell">Price</th>
<th className="text-right px-4 py-3">Value</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">Gain / Loss</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">24h</th>
<th className="w-16"></th>
</tr>
</thead>
<tbody>
{portfolio.holdings.map((h) => {
const isUp = (h.unrealised_gain ?? 0) >= 0;
const change24Up = (h.price_change_24h ?? 0) >= 0;
return (
<tr key={h.id} className="border-b border-border/50 hover:bg-secondary/20 transition-colors group">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary/15 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-primary">{h.symbol.slice(0,3)}</span>
</div>
<div className="min-w-0">
<p className="font-semibold truncate">{h.symbol}</p>
<p className="text-xs text-muted-foreground truncate">{h.asset_name}</p>
</div>
</div>
</td>
<td className="px-4 py-3 text-right hidden sm:table-cell tabular-nums">
{Number(h.quantity).toLocaleString()}
</td>
<td className="px-4 py-3 text-right hidden md:table-cell tabular-nums">
{h.current_price != null ? formatCurrency(h.current_price, h.currency) : "—"}
</td>
<td className="px-4 py-3 text-right font-semibold tabular-nums">
{h.current_value != null ? formatCurrency(h.current_value, h.currency) : formatCurrency(h.cost_basis_total, h.currency)}
</td>
<td className={cn("px-4 py-3 text-right hidden lg:table-cell", isUp ? "text-success" : "text-destructive")}>
{h.unrealised_gain != null ? (
<div>
<p className="tabular-nums font-medium">{isUp ? "+" : ""}{formatCurrency(h.unrealised_gain, h.currency)}</p>
<p className="text-xs">{isUp ? "+" : ""}{Number(h.unrealised_gain_pct).toFixed(2)}%</p>
</div>
) : "—"}
</td>
<td className={cn("px-4 py-3 text-right hidden lg:table-cell text-xs", change24Up ? "text-success" : "text-destructive")}>
{h.price_change_24h != null ? (
<span className="flex items-center justify-end gap-0.5">
{change24Up ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{Number(h.price_change_24h).toFixed(2)}%
</span>
) : "—"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Link
to={`/investments/${h.asset_id}`}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
>
<ChevronRight className="w-4 h-4" />
</Link>
<button
onClick={() => deleteMutation.mutate(h.id)}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showAdd && (
<AddHoldingModal
accounts={accounts}
onClose={() => setShowAdd(false)}
onSuccess={() => { qc.invalidateQueries({ queryKey: ["portfolio"] }); setShowAdd(false); }}
/>
)}
</div>
);
}

View file

@ -0,0 +1,499 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
getSpendingForecast, getNetWorthProjection, postMonteCarlo,
getBudgetForecast, getCashFlowForecast,
} from "@/api/predictions";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { Sparkles, TrendingUp, BarChart3, Wallet, RefreshCw, Loader2 } from "lucide-react";
import {
AreaChart, Area, BarChart, Bar, LineChart, Line,
XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, ReferenceLine,
} from "recharts";
import Plot from "react-plotly.js";
const TABS = [
{ id: "spending", label: "Spending", icon: BarChart3 },
{ id: "networth", label: "Net Worth", icon: TrendingUp },
{ id: "montecarlo", label: "Monte Carlo", icon: Sparkles },
{ id: "cashflow", label: "Cash Flow", icon: Wallet },
] as const;
type Tab = (typeof TABS)[number]["id"];
export default function PredictionsPage() {
const [tab, setTab] = useState<Tab>("spending");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Predictions</h1>
<p className="text-sm text-muted-foreground mt-1">ML-powered forecasts based on your financial history</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 bg-secondary/50 p-1 rounded-xl w-fit">
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setTab(id)}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors",
tab === id ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
{tab === "spending" && <SpendingTab />}
{tab === "networth" && <NetWorthTab />}
{tab === "montecarlo" && <MonteCarloTab />}
{tab === "cashflow" && <CashFlowTab />}
</div>
);
}
// ─── Spending Forecast ───────────────────────────────────────────────────────
function SpendingTab() {
const { data, isLoading } = useQuery({ queryKey: ["pred-spending"], queryFn: getSpendingForecast });
const [selected, setSelected] = useState(0);
if (isLoading) return <LoadingCard />;
if (!data?.categories.length) return <EmptyCard message="Add some transactions to generate a spending forecast." />;
const cat = data.categories[selected];
const chartData = [
...cat.actuals.map(p => ({ date: p.date.slice(0, 7), actual: p.amount })),
...cat.forecast.map(p => ({
date: p.date.slice(0, 7),
forecast: p.amount,
lower: p.lower,
upper: p.upper,
})),
];
return (
<div className="space-y-4">
{/* Category selector */}
<div className="flex gap-2 flex-wrap">
{data.categories.map((c, i) => (
<button
key={c.category_id}
onClick={() => setSelected(i)}
className={cn(
"px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border",
selected === i
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
{c.category_name}
<span className="ml-1.5 opacity-60 text-xs">{formatCurrency(c.monthly_avg, "GBP")}/mo</span>
</button>
))}
</div>
<div className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold">{cat.category_name} Spending Forecast</p>
<p className="text-xs text-muted-foreground">Shaded = 80% confidence interval</p>
</div>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${v}`} width={55} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Bar dataKey="actual" fill="#6366f1" name="Actual" radius={[2, 2, 0, 0]} />
<Bar dataKey="forecast" fill="#6366f180" name="Forecast" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
{/* Confidence band as area overlay */}
{cat.forecast.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground text-center">
Forecast next 3 months: {cat.forecast.map(f =>
`${f.date.slice(0, 7)}: ${formatCurrency(f.amount, "GBP")} (${formatCurrency(f.lower, "GBP")}${formatCurrency(f.upper, "GBP")})`
).join(" · ")}
</div>
)}
</div>
{/* Budget forecast alert cards */}
<BudgetAlerts />
</div>
);
}
function BudgetAlerts() {
const { data } = useQuery({ queryKey: ["pred-budget"], queryFn: getBudgetForecast });
if (!data?.forecasts.length) return null;
const atRisk = data.forecasts.filter(f => f.probability_overspend > 0.5);
if (!atRisk.length) return null;
return (
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-3">Budget Overspend Risk</p>
<div className="space-y-3">
{atRisk.slice(0, 5).map(f => {
const forecastPct = Math.min(140, (f.forecast_month_total / f.budget_amount) * 100);
return (
<div key={f.category_id}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium">{f.category_name}</span>
<span className={cn("text-xs font-medium", f.probability_overspend > 0.75 ? "text-destructive" : "text-yellow-500")}>
{(f.probability_overspend * 100).toFixed(0)}% overspend risk
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden relative">
<div
className={cn("h-full rounded-full", f.probability_overspend > 0.75 ? "bg-destructive" : "bg-yellow-500")}
style={{ width: `${Math.min(100, forecastPct)}%` }}
/>
<div className="absolute top-0 h-full w-0.5 bg-foreground/40" style={{ left: "100%" }} />
</div>
<div className="flex justify-between text-xs text-muted-foreground mt-0.5">
<span>Spent: {formatCurrency(f.spent_so_far, "GBP")}</span>
<span>Forecast: {formatCurrency(f.forecast_month_total, "GBP")} / {formatCurrency(f.budget_amount, "GBP")}</span>
</div>
</div>
);
})}
</div>
</div>
);
}
// ─── Net Worth Projection ────────────────────────────────────────────────────
function NetWorthTab() {
const [years, setYears] = useState(5);
const { data, isLoading } = useQuery({
queryKey: ["pred-networth", years],
queryFn: () => getNetWorthProjection(years),
});
if (isLoading) return <LoadingCard />;
if (!data) return <EmptyCard message="No data available." />;
if (data.insufficient_data) {
return <EmptyCard message="Not enough net worth history yet. Snapshots are taken nightly — check back after a few days." />;
}
const historyPoints = data.history.map(p => ({ date: p.date, history: p.value }));
const projPoints = data.projections.base.map((p, i) => ({
date: p.date,
conservative: data.projections.conservative[i]?.value,
base: p.value,
optimistic: data.projections.optimistic[i]?.value,
}));
const chartData = [...historyPoints, ...projPoints];
const lastHistory = data.history[data.history.length - 1];
const lastBase = data.projections.base[data.projections.base.length - 1];
const lastOpt = data.projections.optimistic[data.projections.optimistic.length - 1];
const lastCons = data.projections.conservative[data.projections.conservative.length - 1];
return (
<div className="space-y-4">
{/* Year selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Projection horizon:</span>
{[1, 3, 5, 10].map(y => (
<button
key={y}
onClick={() => setYears(y)}
className={cn(
"px-3 py-1 rounded-lg text-sm font-medium transition-colors border",
years === y ? "bg-primary text-primary-foreground border-primary" : "border-border text-muted-foreground hover:bg-secondary"
)}
>
{y}yr
</button>
))}
</div>
{/* Summary cards */}
<div className="grid grid-cols-3 gap-4">
{[
{ label: "Conservative", value: lastCons?.value, color: "text-destructive" },
{ label: "Base Case", value: lastBase?.value, color: "text-foreground" },
{ label: "Optimistic", value: lastOpt?.value, color: "text-success" },
].map(({ label, value, color }) => (
<div key={label} className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">{label} ({years}yr)</p>
<p className={cn("text-lg font-bold tabular-nums", color)}>
{value != null ? formatCurrency(value, "GBP") : "—"}
</p>
</div>
))}
</div>
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-4">Net Worth Projection</p>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v / 1000).toFixed(0)}k`} width={55} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<Legend />
{lastHistory && <ReferenceLine x={lastHistory.date} stroke="var(--border)" strokeDasharray="4 2" label={{ value: "Today", fontSize: 10 }} />}
<Line type="monotone" dataKey="history" stroke="#6366f1" strokeWidth={2} dot={false} name="History" />
<Line type="monotone" dataKey="conservative" stroke="#ef4444" strokeWidth={1.5} strokeDasharray="4 2" dot={false} name="Conservative" />
<Line type="monotone" dataKey="base" stroke="#22c55e" strokeWidth={2} strokeDasharray="4 2" dot={false} name="Base" />
<Line type="monotone" dataKey="optimistic" stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="4 2" dot={false} name="Optimistic" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}
// ─── Monte Carlo ─────────────────────────────────────────────────────────────
function MonteCarloTab() {
const [years, setYears] = useState(5);
const [contribution, setContribution] = useState(0);
const mutation = useMutation({
mutationFn: () => postMonteCarlo({ years, n_simulations: 1000, annual_contribution: contribution }),
});
const data = mutation.data;
return (
<div className="space-y-4">
{/* Controls */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-4">Simulation Parameters</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="text-xs text-muted-foreground block mb-1.5">Projection years</label>
<div className="flex gap-2">
{[1, 3, 5, 10].map(y => (
<button
key={y}
onClick={() => setYears(y)}
className={cn(
"flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors border",
years === y ? "bg-primary text-primary-foreground border-primary" : "border-border text-muted-foreground hover:bg-secondary"
)}
>
{y}yr
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-muted-foreground block mb-1.5">Annual contribution (£)</label>
<input
type="number"
min="0"
step="500"
value={contribution}
onChange={e => setContribution(Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{mutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{mutation.isPending ? "Running 1,000 simulations…" : "Run Simulation"}
</button>
</div>
{data && !data.insufficient_data && (
<>
{/* Summary */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Current Value</p>
<p className="text-lg font-bold">{formatCurrency(data.current_value, "GBP")}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Expected Value (P50, {years}yr)</p>
<p className="text-lg font-bold text-success">{formatCurrency(data.expected_value, "GBP")}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Probability of Gain</p>
<p className={cn("text-lg font-bold", data.probability_of_gain >= 0.5 ? "text-success" : "text-destructive")}>
{(data.probability_of_gain * 100).toFixed(1)}%
</p>
</div>
</div>
{/* Fan chart */}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-1">Portfolio Simulation Fan Chart</p>
<p className="text-xs text-muted-foreground mb-4">1,000 simulations shaded regions show P10P90 range</p>
<Plot
data={[
{
type: "scatter" as const,
x: data.percentiles.p90.map(p => p.date),
y: data.percentiles.p90.map(p => p.value),
fill: "tonexty",
fillcolor: "rgba(99,102,241,0.15)",
line: { color: "#6366f1", width: 1 },
name: "P90",
mode: "lines",
},
{
type: "scatter" as const,
x: data.percentiles.p75.map(p => p.date),
y: data.percentiles.p75.map(p => p.value),
fill: "tonexty",
fillcolor: "rgba(99,102,241,0.2)",
line: { color: "#6366f1", width: 1 },
name: "P75",
mode: "lines",
},
{
type: "scatter" as const,
x: data.percentiles.p50.map(p => p.date),
y: data.percentiles.p50.map(p => p.value),
line: { color: "#22c55e", width: 2.5 },
name: "P50 (Median)",
mode: "lines",
},
{
type: "scatter" as const,
x: data.percentiles.p25.map(p => p.date),
y: data.percentiles.p25.map(p => p.value),
fill: "tonexty",
fillcolor: "rgba(239,68,68,0.1)",
line: { color: "#ef4444", width: 1 },
name: "P25",
mode: "lines",
},
{
type: "scatter" as const,
x: data.percentiles.p10.map(p => p.date),
y: data.percentiles.p10.map(p => p.value),
fill: "tonexty",
fillcolor: "rgba(239,68,68,0.15)",
line: { color: "#ef4444", width: 1 },
name: "P10",
mode: "lines",
},
]}
layout={{
paper_bgcolor: "transparent",
plot_bgcolor: "transparent",
font: { color: "var(--muted-foreground)", size: 11 },
xaxis: { gridcolor: "var(--border)", showgrid: true },
yaxis: {
gridcolor: "var(--border)",
showgrid: true,
tickformat: "£,.0f",
},
margin: { t: 10, r: 10, b: 40, l: 80 },
showlegend: true,
legend: { orientation: "h", y: -0.2 },
}}
config={{ responsive: true, displayModeBar: false }}
style={{ width: "100%", height: "360px" }}
/>
</div>
</>
)}
{data?.insufficient_data && (
<EmptyCard message="No investment holdings found. Add holdings in the Investments section first." />
)}
</div>
);
}
// ─── Cash Flow ───────────────────────────────────────────────────────────────
function CashFlowTab() {
const { data, isLoading } = useQuery({ queryKey: ["pred-cashflow"], queryFn: getCashFlowForecast });
if (isLoading) return <LoadingCard />;
if (!data) return <EmptyCard message="No data available." />;
const hasRisk = data.negative_risk_days.length > 0;
return (
<div className="space-y-4">
{/* Summary cards */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Current Balance</p>
<p className={cn("text-lg font-bold tabular-nums", data.current_balance >= 0 ? "text-foreground" : "text-destructive")}>
{formatCurrency(data.current_balance, "GBP")}
</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Avg Daily Inflow</p>
<p className="text-lg font-bold text-success tabular-nums">+{formatCurrency(data.avg_daily_inflow, "GBP")}</p>
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">Avg Daily Outflow</p>
<p className="text-lg font-bold text-destructive tabular-nums">-{formatCurrency(data.avg_daily_outflow, "GBP")}</p>
</div>
</div>
{hasRisk && (
<div className="flex items-start gap-3 bg-destructive/10 border border-destructive/30 rounded-xl px-4 py-3">
<span className="text-destructive text-sm font-medium shrink-0"> Negative balance risk</span>
<p className="text-sm text-muted-foreground">
Balance may go negative on: {data.negative_risk_days.slice(0, 5).join(", ")}
{data.negative_risk_days.length > 5 && ` +${data.negative_risk_days.length - 5} more`}
</p>
</div>
)}
<div className="bg-card border border-border rounded-xl p-5">
<p className="text-sm font-semibold mb-1">30-Day Balance Forecast</p>
<p className="text-xs text-muted-foreground mb-4">Based on {data.history_days} days of transaction history</p>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={data.forecast} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="balanceGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => v.slice(5)} />
<YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" tickFormatter={v => `£${(v / 1000).toFixed(1)}k`} width={55} />
<Tooltip formatter={(v: number) => formatCurrency(v, "GBP")} />
<ReferenceLine y={0} stroke="#ef4444" strokeDasharray="4 2" />
<Area type="monotone" dataKey="balance" stroke="#6366f1" fill="url(#balanceGrad)" strokeWidth={2} name="Balance" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}
// ─── Shared components ────────────────────────────────────────────────────────
function LoadingCard() {
return (
<div className="space-y-3">
{[1, 2].map(i => (
<div key={i} className="h-48 bg-card border border-border rounded-xl animate-pulse" />
))}
</div>
);
}
function EmptyCard({ message }: { message: string }) {
return (
<div className="bg-card border border-border rounded-xl py-16 text-center text-muted-foreground">
<Sparkles className="w-10 h-10 mx-auto mb-3 opacity-20" />
<p className="text-sm">{message}</p>
</div>
);
}

View file

@ -0,0 +1,282 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
getNetWorthReport,
getIncomeExpenseReport,
getCategoryBreakdown,
getBudgetVsActual,
getSpendingTrends,
} from "@/api/reports";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import {
AreaChart, Area, BarChart, Bar,
PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Legend
} from "recharts";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
const TABS = ["Net Worth", "Income vs Expense", "Categories", "Budget vs Actual", "Spending Trends"] as const;
type Tab = typeof TABS[number];
const COLORS = [
"#6366f1", "#22c55e", "#f97316", "#ec4899", "#14b8a6",
"#f59e0b", "#8b5cf6", "#06b6d4", "#84cc16", "#ef4444",
];
function StatCard({ label, value, change, currency }: {
label: string; value: number; change?: number; currency: string;
}) {
const positive = change !== undefined ? change >= 0 : undefined;
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className="text-xl font-bold tabular-nums">{formatCurrency(value, currency)}</p>
{change !== undefined && (
<div className={cn("flex items-center gap-1 mt-1 text-xs", positive ? "text-success" : "text-destructive")}>
{positive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{positive ? "+" : ""}{formatCurrency(change, currency)} (30d)
</div>
)}
</div>
);
}
function NetWorthTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-net-worth"], queryFn: () => getNetWorthReport(12) });
if (isLoading) return <ChartSkeleton />;
if (!data) return null;
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<StatCard label="Net Worth" value={Number(data.current_net_worth)} change={Number(data.change_30d)} currency={data.base_currency} />
<StatCard label="30d Change %" value={Number(data.change_30d_pct)} currency="%" />
<StatCard label="Data Points" value={data.points.length} currency="" />
</div>
{data.points.length === 0 ? (
<EmptyChart message="No snapshots yet — snapshots are taken daily at 2am" />
) : (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Net Worth Over Time</p>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data.points.map(p => ({ ...p, net_worth: Number(p.net_worth), total_assets: Number(p.total_assets), total_liabilities: Number(p.total_liabilities) }))}>
<defs>
<linearGradient id="nwGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.base_currency)} />
<Area type="monotone" dataKey="net_worth" stroke="#6366f1" fill="url(#nwGrad)" strokeWidth={2} name="Net Worth" />
<Area type="monotone" dataKey="total_assets" stroke="#22c55e" fill="none" strokeWidth={1.5} strokeDasharray="4 2" name="Assets" />
<Area type="monotone" dataKey="total_liabilities" stroke="#ef4444" fill="none" strokeWidth={1.5} strokeDasharray="4 2" name="Liabilities" />
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
function IncomeExpenseTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-income-expense"], queryFn: () => getIncomeExpenseReport(12) });
if (isLoading) return <ChartSkeleton />;
if (!data) return null;
const chartData = data.points.map(p => ({ ...p, income: Number(p.income), expenses: Number(p.expenses), net: Number(p.net) }));
return (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-4">
<StatCard label="Total Income" value={Number(data.total_income)} currency={data.currency} />
<StatCard label="Total Expenses" value={Number(data.total_expenses)} currency={data.currency} />
<StatCard label="Avg Monthly Income" value={Number(data.avg_monthly_income)} currency={data.currency} />
<StatCard label="Avg Monthly Expenses" value={Number(data.avg_monthly_expenses)} currency={data.currency} />
</div>
{chartData.length === 0 ? <EmptyChart /> : (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Monthly Income vs Expenses</p>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${(v/1000).toFixed(0)}k`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
<Bar dataKey="income" fill="#22c55e" name="Income" radius={[2, 2, 0, 0]} />
<Bar dataKey="expenses" fill="#ef4444" name="Expenses" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
function CategoriesTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-categories"], queryFn: () => getCategoryBreakdown() });
if (isLoading) return <ChartSkeleton />;
if (!data) return null;
const pieData = data.items.slice(0, 10).map(i => ({ name: i.category_name, value: Number(i.amount) }));
return (
<div className="space-y-4">
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-1">Expense Breakdown This Month</p>
<p className="text-xs text-muted-foreground mb-4">Total: {formatCurrency(Number(data.total), data.currency)}</p>
{pieData.length === 0 ? <EmptyChart /> : (
<div className="flex gap-6 items-start">
<ResponsiveContainer width={220} height={220}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" innerRadius={60} outerRadius={90} dataKey="value" paddingAngle={2}>
{pieData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2">
{data.items.slice(0, 10).map((item, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
<span className="flex-1 truncate text-muted-foreground">{item.category_name}</span>
<span className="font-medium tabular-nums">{formatCurrency(Number(item.amount), data.currency)}</span>
<span className="text-xs text-muted-foreground w-10 text-right">{item.percent}%</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
function BudgetVsActualTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-budget-actual"], queryFn: getBudgetVsActual });
if (isLoading) return <ChartSkeleton />;
if (!data || data.items.length === 0) return <EmptyChart message="No active budgets" />;
const chartData = data.items.map(i => ({
name: i.budget_name,
budgeted: Number(i.budgeted),
actual: Number(i.actual),
}));
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<StatCard label="Total Budgeted" value={Number(data.total_budgeted)} currency={data.currency} />
<StatCard label="Total Actual" value={Number(data.total_actual)} currency={data.currency} />
</div>
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Budget vs Actual Spending</p>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis type="number" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${v}`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" width={120} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
<Bar dataKey="budgeted" fill="#6366f1" name="Budgeted" radius={[0, 2, 2, 0]} />
<Bar dataKey="actual" fill="#f97316" name="Actual" radius={[0, 2, 2, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
function SpendingTrendsTab() {
const { data, isLoading } = useQuery({ queryKey: ["report-spending-trends"], queryFn: () => getSpendingTrends(6) });
if (isLoading) return <ChartSkeleton />;
if (!data || data.points.length === 0) return <EmptyChart />;
const months = [...new Set(data.points.map(p => p.month))].sort();
const chartData = months.map(month => {
const row: Record<string, string | number> = { month };
data.categories.forEach(cat => {
const pt = data.points.find(p => p.month === month && p.category_name === cat);
row[cat] = pt ? Number(pt.amount) : 0;
});
return row;
});
return (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-4">Spending by Category (6 months)</p>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="month" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" tickFormatter={(v) => `£${v}`} />
<Tooltip formatter={(v: number) => formatCurrency(v, data.currency)} />
<Legend />
{data.categories.slice(0, 8).map((cat, i) => (
<Bar key={cat} dataKey={cat} stackId="a" fill={COLORS[i % COLORS.length]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}
function ChartSkeleton() {
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => <div key={i} className="h-20 bg-card border border-border rounded-xl animate-pulse" />)}
</div>
<div className="h-80 bg-card border border-border rounded-xl animate-pulse" />
</div>
);
}
function EmptyChart({ message = "No data for this period" }: { message?: string }) {
return (
<div className="bg-card border border-border rounded-xl py-16 text-center text-muted-foreground">
<Minus className="w-8 h-8 mx-auto mb-2 opacity-30" />
<p>{message}</p>
</div>
);
}
export default function ReportsPage() {
const [activeTab, setActiveTab] = useState<Tab>("Net Worth");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Reports</h1>
<p className="text-sm text-muted-foreground mt-1">Financial insights and analysis</p>
</div>
<div className="flex gap-1 border-b border-border">
{TABS.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors",
activeTab === tab
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab}
</button>
))}
</div>
<div>
{activeTab === "Net Worth" && <NetWorthTab />}
{activeTab === "Income vs Expense" && <IncomeExpenseTab />}
{activeTab === "Categories" && <CategoriesTab />}
{activeTab === "Budget vs Actual" && <BudgetVsActualTab />}
{activeTab === "Spending Trends" && <SpendingTrendsTab />}
</div>
</div>
);
}

View file

@ -0,0 +1,542 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
import {
getSessions, revokeSession, revokeAllSessions,
getTotpSetup, enableTotp, disableTotp,
changePassword, updateProfile, exportData, getMe,
} from "@/api/auth";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
User, Shield, MonitorSmartphone, Download,
Loader2, CheckCircle, Eye, EyeOff, Trash2,
LogOut, QrCode, KeyRound, AlertTriangle,
} from "lucide-react";
const SECTIONS = [
{ id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield },
{ id: "sessions", label: "Sessions", icon: MonitorSmartphone },
{ id: "data", label: "Data", icon: Download },
] as const;
type Section = (typeof SECTIONS)[number]["id"];
export default function SettingsPage() {
const [section, setSection] = useState<Section>("profile");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">Manage your account and preferences</p>
</div>
<div className="flex gap-6 flex-col lg:flex-row">
{/* Side nav */}
<nav className="flex lg:flex-col gap-1 lg:w-48 shrink-0 overflow-x-auto lg:overflow-visible">
{SECTIONS.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setSection(id)}
className={cn(
"flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap",
section === id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
<Icon className="w-4 h-4 shrink-0" />
{label}
</button>
))}
</nav>
{/* Content */}
<div className="flex-1 min-w-0 space-y-4">
{section === "profile" && <ProfileSection />}
{section === "security" && <SecuritySection />}
{section === "sessions" && <SessionsSection />}
{section === "data" && <DataSection />}
</div>
</div>
</div>
);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const inputCls = "w-full rounded-lg border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
const cardCls = "bg-card border border-border rounded-xl p-5 space-y-4";
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="font-semibold text-base">{children}</h2>;
}
function SuccessBanner({ message }: { message: string }) {
return (
<div className="flex items-center gap-2 bg-success/10 border border-success/30 text-success rounded-lg px-3 py-2 text-sm">
<CheckCircle className="w-4 h-4 shrink-0" />
{message}
</div>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="flex items-center gap-2 bg-destructive/10 border border-destructive/30 text-destructive rounded-lg px-3 py-2 text-sm">
<AlertTriangle className="w-4 h-4 shrink-0" />
{message}
</div>
);
}
// ─── Profile ──────────────────────────────────────────────────────────────────
function ProfileSection() {
const qc = useQueryClient();
const { displayName, setToken, token, userId } = useAuthStore();
const [name, setName] = useState(displayName ?? "");
const [currency, setCurrency] = useState("GBP");
const [success, setSuccess] = useState(false);
useQuery({ queryKey: ["me"], queryFn: getMe, onSuccess: (d: any) => {
setName(d.display_name ?? "");
setCurrency(d.base_currency ?? "GBP");
}} as any);
const mutation = useMutation({
mutationFn: () => updateProfile({ display_name: name, base_currency: currency }),
onSuccess: () => {
setSuccess(true);
setToken(token!, userId!, name);
qc.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => setSuccess(false), 3000);
},
});
return (
<div className={cardCls}>
<SectionTitle>Profile</SectionTitle>
{success && <SuccessBanner message="Profile updated" />}
{mutation.isError && <ErrorBanner message={(mutation.error as any)?.response?.data?.detail ?? "Update failed"} />}
<div>
<label className="text-sm font-medium block mb-1.5">Display name</label>
<input value={name} onChange={e => setName(e.target.value)} className={inputCls} placeholder="Your name" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Base currency</label>
<input value={currency} onChange={e => setCurrency(e.target.value.toUpperCase())} className={inputCls} placeholder="GBP" maxLength={10} />
<p className="text-xs text-muted-foreground mt-1">Used for net worth and report totals</p>
</div>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Save Changes
</button>
</div>
);
}
// ─── Security ─────────────────────────────────────────────────────────────────
function SecuritySection() {
return (
<div className="space-y-4">
<PasswordCard />
<TotpCard />
</div>
);
}
function PasswordCard() {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [showCurrent, setShowCurrent] = useState(false);
const [showNext, setShowNext] = useState(false);
const [success, setSuccess] = useState(false);
const mutation = useMutation({
mutationFn: () => changePassword(current, next),
onSuccess: () => {
setSuccess(true);
setCurrent(""); setNext(""); setConfirm("");
setTimeout(() => setSuccess(false), 4000);
},
});
const mismatch = next.length > 0 && confirm.length > 0 && next !== confirm;
const tooShort = next.length > 0 && next.length < 10;
const canSubmit = current && next && confirm && next === confirm && next.length >= 10;
return (
<div className={cardCls}>
<div className="flex items-center gap-2">
<KeyRound className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Change Password</SectionTitle>
</div>
{success && <SuccessBanner message="Password changed successfully" />}
{mutation.isError && <ErrorBanner message={(mutation.error as any)?.response?.data?.detail ?? "Password change failed"} />}
<div>
<label className="text-sm font-medium block mb-1.5">Current password</label>
<div className="relative">
<input
type={showCurrent ? "text" : "password"}
value={current}
onChange={e => setCurrent(e.target.value)}
className={cn(inputCls, "pr-10")}
/>
<button type="button" onClick={() => setShowCurrent(v => !v)} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{showCurrent ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">New password</label>
<div className="relative">
<input
type={showNext ? "text" : "password"}
value={next}
onChange={e => setNext(e.target.value)}
className={cn(inputCls, "pr-10", tooShort && "border-destructive")}
/>
<button type="button" onClick={() => setShowNext(v => !v)} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{showNext ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{tooShort && <p className="text-xs text-destructive mt-1">Minimum 10 characters</p>}
{/* Strength bar */}
{next.length > 0 && (
<div className="mt-2 flex gap-1">
{[1,2,3,4].map(i => {
const score = Math.min(4, Math.floor(next.length / 3));
return <div key={i} className={cn("h-1 flex-1 rounded-full transition-colors", i <= score ? (score <= 1 ? "bg-destructive" : score <= 2 ? "bg-yellow-500" : score <= 3 ? "bg-primary" : "bg-success") : "bg-secondary")} />;
})}
</div>
)}
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Confirm new password</label>
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
className={cn(inputCls, mismatch && "border-destructive")}
/>
{mismatch && <p className="text-xs text-destructive mt-1">Passwords don't match</p>}
</div>
<button
onClick={() => mutation.mutate()}
disabled={!canSubmit || mutation.isPending}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Update Password
</button>
</div>
);
}
function TotpCard() {
const qc = useQueryClient();
const totpEnabled = useAuthStore(s => s.totpEnabled);
const { setToken, token, userId, displayName } = useAuthStore();
const [step, setStep] = useState<"idle" | "setup" | "disable">("idle");
const [setupData, setSetupData] = useState<{ secret: string; qr_code_png_b64: string; backup_codes: string[] } | null>(null);
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [success, setSuccess] = useState("");
const setupMutation = useMutation({
mutationFn: getTotpSetup,
onSuccess: (data) => { setSetupData(data); setStep("setup"); },
});
const enableMutation = useMutation({
mutationFn: () => enableTotp(setupData!.secret, code),
onSuccess: () => {
setBackupCodes(setupData!.backup_codes);
setToken(token!, userId!, displayName ?? "");
useAuthStore.setState({ totpEnabled: true });
qc.invalidateQueries({ queryKey: ["me"] });
setStep("idle");
setCode("");
},
});
const disableMutation = useMutation({
mutationFn: () => disableTotp(password),
onSuccess: () => {
useAuthStore.setState({ totpEnabled: false });
qc.invalidateQueries({ queryKey: ["me"] });
setStep("idle");
setPassword("");
setSuccess("Two-factor authentication disabled");
setTimeout(() => setSuccess(""), 4000);
},
});
return (
<div className={cardCls}>
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Two-Factor Authentication</SectionTitle>
<span className={cn("ml-auto text-xs px-2 py-0.5 rounded-full font-medium", totpEnabled ? "bg-success/15 text-success" : "bg-secondary text-muted-foreground")}>
{totpEnabled ? "Enabled" : "Disabled"}
</span>
</div>
{success && <SuccessBanner message={success} />}
{(enableMutation.isError || disableMutation.isError) && (
<ErrorBanner message={(enableMutation.error as any)?.response?.data?.detail ?? (disableMutation.error as any)?.response?.data?.detail ?? "Failed"} />
)}
{/* Backup codes shown after enabling */}
{backupCodes && (
<div className="bg-success/10 border border-success/30 rounded-lg p-4 space-y-2">
<p className="text-sm font-semibold text-success">2FA enabled save your backup codes</p>
<p className="text-xs text-muted-foreground">Store these somewhere safe. Each can only be used once.</p>
<div className="grid grid-cols-2 gap-1 mt-2">
{backupCodes.map(c => (
<code key={c} className="text-xs bg-background px-2 py-1 rounded font-mono">{c}</code>
))}
</div>
<button onClick={() => setBackupCodes(null)} className="text-xs text-muted-foreground hover:text-foreground underline mt-1">
I've saved these
</button>
</div>
)}
{step === "idle" && !totpEnabled && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Add an extra layer of security with an authenticator app.</p>
<button
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{setupMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Set up 2FA
</button>
</div>
)}
{step === "idle" && totpEnabled && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">2FA is active. Your account is protected.</p>
<button
onClick={() => setStep("disable")}
className="flex items-center gap-2 border border-destructive/40 text-destructive px-4 py-2 rounded-lg text-sm font-medium hover:bg-destructive/10 transition-colors"
>
Disable 2FA
</button>
</div>
)}
{step === "setup" && setupData && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Scan this QR code with your authenticator app, then enter the 6-digit code to confirm.</p>
<div className="flex justify-center">
<img src={`data:image/png;base64,${setupData.qr_code_png_b64}`} alt="TOTP QR Code" className="w-40 h-40 rounded-lg" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Verification code</label>
<input
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
className={inputCls}
placeholder="000000"
maxLength={6}
/>
</div>
<div className="flex gap-3">
<button onClick={() => setStep("idle")} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={() => enableMutation.mutate()}
disabled={code.length !== 6 || enableMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{enableMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Verify & Enable
</button>
</div>
</div>
)}
{step === "disable" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Enter your password to confirm disabling 2FA.</p>
<div>
<label className="text-sm font-medium block mb-1.5">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={inputCls} />
</div>
<div className="flex gap-3">
<button onClick={() => setStep("idle")} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={() => disableMutation.mutate()}
disabled={!password || disableMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 bg-destructive text-destructive-foreground rounded-lg py-2 text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{disableMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Disable 2FA
</button>
</div>
</div>
)}
</div>
);
}
// ─── Sessions ─────────────────────────────────────────────────────────────────
function SessionsSection() {
const qc = useQueryClient();
const navigate = useNavigate();
const { clearAuth } = useAuthStore();
const { data: sessions = [], isLoading } = useQuery({
queryKey: ["sessions"],
queryFn: getSessions,
});
const revokeMutation = useMutation({
mutationFn: revokeSession,
onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
});
const revokeAllMutation = useMutation({
mutationFn: revokeAllSessions,
onSuccess: () => { clearAuth(); navigate("/login"); },
});
return (
<div className={cardCls}>
<div className="flex items-center justify-between">
<SectionTitle>Active Sessions</SectionTitle>
<button
onClick={() => revokeAllMutation.mutate()}
disabled={revokeAllMutation.isPending}
className="flex items-center gap-1.5 text-xs text-destructive hover:text-destructive/80 border border-destructive/30 px-3 py-1.5 rounded-lg hover:bg-destructive/10 transition-colors"
>
{revokeAllMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <LogOut className="w-3 h-3" />}
Sign out all
</button>
</div>
<p className="text-sm text-muted-foreground">All devices currently signed into your account.</p>
{isLoading ? (
<div className="space-y-2">
{[1,2,3].map(i => <div key={i} className="h-14 bg-secondary/30 rounded-lg animate-pulse" />)}
</div>
) : (
<div className="space-y-2">
{(sessions as any[]).map((s: any) => (
<div key={s.id} className={cn(
"flex items-center gap-3 p-3 rounded-lg border",
s.is_current ? "border-primary/30 bg-primary/5" : "border-border bg-secondary/20"
)}>
<MonitorSmartphone className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{s.user_agent?.split(" ")[0] ?? "Unknown device"}</p>
{s.is_current && <span className="text-xs bg-primary/20 text-primary px-1.5 py-0.5 rounded font-medium shrink-0">This device</span>}
</div>
<p className="text-xs text-muted-foreground">
{s.ip_address} · {s.last_active_at ? `Active ${format(new Date(s.last_active_at), "dd MMM HH:mm")}` : `Created ${format(new Date(s.created_at), "dd MMM")}`}
</p>
</div>
{!s.is_current && (
<button
onClick={() => revokeMutation.mutate(s.id)}
disabled={revokeMutation.isPending}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
title="Revoke session"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
)}
</div>
);
}
// ─── Data ─────────────────────────────────────────────────────────────────────
function DataSection() {
const [exporting, setExporting] = useState(false);
const [exported, setExported] = useState(false);
async function handleExport() {
setExporting(true);
try {
await exportData();
setExported(true);
setTimeout(() => setExported(false), 4000);
} finally {
setExporting(false);
}
}
return (
<div className="space-y-4">
<div className={cardCls}>
<div className="flex items-center gap-2">
<Download className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Export Data</SectionTitle>
</div>
{exported && <SuccessBanner message="Download started" />}
<p className="text-sm text-muted-foreground">
Download all your transactions as a CSV file. Includes date, description, amount, category, and account for every transaction.
</p>
<button
onClick={handleExport}
disabled={exporting}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{exporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
{exporting ? "Preparing export…" : "Download transactions CSV"}
</button>
</div>
<div className={cn(cardCls, "border-destructive/30")}>
<SectionTitle>Danger Zone</SectionTitle>
<p className="text-sm text-muted-foreground">
These actions are permanent. Export your data first if needed.
</p>
<div className="flex items-center gap-3 p-3 border border-destructive/20 rounded-lg bg-destructive/5">
<AlertTriangle className="w-4 h-4 text-destructive shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">Delete account</p>
<p className="text-xs text-muted-foreground">Permanently removes all your data. Cannot be undone.</p>
</div>
<button className="text-xs text-destructive border border-destructive/30 px-3 py-1.5 rounded-lg hover:bg-destructive/10 transition-colors" disabled>
Contact admin
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,258 @@
import { useCallback, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import {
X, Paperclip, Upload, Trash2, FileText, ImageIcon, Loader2,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp,
} from "lucide-react";
import { cn } from "@/utils/cn";
import { formatCurrency } from "@/utils/currency";
import type { Transaction, AttachmentRef } from "@/api/transactions";
import { uploadAttachment, deleteAttachment, getAttachmentUrl } from "@/api/transactions";
const TYPE_COLORS = {
income: "text-success",
expense: "text-destructive",
transfer: "text-muted-foreground",
investment: "text-primary",
};
const TYPE_ICONS = {
income: ArrowUpCircle,
expense: ArrowDownCircle,
transfer: ArrowLeftRight,
investment: TrendingUp,
};
const TYPE_BG = {
income: "bg-success/10",
expense: "bg-destructive/10",
transfer: "bg-secondary",
investment: "bg-primary/10",
};
function humanFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FileIcon({ mimeType }: { mimeType: string }) {
if (mimeType === "application/pdf") return <FileText className="w-4 h-4 shrink-0" />;
return <ImageIcon className="w-4 h-4 shrink-0" />;
}
interface Props {
transaction: Transaction;
accountName?: string;
categoryName?: string;
onClose: () => void;
}
export default function TransactionDetailDrawer({ transaction, accountName, categoryName, onClose }: Props) {
const qc = useQueryClient();
const [attachments, setAttachments] = useState<AttachmentRef[]>(transaction.attachment_refs ?? []);
const [dragging, setDragging] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const Icon = TYPE_ICONS[transaction.type] ?? ArrowDownCircle;
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadAttachment(transaction.id, file),
onSuccess: (ref) => {
setAttachments((prev) => [...prev, ref]);
qc.invalidateQueries({ queryKey: ["transactions"] });
setUploadError(null);
},
onError: (err: any) => {
setUploadError(err?.response?.data?.detail ?? "Upload failed");
},
});
const deleteMutation = useMutation({
mutationFn: (attachmentId: string) => deleteAttachment(transaction.id, attachmentId),
onSuccess: (_data, attachmentId) => {
setAttachments((prev) => prev.filter((a) => a.id !== attachmentId));
qc.invalidateQueries({ queryKey: ["transactions"] });
},
});
const handleFiles = useCallback((files: FileList | null) => {
if (!files) return;
setUploadError(null);
for (const file of Array.from(files)) {
uploadMutation.mutate(file);
}
}, [uploadMutation]);
const onDragOver = (e: React.DragEvent) => { e.preventDefault(); setDragging(true); };
const onDragLeave = () => setDragging(false);
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
handleFiles(e.dataTransfer.files);
};
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={onClose}
/>
{/* Drawer */}
<div className="fixed right-0 top-0 bottom-0 z-50 w-full max-w-md bg-card border-l border-border shadow-2xl flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
<div className="flex items-center gap-3 min-w-0">
<div className={cn("w-9 h-9 rounded-full flex items-center justify-center shrink-0", TYPE_BG[transaction.type])}>
<Icon className={cn("w-4 h-4", TYPE_COLORS[transaction.type])} />
</div>
<div className="min-w-0">
<p className="font-semibold truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">
{format(new Date(transaction.date), "dd MMMM yyyy")}
</p>
</div>
</div>
<button onClick={onClose} className="ml-2 shrink-0 text-muted-foreground hover:text-foreground p-1 rounded transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{/* Amount */}
<div className="bg-secondary/50 rounded-xl p-4 text-center">
<p className={cn("text-3xl font-bold tabular-nums", TYPE_COLORS[transaction.type])}>
{transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
{transaction.amount_base !== null && transaction.currency !== transaction.base_currency && (
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(transaction.amount_base, transaction.base_currency)}
</p>
)}
</div>
{/* Detail rows */}
<div className="space-y-2 text-sm">
{[
["Account", accountName ?? "—"],
["Category", categoryName ?? "Uncategorised"],
["Status", transaction.status.charAt(0).toUpperCase() + transaction.status.slice(1)],
["Type", transaction.type.charAt(0).toUpperCase() + transaction.type.slice(1)],
...(transaction.merchant ? [["Merchant", transaction.merchant]] : []),
...(transaction.notes ? [["Notes", transaction.notes]] : []),
].map(([label, value]) => (
<div key={label} className="flex justify-between gap-4 py-1.5 border-b border-border/50 last:border-0">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-right break-words">{value}</span>
</div>
))}
{transaction.tags.length > 0 && (
<div className="flex justify-between gap-4 py-1.5">
<span className="text-muted-foreground shrink-0">Tags</span>
<div className="flex flex-wrap gap-1 justify-end">
{transaction.tags.map((t) => (
<span key={t} className="text-xs bg-secondary px-2 py-0.5 rounded-full">{t}</span>
))}
</div>
</div>
)}
</div>
{/* Attachments */}
<div>
<div className="flex items-center gap-2 mb-3">
<Paperclip className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Receipts & Attachments</h3>
<span className="text-xs text-muted-foreground ml-auto">{attachments.length}/10</span>
</div>
{/* Existing attachments */}
{attachments.length > 0 && (
<div className="space-y-2 mb-3">
{attachments.map((att) => (
<div
key={att.id}
className="flex items-center gap-3 bg-secondary/50 rounded-lg px-3 py-2 group"
>
<FileIcon mimeType={att.mime_type} />
<div className="flex-1 min-w-0">
<a
href={getAttachmentUrl(transaction.id, att.id)}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium truncate hover:text-primary transition-colors block"
download={att.filename}
>
{att.filename}
</a>
<p className="text-xs text-muted-foreground">{humanFileSize(att.size)}</p>
</div>
<button
onClick={() => deleteMutation.mutate(att.id)}
disabled={deleteMutation.isPending}
className="shrink-0 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all p-1 rounded"
>
{deleteMutation.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
</button>
</div>
))}
</div>
)}
{/* Drop zone */}
{attachments.length < 10 && (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl p-5 text-center cursor-pointer transition-colors select-none",
dragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/30"
)}
>
{uploadMutation.isPending ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin" />
<p className="text-sm">Uploading</p>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="w-6 h-6" />
<p className="text-sm font-medium">Drop files here or click to browse</p>
<p className="text-xs">JPEG, PNG, WebP, PDF max 10 MB</p>
</div>
)}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.webp,.pdf,image/jpeg,image/png,image/webp,application/pdf"
className="sr-only"
onChange={(e) => handleFiles(e.target.files)}
/>
{uploadError && (
<p className="text-destructive text-xs mt-2">{uploadError}</p>
)}
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,168 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { format } from "date-fns";
import { X, Loader2 } from "lucide-react";
import type { Account } from "@/api/accounts";
const schema = z.object({
account_id: z.string().uuid("Select an account"),
transfer_account_id: z.string().uuid().optional().or(z.literal("")),
category_id: z.string().uuid().optional().or(z.literal("")),
type: z.enum(["income", "expense", "transfer", "investment"]),
status: z.enum(["pending", "cleared", "reconciled", "void"]).default("cleared"),
amount: z.coerce.number().refine((v) => v !== 0, "Amount cannot be zero"),
currency: z.string().min(3).max(10).default("GBP"),
date: z.string().min(1, "Date required"),
description: z.string().min(1, "Description required"),
merchant: z.string().optional(),
notes: z.string().optional(),
});
type Form = z.infer<typeof schema>;
interface Props {
accounts: Account[];
categories: { id: string; name: string; type: string }[];
onClose: () => void;
onSubmit: (data: any) => void;
isLoading: boolean;
}
export default function TransactionFormModal({ accounts, categories, onClose, onSubmit, isLoading }: Props) {
const { register, handleSubmit, watch, formState: { errors } } = useForm<Form>({
resolver: zodResolver(schema),
defaultValues: {
type: "expense",
status: "cleared",
currency: "GBP",
date: format(new Date(), "yyyy-MM-dd"),
},
});
const txnType = watch("type");
const filteredCategories = categories.filter((c) =>
txnType === "income" ? c.type === "income" :
txnType === "expense" ? c.type === "expense" :
c.type === "transfer"
);
function handleFormSubmit(data: Form) {
const payload: any = {
...data,
category_id: data.category_id || undefined,
transfer_account_id: data.transfer_account_id || undefined,
};
// Expenses: ensure negative; Income: ensure positive
if (txnType === "expense" && payload.amount > 0) payload.amount = -payload.amount;
if (txnType === "income" && payload.amount < 0) payload.amount = -payload.amount;
onSubmit(payload);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<div className="bg-card border border-border rounded-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-semibold">Add Transaction</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-6 space-y-4">
{/* Type */}
<div>
<label className="text-sm font-medium block mb-1.5">Type</label>
<div className="grid grid-cols-4 gap-1">
{(["expense","income","transfer","investment"] as const).map((t) => (
<label key={t} className="cursor-pointer">
<input {...register("type")} type="radio" value={t} className="sr-only" />
<span className={`block text-center py-1.5 rounded text-xs font-medium border transition-colors ${
watch("type") === t
? "bg-primary text-primary-foreground border-primary"
: "border-border hover:bg-secondary"
}`}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</span>
</label>
))}
</div>
</div>
{/* Account */}
<div>
<label className="text-sm font-medium block mb-1.5">Account *</label>
<select {...register("account_id")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
<option value="">Select account...</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
{errors.account_id && <p className="text-destructive text-xs mt-1">{errors.account_id.message}</p>}
</div>
{/* Transfer destination */}
{txnType === "transfer" && (
<div>
<label className="text-sm font-medium block mb-1.5">To Account *</label>
<select {...register("transfer_account_id")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
<option value="">Select account...</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
)}
{/* Date + Amount */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1.5">Date *</label>
<input {...register("date")} type="date" className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
{errors.date && <p className="text-destructive text-xs mt-1">{errors.date.message}</p>}
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Amount *</label>
<input {...register("amount")} type="number" step="0.01" className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" placeholder="0.00" />
{errors.amount && <p className="text-destructive text-xs mt-1">{errors.amount.message}</p>}
</div>
</div>
{/* Description */}
<div>
<label className="text-sm font-medium block mb-1.5">Description *</label>
<input {...register("description")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" placeholder="e.g. Tesco groceries" />
{errors.description && <p className="text-destructive text-xs mt-1">{errors.description.message}</p>}
</div>
{/* Merchant */}
<div>
<label className="text-sm font-medium block mb-1.5">Merchant</label>
<input {...register("merchant")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" placeholder="e.g. Tesco" />
</div>
{/* Category */}
<div>
<label className="text-sm font-medium block mb-1.5">Category</label>
<select {...register("category_id")} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
<option value="">Uncategorised</option>
{filteredCategories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
{/* Notes */}
<div>
<label className="text-sm font-medium block mb-1.5">Notes</label>
<textarea {...register("notes")} rows={2} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none" />
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button type="submit" disabled={isLoading} className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors">
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
Add Transaction
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,171 @@
import { useState, useRef } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { importCsv } from "@/api/transactions";
import { getAccounts } from "@/api/accounts";
import { Upload, FileText, CheckCircle, XCircle, Loader2, Download } from "lucide-react";
import { api } from "@/api/client";
import { cn } from "@/utils/cn";
export default function TransactionImport() {
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [accountId, setAccountId] = useState("");
const [colDate, setColDate] = useState("date");
const [colDesc, setColDesc] = useState("description");
const [colAmount, setColAmount] = useState("amount");
const [result, setResult] = useState<{ imported: number; skipped: number } | null>(null);
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const importMutation = useMutation({
mutationFn: () => importCsv(file!, accountId, { date: colDate, description: colDesc, amount: colAmount }),
onSuccess: (data) => {
setResult(data);
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["accounts"] });
},
});
function onDrop(e: React.DragEvent) {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f?.name.endsWith(".csv")) setFile(f);
}
async function downloadTemplate() {
const res = await api.get("/transactions/import/template", { responseType: "blob" });
const url = URL.createObjectURL(res.data);
const a = document.createElement("a");
a.href = url;
a.download = "import_template.csv";
a.click();
}
return (
<div className="max-w-xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold">Import Transactions</h1>
<p className="text-sm text-muted-foreground mt-1">Import from a CSV bank export</p>
</div>
{result ? (
<div className="bg-card border border-border rounded-xl p-8 text-center space-y-4">
<CheckCircle className="w-12 h-12 text-success mx-auto" />
<div>
<p className="text-xl font-bold">{result.imported} transactions imported</p>
{result.skipped > 0 && (
<p className="text-sm text-muted-foreground mt-1">{result.skipped} duplicates skipped</p>
)}
</div>
<button
onClick={() => { setResult(null); setFile(null); }}
className="bg-primary text-primary-foreground px-6 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
Import another file
</button>
</div>
) : (
<div className="bg-card border border-border rounded-xl p-6 space-y-5">
{/* Template download */}
<button
onClick={downloadTemplate}
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
<Download className="w-4 h-4" />
Download CSV template
</button>
{/* Drop zone */}
<div
onDrop={onDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => fileRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors",
file ? "border-success bg-success/5" : "border-border hover:border-primary/50"
)}
>
{file ? (
<div className="flex items-center justify-center gap-2">
<FileText className="w-5 h-5 text-success" />
<span className="font-medium text-success">{file.name}</span>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setFile(null); }}
className="text-muted-foreground hover:text-destructive ml-1"
>
<XCircle className="w-4 h-4" />
</button>
</div>
) : (
<div className="text-muted-foreground">
<Upload className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Drop a CSV file here or click to browse</p>
</div>
)}
<input
ref={fileRef}
type="file"
accept=".csv"
className="hidden"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</div>
{/* Account */}
<div>
<label className="text-sm font-medium block mb-1.5">Import into account *</label>
<select
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Select account...</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
{/* Column mapping */}
<div>
<p className="text-sm font-medium mb-2">Column names in your CSV</p>
<div className="grid grid-cols-3 gap-2">
{[
{ label: "Date column", value: colDate, onChange: setColDate },
{ label: "Description column", value: colDesc, onChange: setColDesc },
{ label: "Amount column", value: colAmount, onChange: setColAmount },
].map(({ label, value, onChange }) => (
<div key={label}>
<label className="text-xs text-muted-foreground block mb-1">{label}</label>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded border border-input bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
))}
</div>
</div>
<button
onClick={() => importMutation.mutate()}
disabled={!file || !accountId || importMutation.isPending}
className="w-full flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2.5 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{importMutation.isPending ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Importing...</>
) : (
<><Upload className="w-4 h-4" /> Import</>
)}
</button>
{importMutation.isError && (
<p className="text-destructive text-sm text-center">
Import failed. Check the file format and column names.
</p>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,266 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getTransactions, deleteTransaction, createTransaction, getCategories } from "@/api/transactions";
import type { Transaction } from "@/api/transactions";
import { getAccounts } from "@/api/accounts";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
Plus, Trash2, Search, ChevronLeft, ChevronRight, Upload,
ArrowUpCircle, ArrowDownCircle, ArrowLeftRight, TrendingUp, Paperclip
} from "lucide-react";
import TransactionFormModal from "./TransactionFormModal";
import TransactionDetailDrawer from "./TransactionDetailDrawer";
import { Link } from "react-router-dom";
const TYPE_COLORS = {
income: "text-success",
expense: "text-destructive",
transfer: "text-muted-foreground",
investment: "text-primary",
};
const TYPE_ICONS = {
income: ArrowUpCircle,
expense: ArrowDownCircle,
transfer: ArrowLeftRight,
investment: TrendingUp,
};
export default function TransactionList() {
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [selectedTxn, setSelectedTxn] = useState<Transaction | null>(null);
const [search, setSearch] = useState("");
const [filterType, setFilterType] = useState("");
const [filterAccount, setFilterAccount] = useState("");
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ["transactions", { search, filterType, filterAccount, page }],
queryFn: () =>
getTransactions({
search: search || undefined,
type: filterType || undefined,
account_id: filterAccount || undefined,
page,
page_size: 50,
}),
});
const { data: accounts = [] } = useQuery({ queryKey: ["accounts"], queryFn: getAccounts });
const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: getCategories });
const deleteMutation = useMutation({
mutationFn: deleteTransaction,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["accounts"] });
},
});
const createMutation = useMutation({
mutationFn: createTransaction,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["accounts"] });
setShowForm(false);
},
});
const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a]));
const categoryMap = Object.fromEntries(categories.map((c) => [c.id, c]));
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Transactions</h1>
<p className="text-sm text-muted-foreground mt-1">
{data ? `${data.total} transactions` : ""}
</p>
</div>
<div className="flex gap-2">
<Link
to="/transactions/import"
className="flex items-center gap-2 border border-border px-3 py-2 rounded-lg text-sm hover:bg-secondary transition-colors"
>
<Upload className="w-4 h-4" />
Import CSV
</Link>
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="w-4 h-4" />
Add
</button>
</div>
</div>
{/* Filters */}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="Search transactions..."
className="w-full pl-9 pr-3 py-2 rounded-lg border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<select
value={filterType}
onChange={(e) => { setFilterType(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All types</option>
<option value="income">Income</option>
<option value="expense">Expense</option>
<option value="transfer">Transfer</option>
<option value="investment">Investment</option>
</select>
<select
value={filterAccount}
onChange={(e) => { setFilterAccount(e.target.value); setPage(1); }}
className="px-3 py-2 rounded-lg border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All accounts</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
{/* Table */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{isLoading ? (
<div className="space-y-px">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-14 bg-secondary/30 animate-pulse" />
))}
</div>
) : (data?.items.length ?? 0) === 0 ? (
<div className="py-16 text-center text-muted-foreground">
<p>No transactions found</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="border-b border-border">
<tr className="text-muted-foreground text-xs uppercase tracking-wider">
<th className="text-left px-4 py-3">Date</th>
<th className="text-left px-4 py-3">Description</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Account</th>
<th className="text-left px-4 py-3 hidden lg:table-cell">Category</th>
<th className="text-right px-4 py-3">Amount</th>
<th className="w-10"></th>
</tr>
</thead>
<tbody>
{data?.items.map((txn) => {
const Icon = TYPE_ICONS[txn.type] || ArrowDownCircle;
const account = accountMap[txn.account_id];
const category = txn.category_id ? categoryMap[txn.category_id] : null;
return (
<tr
key={txn.id}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors group cursor-pointer"
onClick={() => setSelectedTxn(txn)}
>
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
{format(new Date(txn.date), "dd MMM yyyy")}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Icon className={cn("w-4 h-4 shrink-0", TYPE_COLORS[txn.type])} />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<p className="truncate font-medium">{txn.description}</p>
{txn.attachment_refs?.length > 0 && (
<Paperclip className="w-3 h-3 text-muted-foreground shrink-0" />
)}
</div>
{txn.merchant && <p className="text-xs text-muted-foreground truncate">{txn.merchant}</p>}
</div>
</div>
</td>
<td className="px-4 py-3 hidden md:table-cell text-muted-foreground">
{account?.name ?? "—"}
</td>
<td className="px-4 py-3 hidden lg:table-cell">
{category ? (
<span className="text-xs bg-secondary px-2 py-0.5 rounded-full">{category.name}</span>
) : (
<span className="text-muted-foreground text-xs">Uncategorised</span>
)}
</td>
<td className={cn("px-4 py-3 text-right font-semibold tabular-nums whitespace-nowrap", TYPE_COLORS[txn.type])}>
{txn.amount >= 0 ? "+" : ""}
{formatCurrency(txn.amount, txn.currency)}
</td>
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => deleteMutation.mutate(txn.id)}
className="p-1 rounded text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{data && data.pages > 1 && (
<div className="flex items-center justify-between text-sm">
<p className="text-muted-foreground">
Page {data.page} of {data.pages}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 rounded border border-border hover:bg-secondary disabled:opacity-40 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
disabled={page === data.pages}
className="p-2 rounded border border-border hover:bg-secondary disabled:opacity-40 transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
{showForm && (
<TransactionFormModal
accounts={accounts}
categories={categories}
onClose={() => setShowForm(false)}
onSubmit={(data) => createMutation.mutate(data)}
isLoading={createMutation.isPending}
/>
)}
{selectedTxn && (
<TransactionDetailDrawer
transaction={selectedTxn}
accountName={accountMap[selectedTxn.account_id]?.name}
categoryName={selectedTxn.category_id ? categoryMap[selectedTxn.category_id]?.name : undefined}
onClose={() => setSelectedTxn(null)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,29 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface AuthState {
token: string | null;
userId: string | null;
displayName: string | null;
totpEnabled: boolean;
setToken: (token: string, userId: string, displayName: string) => void;
setTotpEnabled: (v: boolean) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
userId: null,
displayName: null,
totpEnabled: false,
setToken: (token, userId, displayName) =>
set({ token, userId, displayName }),
setTotpEnabled: (v) => set({ totpEnabled: v }),
clearAuth: () =>
set({ token: null, userId: null, displayName: null, totpEnabled: false }),
}),
{ name: "finance-auth" }
)
);

View file

@ -0,0 +1,34 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type Theme =
| "obsidian"
| "arctic"
| "midnight"
| "vault"
| "terminal"
| "synthwave"
| "ledger";
interface UiState {
theme: Theme;
sidebarOpen: boolean;
currency: string;
setTheme: (t: Theme) => void;
setSidebarOpen: (v: boolean) => void;
setCurrency: (c: string) => void;
}
export const useUiStore = create<UiState>()(
persist(
(set) => ({
theme: "obsidian",
sidebarOpen: true,
currency: "GBP",
setTheme: (theme) => set({ theme }),
setSidebarOpen: (v) => set({ sidebarOpen: v }),
setCurrency: (c) => set({ currency: c }),
}),
{ name: "finance-ui" }
)
);

6
frontend/src/utils/cn.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -0,0 +1,17 @@
export function formatCurrency(
amount: number,
currency: string = "GBP",
locale: string = "en-GB"
): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: 2,
maximumFractionDigits: currency === "BTC" || currency === "ETH" ? 8 : 2,
}).format(amount);
}
export function formatPercent(value: number, decimals = 2): string {
const sign = value >= 0 ? "+" : "";
return `${sign}${value.toFixed(decimals)}%`;
}

View file

@ -0,0 +1,51 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
success: { DEFAULT: "hsl(var(--success, 142 71% 45%))" },
warning: { DEFAULT: "#f59e0b" },
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;

25
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
sourcemap: false,
},
});