mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
Initial public release
Aurum — a self-hosted personal finance operating system: cash flow, net worth tracking, budgets, goals, recurring payments, reports, proactive alerts, and a compound-interest returns calculator, all bilingual (RU/EN) and mobile-first. Ships as a three-container Docker Compose stack (Postgres + FastAPI + nginx-served React frontend). See README.md for setup and CONTRIBUTING.md for development.
This commit is contained in:
14
frontend/.dockerignore
Normal file
14
frontend/.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
||||
# Without this file, `docker build` sends the whole frontend/ directory
|
||||
# (including node_modules, which can be 100+MB of host-platform-specific
|
||||
# native binaries) to the daemon, and the final `COPY . .` in the Dockerfile
|
||||
# copies it straight over the fresh, correct-platform `npm install` output
|
||||
# from the layer before it. Keep this in sync with .gitignore's intent even
|
||||
# though the two files serve different tools.
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
21
frontend/Dockerfile
Normal file
21
frontend/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
# openssl generates the htpasswd hash for optional basic auth — see
|
||||
# docker-entrypoint.d/20-basic-auth.sh.
|
||||
RUN apk add --no-cache openssl
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker-entrypoint.d/20-basic-auth.sh /docker-entrypoint.d/20-basic-auth.sh
|
||||
RUN chmod +x /docker-entrypoint.d/20-basic-auth.sh \
|
||||
&& touch /etc/nginx/basic-auth.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=5 \
|
||||
CMD wget -qO- http://127.0.0.1:80/api/health || exit 1
|
||||
32
frontend/docker-entrypoint.d/20-basic-auth.sh
Normal file
32
frontend/docker-entrypoint.d/20-basic-auth.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/bin/sh
|
||||
# Runs automatically before nginx starts (official nginx image convention:
|
||||
# every executable script in /docker-entrypoint.d/ is sourced on boot).
|
||||
#
|
||||
# Aurum's backend has no login system by design (see backend/app/main.py) —
|
||||
# it's built for one person self-hosting their own instance, not a
|
||||
# multi-tenant service. That means whoever can reach this container can
|
||||
# read, edit, and delete all financial data with no password at all. This
|
||||
# script is the only gate in front of that: if AURUM_BASIC_AUTH_USER and
|
||||
# AURUM_BASIC_AUTH_PASSWORD are set, it turns on HTTP Basic Auth for the
|
||||
# whole app (UI + API) at the nginx layer, in front of everything except
|
||||
# the health check endpoint (which must stay reachable for Docker's own
|
||||
# HEALTHCHECK and external uptime monitors).
|
||||
set -eu
|
||||
|
||||
AUTH_FRAGMENT=/etc/nginx/basic-auth.conf
|
||||
|
||||
if [ -n "${AURUM_BASIC_AUTH_USER:-}" ] && [ -n "${AURUM_BASIC_AUTH_PASSWORD:-}" ]; then
|
||||
HASH="$(openssl passwd -apr1 "$AURUM_BASIC_AUTH_PASSWORD")"
|
||||
echo "${AURUM_BASIC_AUTH_USER}:${HASH}" > /etc/nginx/.htpasswd
|
||||
cat > "$AUTH_FRAGMENT" <<EOF
|
||||
auth_basic "Aurum";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
EOF
|
||||
echo "[aurum] Basic auth enabled for user '${AURUM_BASIC_AUTH_USER}'."
|
||||
else
|
||||
: > "$AUTH_FRAGMENT"
|
||||
echo "[aurum] WARNING: AURUM_BASIC_AUTH_USER / AURUM_BASIC_AUTH_PASSWORD are not set." >&2
|
||||
echo "[aurum] This instance has NO authentication — anyone who can reach it can read," >&2
|
||||
echo "[aurum] edit, and delete all financial data. Fine for 'localhost only'. Before" >&2
|
||||
echo "[aurum] exposing this beyond your own machine, set both variables in .env." >&2
|
||||
fi
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b0b0b" />
|
||||
<title>Aurum — Личные финансы</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
frontend/nginx.conf
Normal file
37
frontend/nginx.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Always open, no auth — this is what Docker's own HEALTHCHECK and any
|
||||
# external uptime monitor hit, and it returns nothing but {"status":"ok"}.
|
||||
# docker-entrypoint.d/20-basic-auth.sh writes basic-auth.conf; without an
|
||||
# explicit "auth_basic off" here this location would inherit whatever it
|
||||
# sets below and healthchecks would start failing with 401.
|
||||
location = /api/health {
|
||||
auth_basic off;
|
||||
proxy_pass http://backend:8000/api/health;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Reverse proxy to the FastAPI backend container. basic-auth.conf is
|
||||
# generated at container start (empty if AURUM_BASIC_AUTH_USER/PASSWORD
|
||||
# aren't set, so this is a no-op by default) — see docker-entrypoint.d/.
|
||||
location /api/ {
|
||||
include /etc/nginx/basic-auth.conf;
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# SPA fallback — client-side routing handles the rest.
|
||||
location / {
|
||||
include /etc/nginx/basic-auth.conf;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
}
|
||||
2467
frontend/package-lock.json
generated
Normal file
2467
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "aurum-frontend",
|
||||
"private": true,
|
||||
"version": "0.15.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.31.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
12
frontend/public/favicon.svg
Normal file
12
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="aurumCoin" x1="5" y1="4" x2="27" y2="28" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#f7d488"/>
|
||||
<stop offset="0.55" stop-color="#eda100"/>
|
||||
<stop offset="1" stop-color="#a8690a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="16" cy="16" r="14.5" fill="url(#aurumCoin)" stroke="#7a4e08" stroke-width="1.2"/>
|
||||
<circle cx="16" cy="16" r="11.5" fill="none" stroke="#7a4e08" stroke-width="0.6" opacity="0.45"/>
|
||||
<text x="16" y="21.5" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="700" text-anchor="middle" fill="#3a2405">A</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 693 B |
62
frontend/src/App.tsx
Normal file
62
frontend/src/App.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { Topbar } from "@/components/layout/Topbar";
|
||||
import { useLocalStorageState } from "@/hooks/useLocalStorageState";
|
||||
import { useAppSettings } from "@/hooks/useSettings";
|
||||
import { setCurrency } from "@/lib/i18n";
|
||||
import { AccountsPage } from "@/pages/AccountsPage";
|
||||
import { AdvicePage } from "@/pages/AdvicePage";
|
||||
import { BudgetPage } from "@/pages/BudgetPage";
|
||||
import { CashFlowPage } from "@/pages/CashFlowPage";
|
||||
import { DashboardPage } from "@/pages/DashboardPage";
|
||||
import { GoalsPage } from "@/pages/GoalsPage";
|
||||
import { NetWorthPage } from "@/pages/NetWorthPage";
|
||||
import { RecurringPage } from "@/pages/RecurringPage";
|
||||
import { ReportsPage } from "@/pages/ReportsPage";
|
||||
import { RoiPage } from "@/pages/RoiPage";
|
||||
import { SettingsPage } from "@/pages/SettingsPage";
|
||||
import { TransactionsPage } from "@/pages/TransactionsPage";
|
||||
|
||||
export default function App() {
|
||||
const [collapsed, setCollapsed] = useLocalStorageState("aurum:sidebar-collapsed", false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
|
||||
// Primary currency is server-persisted, unlike language — sync the
|
||||
// client-side reactive mirror (lib/i18n's getCurrency/formatCurrency)
|
||||
// once the setting loads, so it's not stuck on the "USD" fallback default.
|
||||
const { data: settings } = useAppSettings();
|
||||
useEffect(() => {
|
||||
if (settings) setCurrency(settings.currency);
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-surface-0">
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed(!collapsed)}
|
||||
mobileOpen={mobileNavOpen}
|
||||
onCloseMobile={() => setMobileNavOpen(false)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Topbar onOpenMobileNav={() => setMobileNavOpen(true)} />
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-5 sm:px-6 sm:py-6 lg:px-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/net-worth" element={<NetWorthPage />} />
|
||||
<Route path="/roi" element={<RoiPage />} />
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/cash-flow" element={<CashFlowPage />} />
|
||||
<Route path="/transactions" element={<TransactionsPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/budget" element={<BudgetPage />} />
|
||||
<Route path="/advice" element={<AdvicePage />} />
|
||||
<Route path="/goals" element={<GoalsPage />} />
|
||||
<Route path="/recurring" element={<RecurringPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/api/accounts.ts
Normal file
18
frontend/src/api/accounts.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { AccountInput, AccountWithBalance } from "@/types";
|
||||
|
||||
export function fetchAccounts(includeArchived = false) {
|
||||
return api.get<AccountWithBalance[]>(`/accounts?include_archived=${includeArchived}`);
|
||||
}
|
||||
|
||||
export function createAccount(input: AccountInput) {
|
||||
return api.post<AccountWithBalance>("/accounts", input);
|
||||
}
|
||||
|
||||
export function updateAccount(id: number, input: Partial<AccountInput> & { is_archived?: boolean }) {
|
||||
return api.patch<AccountWithBalance>(`/accounts/${id}`, input);
|
||||
}
|
||||
|
||||
export function deleteAccount(id: number) {
|
||||
return api.delete<void>(`/accounts/${id}`);
|
||||
}
|
||||
6
frontend/src/api/advice.ts
Normal file
6
frontend/src/api/advice.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { AdviceResponse } from "@/types";
|
||||
|
||||
export function fetchAdvice() {
|
||||
return api.get<AdviceResponse>("/advice");
|
||||
}
|
||||
22
frontend/src/api/assets.ts
Normal file
22
frontend/src/api/assets.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { Asset, AssetInput, AssetUpdateInput, AssetValuationInput } from "@/types";
|
||||
|
||||
export function fetchAssets() {
|
||||
return api.get<Asset[]>("/assets");
|
||||
}
|
||||
|
||||
export function createAsset(input: AssetInput) {
|
||||
return api.post<Asset>("/assets", input);
|
||||
}
|
||||
|
||||
export function updateAsset(id: number, input: AssetUpdateInput) {
|
||||
return api.patch<Asset>(`/assets/${id}`, input);
|
||||
}
|
||||
|
||||
export function addAssetValuation(id: number, input: AssetValuationInput) {
|
||||
return api.post<Asset>(`/assets/${id}/valuations`, input);
|
||||
}
|
||||
|
||||
export function deleteAsset(id: number) {
|
||||
return api.delete<void>(`/assets/${id}`);
|
||||
}
|
||||
31
frontend/src/api/backup.ts
Normal file
31
frontend/src/api/backup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { api } from "@/api/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export async function exportBackup(): Promise<void> {
|
||||
const response = await fetch("/api/backup/export");
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `aurum-backup-${date}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function importBackup(file: File): Promise<void> {
|
||||
const text = await file.text();
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(t("backup.invalidFile"));
|
||||
}
|
||||
await api.post<{ status: string }>("/backup/import", payload);
|
||||
}
|
||||
22
frontend/src/api/budgets.ts
Normal file
22
frontend/src/api/budgets.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { Budget, BudgetInput, BudgetStatusResponse } from "@/types";
|
||||
|
||||
export function fetchBudgets() {
|
||||
return api.get<Budget[]>("/budgets");
|
||||
}
|
||||
|
||||
export function fetchBudgetStatus(year: number, month: number) {
|
||||
return api.get<BudgetStatusResponse>(`/budgets/status?year=${year}&month=${month}`);
|
||||
}
|
||||
|
||||
export function createBudget(input: BudgetInput) {
|
||||
return api.post<Budget>("/budgets", input);
|
||||
}
|
||||
|
||||
export function updateBudget(id: number, monthlyLimit: string) {
|
||||
return api.patch<Budget>(`/budgets/${id}`, { monthly_limit: monthlyLimit });
|
||||
}
|
||||
|
||||
export function deleteBudget(id: number) {
|
||||
return api.delete<void>(`/budgets/${id}`);
|
||||
}
|
||||
10
frontend/src/api/cashFlow.ts
Normal file
10
frontend/src/api/cashFlow.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { CashFlowResponse } from "@/types";
|
||||
|
||||
export function fetchCashFlow(startDate?: string, endDate?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (startDate) params.set("start_date", startDate);
|
||||
if (endDate) params.set("end_date", endDate);
|
||||
const query = params.toString();
|
||||
return api.get<CashFlowResponse>(`/cash-flow${query ? `?${query}` : ""}`);
|
||||
}
|
||||
6
frontend/src/api/categories.ts
Normal file
6
frontend/src/api/categories.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { Category } from "@/types";
|
||||
|
||||
export function fetchCategories() {
|
||||
return api.get<Category[]>("/categories");
|
||||
}
|
||||
43
frontend/src/api/client.ts
Normal file
43
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
const API_BASE = "/api";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
let message = body || response.statusText;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { detail?: unknown };
|
||||
if (typeof parsed.detail === "string") message = parsed.detail;
|
||||
} catch {
|
||||
// body wasn't JSON — fall back to the raw text set above
|
||||
}
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
6
frontend/src/api/dashboard.ts
Normal file
6
frontend/src/api/dashboard.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { DashboardSummary } from "@/types";
|
||||
|
||||
export function fetchDashboardSummary(year: number, month: number) {
|
||||
return api.get<DashboardSummary>(`/dashboard/summary?year=${year}&month=${month}`);
|
||||
}
|
||||
22
frontend/src/api/goals.ts
Normal file
22
frontend/src/api/goals.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { Goal, GoalContributionInput, GoalInput } from "@/types";
|
||||
|
||||
export function fetchGoals() {
|
||||
return api.get<Goal[]>("/goals");
|
||||
}
|
||||
|
||||
export function createGoal(input: GoalInput) {
|
||||
return api.post<Goal>("/goals", input);
|
||||
}
|
||||
|
||||
export function updateGoal(id: number, input: Partial<GoalInput>) {
|
||||
return api.patch<Goal>(`/goals/${id}`, input);
|
||||
}
|
||||
|
||||
export function deleteGoal(id: number) {
|
||||
return api.delete<void>(`/goals/${id}`);
|
||||
}
|
||||
|
||||
export function addGoalContribution(id: number, input: GoalContributionInput) {
|
||||
return api.post<Goal>(`/goals/${id}/contributions`, input);
|
||||
}
|
||||
6
frontend/src/api/insights.ts
Normal file
6
frontend/src/api/insights.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { FinancialAlert } from "@/types";
|
||||
|
||||
export function fetchFinancialAlerts() {
|
||||
return api.get<{ alerts: FinancialAlert[] }>("/insights/alerts");
|
||||
}
|
||||
6
frontend/src/api/netWorth.ts
Normal file
6
frontend/src/api/netWorth.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { NetWorthRange, NetWorthSummary } from "@/types";
|
||||
|
||||
export function fetchNetWorthSummary(range: NetWorthRange) {
|
||||
return api.get<NetWorthSummary>(`/net-worth/summary?range=${range}`);
|
||||
}
|
||||
22
frontend/src/api/recurring.ts
Normal file
22
frontend/src/api/recurring.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { RecurringTransaction, RecurringTransactionInput } from "@/types";
|
||||
|
||||
export function fetchRecurring() {
|
||||
return api.get<RecurringTransaction[]>("/recurring");
|
||||
}
|
||||
|
||||
export function createRecurring(input: RecurringTransactionInput) {
|
||||
return api.post<RecurringTransaction>("/recurring", input);
|
||||
}
|
||||
|
||||
export function updateRecurring(id: number, input: Partial<RecurringTransactionInput>) {
|
||||
return api.patch<RecurringTransaction>(`/recurring/${id}`, input);
|
||||
}
|
||||
|
||||
export function deleteRecurring(id: number) {
|
||||
return api.delete<void>(`/recurring/${id}`);
|
||||
}
|
||||
|
||||
export function postRecurring(id: number) {
|
||||
return api.post<RecurringTransaction>(`/recurring/${id}/post`, {});
|
||||
}
|
||||
16
frontend/src/api/reports.ts
Normal file
16
frontend/src/api/reports.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { CategoryKind, CategoryRankingReport, CategorySpendingReport } from "@/types";
|
||||
|
||||
export function fetchCategorySpendingReport(categoryId: number, startDate?: string, endDate?: string) {
|
||||
const params = new URLSearchParams({ category_id: String(categoryId) });
|
||||
if (startDate) params.set("start_date", startDate);
|
||||
if (endDate) params.set("end_date", endDate);
|
||||
return api.get<CategorySpendingReport>(`/reports/category-spending?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function fetchCategoryRanking(kind: CategoryKind, startDate?: string, endDate?: string) {
|
||||
const params = new URLSearchParams({ kind });
|
||||
if (startDate) params.set("start_date", startDate);
|
||||
if (endDate) params.set("end_date", endDate);
|
||||
return api.get<CategoryRankingReport>(`/reports/category-ranking?${params.toString()}`);
|
||||
}
|
||||
12
frontend/src/api/settings.ts
Normal file
12
frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { AppSettings } from "@/types";
|
||||
|
||||
export function fetchSettings() {
|
||||
return api.get<AppSettings>("/settings");
|
||||
}
|
||||
|
||||
// Partial — the backend applies whatever fields are present (PATCH
|
||||
// semantics), so callers only send the field(s) they're changing.
|
||||
export function updateSettings(input: Partial<AppSettings>) {
|
||||
return api.patch<AppSettings>("/settings", input);
|
||||
}
|
||||
37
frontend/src/api/transactions.ts
Normal file
37
frontend/src/api/transactions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { Transaction, TransactionInput, TransactionPage } from "@/types";
|
||||
|
||||
export type TransactionSort = "date_desc" | "amount_desc" | "amount_asc";
|
||||
|
||||
export interface TransactionFilters {
|
||||
year?: number;
|
||||
month?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
account_id?: number;
|
||||
category_id?: number;
|
||||
type?: string;
|
||||
sort?: TransactionSort;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export function fetchTransactions(filters: TransactionFilters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) params.set(key, String(value));
|
||||
});
|
||||
return api.get<TransactionPage>(`/transactions?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function createTransaction(input: TransactionInput) {
|
||||
return api.post<Transaction>("/transactions", input);
|
||||
}
|
||||
|
||||
export function updateTransaction(id: number, input: Partial<TransactionInput>) {
|
||||
return api.patch<Transaction>(`/transactions/${id}`, input);
|
||||
}
|
||||
|
||||
export function deleteTransaction(id: number) {
|
||||
return api.delete<void>(`/transactions/${id}`);
|
||||
}
|
||||
93
frontend/src/components/accounts/AccountFormModal.tsx
Normal file
93
frontend/src/components/accounts/AccountFormModal.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Label, Select } from "@/components/ui/Input";
|
||||
import { useCreateAccount, useUpdateAccount } from "@/hooks/useAccounts";
|
||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||
import type { Account, AccountType } from "@/types";
|
||||
|
||||
interface AccountFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
account?: Account | null;
|
||||
}
|
||||
|
||||
const ACCOUNT_TYPES: AccountType[] = ["checking", "savings", "credit_card", "cash", "investment", "other"];
|
||||
|
||||
const EMPTY_FORM = { name: "", type: "checking" as AccountType };
|
||||
|
||||
export function AccountFormModal({ open, onClose, account }: AccountFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const createAccount = useCreateAccount();
|
||||
const updateAccount = useUpdateAccount();
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setForm(account ? { name: account.name, type: account.type } : EMPTY_FORM);
|
||||
setError(null);
|
||||
}, [open, account]);
|
||||
|
||||
const isSaving = createAccount.isPending || updateAccount.isPending;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (account) {
|
||||
await updateAccount.mutateAsync({ id: account.id, input: form });
|
||||
} else {
|
||||
await createAccount.mutateAsync(form);
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t("account.form.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title={account ? t("account.form.editTitle") : t("account.form.newTitle")}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="account-name">{t("account.form.nameLabel")}</Label>
|
||||
<Input
|
||||
id="account-name"
|
||||
required
|
||||
placeholder={t("account.form.namePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="account-type">{t("account.form.typeLabel")}</Label>
|
||||
<Select
|
||||
id="account-type"
|
||||
value={form.type}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, type: event.target.value as AccountType }))}
|
||||
>
|
||||
{ACCOUNT_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`account.type.${type}` as TranslationKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/accounts/AccountList.tsx
Normal file
90
frontend/src/components/accounts/AccountList.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Archive, ArchiveRestore, Banknote, CreditCard, Package, Pencil, PiggyBank, TrendingUp, Trash2, Wallet, type LucideIcon } from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||
import type { Account, AccountType, AccountWithBalance } from "@/types";
|
||||
|
||||
interface AccountListProps {
|
||||
items: AccountWithBalance[];
|
||||
onEdit: (account: Account) => void;
|
||||
onToggleArchived: (account: AccountWithBalance) => void;
|
||||
onDelete: (account: AccountWithBalance) => void;
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<AccountType, LucideIcon> = {
|
||||
checking: Wallet,
|
||||
savings: PiggyBank,
|
||||
credit_card: CreditCard,
|
||||
cash: Banknote,
|
||||
investment: TrendingUp,
|
||||
other: Package,
|
||||
};
|
||||
|
||||
export function AccountList({ items, onEdit, onToggleArchived, onDelete }: AccountListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-text-muted">{t("account.empty")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-gridline">
|
||||
{items.map((account) => {
|
||||
const Icon = TYPE_ICONS[account.type];
|
||||
const balance = Number(account.balance);
|
||||
|
||||
return (
|
||||
<li key={account.id} className={`flex items-center gap-3 py-3 ${account.is_archived ? "opacity-50" : ""}`}>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-2">
|
||||
<Icon size={16} className="text-text-secondary" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5 truncate text-sm font-medium text-text-primary">
|
||||
{account.name}
|
||||
{account.is_archived && (
|
||||
<span className="shrink-0 rounded bg-surface-2 px-1 py-0.5 text-[10px] leading-none text-text-muted">
|
||||
{t("account.archivedBadge")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-text-muted">
|
||||
{t(`account.type.${account.type}` as TranslationKey)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-sm font-medium tabular-nums"
|
||||
style={{ color: balance < 0 ? "var(--danger)" : "var(--text-primary)" }}
|
||||
>
|
||||
{formatCurrency(balance)}
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={account.is_archived ? t("account.unarchiveLabel") : t("account.archiveLabel")}
|
||||
onClick={() => onToggleArchived(account)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||
>
|
||||
{account.is_archived ? <ArchiveRestore size={15} /> : <Archive size={15} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => onEdit(account)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => onDelete(account)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
112
frontend/src/components/budget/BudgetFormModal.tsx
Normal file
112
frontend/src/components/budget/BudgetFormModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Label, Select } from "@/components/ui/Input";
|
||||
import { useCategories } from "@/hooks/useCategories";
|
||||
import { useCreateBudget, useUpdateBudget } from "@/hooks/useBudgets";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { Budget } from "@/types";
|
||||
|
||||
interface BudgetFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
budget?: Budget | null;
|
||||
/** Expense categories that already have a budget — excluded from the
|
||||
* create picker since each category can only carry one budget. */
|
||||
excludeCategoryIds: number[];
|
||||
}
|
||||
|
||||
export function BudgetFormModal({ open, onClose, budget, excludeCategoryIds }: BudgetFormModalProps) {
|
||||
const { t, language } = useTranslation();
|
||||
const { data: categories } = useCategories();
|
||||
const createBudget = useCreateBudget();
|
||||
const updateBudget = useUpdateBudget();
|
||||
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [monthlyLimit, setMonthlyLimit] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const availableCategories = (categories ?? [])
|
||||
.filter((category) => category.kind === "expense" && !excludeCategoryIds.includes(category.id))
|
||||
.sort((a, b) => translateCategoryName(a.name).localeCompare(translateCategoryName(b.name), language));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (budget) {
|
||||
setCategoryId(String(budget.category_id));
|
||||
setMonthlyLimit(budget.monthly_limit);
|
||||
} else {
|
||||
setCategoryId(availableCategories[0] ? String(availableCategories[0].id) : "");
|
||||
setMonthlyLimit("");
|
||||
}
|
||||
setError(null);
|
||||
// availableCategories is derived from `categories`, not a stable dep — only re-run on open/budget change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, budget]);
|
||||
|
||||
const isSaving = createBudget.isPending || updateBudget.isPending;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (budget) {
|
||||
await updateBudget.mutateAsync({ id: budget.id, monthlyLimit });
|
||||
} else {
|
||||
await createBudget.mutateAsync({ category_id: Number(categoryId), monthly_limit: monthlyLimit });
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t("budget.form.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title={budget ? t("budget.form.editTitle") : t("budget.form.newTitle")}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="budget-category">{t("budget.form.categoryLabel")}</Label>
|
||||
{budget ? (
|
||||
<p className="text-sm text-text-primary">{translateCategoryName(budget.category_name)}</p>
|
||||
) : availableCategories.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{t("budget.form.noCategoriesAvailable")}</p>
|
||||
) : (
|
||||
<Select id="budget-category" required value={categoryId} onChange={(event) => setCategoryId(event.target.value)}>
|
||||
{availableCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="budget-limit">{t("budget.form.limitLabel")}</Label>
|
||||
<Input
|
||||
id="budget-limit"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
value={monthlyLimit}
|
||||
onChange={(event) => setMonthlyLimit(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving || (!budget && availableCategories.length === 0)}>
|
||||
{isSaving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
85
frontend/src/components/budget/BudgetList.tsx
Normal file
85
frontend/src/components/budget/BudgetList.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { getCategoryIcon } from "@/lib/icons";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { BudgetStatus } from "@/types";
|
||||
|
||||
interface BudgetListProps {
|
||||
items: BudgetStatus[];
|
||||
onEdit: (item: BudgetStatus) => void;
|
||||
onDelete: (item: BudgetStatus) => void;
|
||||
}
|
||||
|
||||
export function BudgetList({ items, onEdit, onDelete }: BudgetListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-text-muted">{t("budget.empty")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-gridline">
|
||||
{items.map((item) => {
|
||||
const Icon = getCategoryIcon(item.category_icon);
|
||||
const spent = Number(item.spent);
|
||||
const limit = Number(item.monthly_limit);
|
||||
const barColor = item.is_over_budget ? "var(--danger)" : "var(--success)";
|
||||
const fillPercent = Math.min(100, item.percent);
|
||||
|
||||
return (
|
||||
<li key={item.budget_id} className="py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: `${item.category_color}26` }}
|
||||
>
|
||||
<Icon size={16} style={{ color: item.category_color }} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-text-primary">
|
||||
{translateCategoryName(item.category_name)}
|
||||
</span>
|
||||
<span className="shrink-0 text-sm tabular-nums text-text-primary">
|
||||
{formatCurrency(spent)} <span className="text-text-muted">/ {formatCurrency(limit)}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => onEdit(item)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => onDelete(item)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 pl-12">
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{ width: `${fillPercent}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-text-muted">
|
||||
{item.percent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 pl-12 text-xs" style={{ color: item.is_over_budget ? "var(--danger)" : "var(--text-muted)" }}>
|
||||
{item.is_over_budget
|
||||
? t("budget.overBy", { amount: formatCurrency(spent - limit) })
|
||||
: t("budget.remaining", { amount: formatCurrency(limit - spent) })}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/cashflow/CashFlowChart.tsx
Normal file
157
frontend/src/components/cashflow/CashFlowChart.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Bar, BarChart, Legend, ResponsiveContainer, Tooltip, XAxis } from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { formatCurrency, formatSignedCurrency, getIntlLocale } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CashFlowResponse } from "@/types";
|
||||
|
||||
interface CashFlowChartProps {
|
||||
cashFlow: CashFlowResponse | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function monthKey(year: number, month: number): string {
|
||||
return `${year}-${String(month).padStart(2, "0")}-01`;
|
||||
}
|
||||
|
||||
function formatMonthLabel(key: string): string {
|
||||
return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", year: "numeric" }).format(
|
||||
new Date(`${key}T00:00:00`)
|
||||
);
|
||||
}
|
||||
|
||||
/** Same landmark idea as the Net Worth and category-spending charts: year
|
||||
* ticks only when the bars span more than one calendar year. */
|
||||
function computeYearTicks(keys: string[]): string[] {
|
||||
if (keys.length < 2) return [];
|
||||
const startYear = Number(keys[0].slice(0, 4));
|
||||
const endYear = Number(keys[keys.length - 1].slice(0, 4));
|
||||
if (startYear === endYear) return [];
|
||||
const ticks: string[] = [];
|
||||
for (let year = startYear + 1; year <= endYear; year++) {
|
||||
const jan = `${year}-01-01`;
|
||||
if (keys.includes(jan)) ticks.push(jan);
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
key: string;
|
||||
income: number;
|
||||
expense: number;
|
||||
net: number;
|
||||
}
|
||||
|
||||
function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
incomeLabel,
|
||||
expenseLabel,
|
||||
netLabel,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: ChartPoint }>;
|
||||
incomeLabel: string;
|
||||
expenseLabel: string;
|
||||
netLabel: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const point = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
|
||||
<p className="text-text-muted">{formatMonthLabel(point.key)}</p>
|
||||
<p className="text-success">
|
||||
{incomeLabel}: {formatCurrency(point.income)}
|
||||
</p>
|
||||
<p className="text-danger">
|
||||
{expenseLabel}: {formatCurrency(point.expense)}
|
||||
</p>
|
||||
<p className="font-medium text-text-primary">
|
||||
{netLabel}: {formatSignedCurrency(point.net)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CashFlowChart({ cashFlow, isLoading }: CashFlowChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const incomeLabel = t("cashFlow.income");
|
||||
const expenseLabel = t("cashFlow.expense");
|
||||
const netLabel = t("cashFlow.net");
|
||||
|
||||
const chartData: ChartPoint[] =
|
||||
cashFlow?.points.map((point) => ({
|
||||
key: monthKey(point.year, point.month),
|
||||
income: Number(point.income),
|
||||
expense: Number(point.expense),
|
||||
net: Number(point.net),
|
||||
})) ?? [];
|
||||
const yearTicks = computeYearTicks(chartData.map((point) => point.key));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="items-start">
|
||||
<div>
|
||||
<CardTitle>{t("nav.cashFlow")}</CardTitle>
|
||||
<p className="mt-1.5 text-2xl font-semibold tabular-nums text-text-primary sm:text-[28px]">
|
||||
{isLoading || !cashFlow ? "…" : formatSignedCurrency(cashFlow.total_net)}
|
||||
</p>
|
||||
{cashFlow && (
|
||||
<p className="mt-1 text-sm">
|
||||
<span className="font-medium text-success">
|
||||
{incomeLabel} {formatCurrency(cashFlow.total_income)}
|
||||
</span>
|
||||
<span className="text-text-muted"> · </span>
|
||||
<span className="font-medium text-danger">
|
||||
{expenseLabel} {formatCurrency(cashFlow.total_expense)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-56 w-full sm:h-64">
|
||||
{isLoading || !cashFlow || chartData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
{isLoading ? t("common.loading") : t("cashFlow.noData")}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 4, bottom: 0, left: 4 }}>
|
||||
{yearTicks.length > 0 && (
|
||||
<XAxis
|
||||
dataKey="key"
|
||||
type="category"
|
||||
ticks={yearTicks}
|
||||
tickFormatter={(value: string) => value.slice(0, 4)}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "var(--text-muted)", fontSize: 11 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
content={<ChartTooltip incomeLabel={incomeLabel} expenseLabel={expenseLabel} netLabel={netLabel} />}
|
||||
cursor={{ fill: "var(--surface-2)" }}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={24}
|
||||
formatter={(value) => (value === "income" ? incomeLabel : expenseLabel)}
|
||||
wrapperStyle={{ fontSize: 12, color: "var(--text-muted)" }}
|
||||
/>
|
||||
<Bar dataKey="income" fill="var(--success)" radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
||||
<Bar dataKey="expense" fill="var(--danger)" radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
{chartData.length > 1 && (
|
||||
<div className="mt-2 flex justify-between text-xs text-text-muted">
|
||||
<span>{formatMonthLabel(chartData[0].key)}</span>
|
||||
<span>{formatMonthLabel(chartData[chartData.length - 1].key)}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
62
frontend/src/components/dashboard/RecentTransactionsCard.tsx
Normal file
62
frontend/src/components/dashboard/RecentTransactionsCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { getCategoryIcon } from "@/lib/icons";
|
||||
import { formatCurrency, formatTransactionDate } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useTransactions } from "@/hooks/useTransactions";
|
||||
|
||||
export function RecentTransactionsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useTransactions({ page: 1, page_size: 6 });
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("dashboard.recentTransactionsTitle")}</CardTitle>
|
||||
<Link to="/transactions" className="text-xs font-medium text-series-1 hover:underline">
|
||||
{t("dashboard.allTransactionsLink")}
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : !data?.items.length ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("dashboard.noTransactionsYet")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gridline">
|
||||
{data.items.map((tx) => {
|
||||
const Icon = getCategoryIcon(tx.category?.icon);
|
||||
const isTransfer = tx.type === "transfer";
|
||||
const isExpense = tx.type === "expense";
|
||||
const color = tx.category?.color ?? "var(--text-muted)";
|
||||
return (
|
||||
<li key={tx.id} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: `${color}26` }}
|
||||
>
|
||||
<Icon size={15} style={{ color }} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-text-primary">{tx.description}</span>
|
||||
<span className="block text-xs text-text-muted">
|
||||
{formatTransactionDate(tx.date)} · {tx.account.name}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 text-sm font-medium tabular-nums ${
|
||||
isTransfer ? "text-text-muted" : isExpense ? "text-text-primary" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{isTransfer ? "" : isExpense ? "-" : "+"}
|
||||
{formatCurrency(Number(tx.amount))}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/dashboard/SpendingByCategoryCard.tsx
Normal file
95
frontend/src/components/dashboard/SpendingByCategoryCard.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { getCategoryIcon } from "@/lib/icons";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { CategoryBreakdownItem } from "@/types";
|
||||
|
||||
interface SpendingByCategoryCardProps {
|
||||
items: CategoryBreakdownItem[];
|
||||
}
|
||||
|
||||
function DonutTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: CategoryBreakdownItem }> }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const item = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
|
||||
<p className="font-medium text-text-primary">{translateCategoryName(item.name)}</p>
|
||||
<p className="text-text-secondary">
|
||||
{formatCurrency(item.amount)} · {item.percent.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasData = items.length > 0;
|
||||
// Recharts needs a numeric dataKey — API amounts arrive as strings (Decimal
|
||||
// is serialized as string to avoid float precision loss).
|
||||
const chartData = items.map((item) => ({ ...item, amount: Number(item.amount) }));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("dashboard.spendingByCategoryTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasData ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("dashboard.noExpensesThisMonth")}</p>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-6 sm:flex-row sm:items-center">
|
||||
<div className="h-56 w-56 shrink-0 sm:h-64 sm:w-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="amount"
|
||||
nameKey="name"
|
||||
innerRadius="62%"
|
||||
outerRadius="100%"
|
||||
paddingAngle={2}
|
||||
stroke="var(--surface-1)"
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Cell key={item.category_id ?? "other"} fill={item.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<DonutTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<ul className="w-full min-w-0 flex-1 divide-y divide-gridline">
|
||||
{items.map((item) => {
|
||||
const Icon = getCategoryIcon(item.icon);
|
||||
return (
|
||||
<li
|
||||
key={item.category_id ?? "other"}
|
||||
className="flex items-center gap-3 py-2 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: `${item.color}26` }}
|
||||
>
|
||||
<Icon size={14} style={{ color: item.color }} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
||||
{translateCategoryName(item.name)}
|
||||
</span>
|
||||
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
|
||||
{formatCurrency(item.amount)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/dashboard/StatCard.tsx
Normal file
27
frontend/src/components/dashboard/StatCard.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
tone?: "default" | "success" | "danger";
|
||||
}
|
||||
|
||||
const TONE_CLASSES: Record<NonNullable<StatCardProps["tone"]>, string> = {
|
||||
default: "text-text-primary",
|
||||
success: "text-success",
|
||||
danger: "text-danger",
|
||||
};
|
||||
|
||||
export function StatCard({ label, value, caption, tone = "default" }: StatCardProps) {
|
||||
return (
|
||||
<Card className="p-4 sm:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">{label}</p>
|
||||
<p className={cn("mt-1.5 text-2xl font-semibold tabular-nums sm:text-[28px]", TONE_CLASSES[tone])}>
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">{caption}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/goals/GoalContributionModal.tsx
Normal file
93
frontend/src/components/goals/GoalContributionModal.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Label } from "@/components/ui/Input";
|
||||
import { useAddGoalContribution } from "@/hooks/useGoals";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { Goal } from "@/types";
|
||||
|
||||
interface GoalContributionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
goal: Goal | null;
|
||||
}
|
||||
|
||||
function todayIso() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function GoalContributionModal({ open, onClose, goal }: GoalContributionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const addContribution = useAddGoalContribution();
|
||||
|
||||
const [amount, setAmount] = useState("");
|
||||
const [date, setDate] = useState(todayIso());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setAmount("");
|
||||
setDate(todayIso());
|
||||
setError(null);
|
||||
}, [open, goal]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!goal) return;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await addContribution.mutateAsync({ id: goal.id, input: { amount, date } });
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t("goal.contribution.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={goal ? t("goal.contribution.title", { name: goal.name }) : ""}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="contribution-amount">{t("goal.contribution.amountLabel")}</Label>
|
||||
<Input
|
||||
id="contribution-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
required
|
||||
placeholder={t("goal.contribution.amountPlaceholder")}
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="contribution-date">{t("goal.contribution.dateLabel")}</Label>
|
||||
<Input
|
||||
id="contribution-date"
|
||||
type="date"
|
||||
required
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{t("goal.contribution.hint")}</p>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={addContribution.isPending}>
|
||||
{addContribution.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
110
frontend/src/components/goals/GoalFormModal.tsx
Normal file
110
frontend/src/components/goals/GoalFormModal.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Label } from "@/components/ui/Input";
|
||||
import { useCreateGoal, useUpdateGoal } from "@/hooks/useGoals";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { Goal } from "@/types";
|
||||
|
||||
interface GoalFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
goal?: Goal | null;
|
||||
}
|
||||
|
||||
const EMPTY_FORM = { name: "", target_amount: "", target_date: "" };
|
||||
|
||||
export function GoalFormModal({ open, onClose, goal }: GoalFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const createGoal = useCreateGoal();
|
||||
const updateGoal = useUpdateGoal();
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (goal) {
|
||||
setForm({ name: goal.name, target_amount: goal.target_amount, target_date: goal.target_date ?? "" });
|
||||
} else {
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
setError(null);
|
||||
}, [open, goal]);
|
||||
|
||||
const isSaving = createGoal.isPending || updateGoal.isPending;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const input = {
|
||||
name: form.name,
|
||||
target_amount: form.target_amount,
|
||||
target_date: form.target_date || null,
|
||||
};
|
||||
|
||||
try {
|
||||
if (goal) {
|
||||
await updateGoal.mutateAsync({ id: goal.id, input });
|
||||
} else {
|
||||
await createGoal.mutateAsync(input);
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t("goal.form.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title={goal ? t("goal.form.editTitle") : t("goal.form.newTitle")}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="goal-name">{t("goal.form.nameLabel")}</Label>
|
||||
<Input
|
||||
id="goal-name"
|
||||
required
|
||||
placeholder={t("goal.form.namePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="goal-target">{t("goal.form.targetAmountLabel")}</Label>
|
||||
<Input
|
||||
id="goal-target"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
value={form.target_amount}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, target_amount: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="goal-date">{t("goal.form.targetDateLabel")}</Label>
|
||||
<Input
|
||||
id="goal-date"
|
||||
type="date"
|
||||
value={form.target_date}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, target_date: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
94
frontend/src/components/goals/GoalList.tsx
Normal file
94
frontend/src/components/goals/GoalList.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Flag, PiggyBank, Pencil, Trash2 } from "lucide-react";
|
||||
import { formatCurrency, getIntlLocale } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { Goal } from "@/types";
|
||||
|
||||
interface GoalListProps {
|
||||
items: Goal[];
|
||||
onContribute: (goal: Goal) => void;
|
||||
onEdit: (goal: Goal) => void;
|
||||
onDelete: (goal: Goal) => void;
|
||||
}
|
||||
|
||||
function formatTargetDate(isoDate: string): string {
|
||||
return new Intl.DateTimeFormat(getIntlLocale(), { day: "numeric", month: "short", year: "numeric" }).format(
|
||||
new Date(`${isoDate}T00:00:00`)
|
||||
);
|
||||
}
|
||||
|
||||
export function GoalList({ items, onContribute, onEdit, onDelete }: GoalListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-text-muted">{t("goal.empty")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-gridline">
|
||||
{items.map((goal) => {
|
||||
const current = Number(goal.current_amount);
|
||||
const target = Number(goal.target_amount);
|
||||
const remaining = Number(goal.remaining);
|
||||
const fillPercent = Math.min(100, goal.percent);
|
||||
|
||||
return (
|
||||
<li key={goal.id} className="py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-2">
|
||||
<Flag size={16} className="text-text-secondary" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-text-primary">{goal.name}</span>
|
||||
<span className="shrink-0 text-sm tabular-nums text-text-primary">
|
||||
{formatCurrency(current)} <span className="text-text-muted">/ {formatCurrency(target)}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("goal.contributeLabel")}
|
||||
onClick={() => onContribute(goal)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-success"
|
||||
>
|
||||
<PiggyBank size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => onEdit(goal)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => onDelete(goal)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 pl-12">
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<span
|
||||
className="block h-full rounded-full bg-success"
|
||||
style={{ width: `${fillPercent}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-text-muted">
|
||||
{goal.percent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 pl-12 text-xs" style={{ color: goal.is_reached ? "var(--success)" : "var(--text-muted)" }}>
|
||||
{goal.is_reached
|
||||
? t("goal.reached")
|
||||
: goal.target_date
|
||||
? t("goal.remainingWithDate", { amount: formatCurrency(remaining), date: formatTargetDate(goal.target_date) })
|
||||
: t("goal.remaining", { amount: formatCurrency(remaining) })}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/insights/AlertBanner.tsx
Normal file
57
frontend/src/components/insights/AlertBanner.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { useFinancialAlerts } from "@/hooks/useInsights";
|
||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||
import type { FinancialAlert } from "@/types";
|
||||
|
||||
// Maps the backend's alert key to the translation template that consumes its params.
|
||||
const ALERT_MESSAGE_KEYS: Record<string, TranslationKey> = {
|
||||
negative_cash_flow_streak: "insights.negativeCashFlow",
|
||||
net_worth_decline_streak: "insights.netWorthDecline",
|
||||
budget_exceeded: "insights.budgetExceeded",
|
||||
risky_allocation_exceeded: "insights.riskyAllocationExceeded",
|
||||
};
|
||||
|
||||
function alertMessage(alert: FinancialAlert, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||
// "1 categories"/"в 1 категориях" is bad grammar in both languages —
|
||||
// singular gets its own pre-written key instead of trying to interpolate
|
||||
// a plural-aware word form through a plain {{count}} template.
|
||||
if (alert.key === "budget_exceeded" && alert.params.count === 1) {
|
||||
return t("insights.budgetExceededOne");
|
||||
}
|
||||
const messageKey = ALERT_MESSAGE_KEYS[alert.key];
|
||||
if (!messageKey) return alert.key;
|
||||
return t(messageKey, alert.params);
|
||||
}
|
||||
|
||||
interface AlertBannerProps {
|
||||
/** Alert keys to hide on this particular page — e.g. Dashboard omits
|
||||
* risky_allocation_exceeded because it reads as too alarming next to the
|
||||
* stat cards there; it still shows on Net Worth/Budget where the rest of
|
||||
* this banner is used unfiltered. */
|
||||
excludeKeys?: string[];
|
||||
}
|
||||
|
||||
/** Proactive early warnings — sustained negative cash flow or a sustained
|
||||
* net-worth decline — computed server-side from data that already exists,
|
||||
* no configuration. Renders nothing when there's nothing to flag. */
|
||||
export function AlertBanner({ excludeKeys }: AlertBannerProps = {}) {
|
||||
const { data } = useFinancialAlerts();
|
||||
const { t } = useTranslation();
|
||||
const alerts = (data?.alerts ?? []).filter((alert) => !excludeKeys?.includes(alert.key));
|
||||
|
||||
if (alerts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.key}
|
||||
className="flex items-start gap-2.5 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger"
|
||||
>
|
||||
<TriangleAlert size={16} className="mt-0.5 shrink-0" />
|
||||
<span>{alertMessage(alert, t)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/layout/Logo.tsx
Normal file
38
frontend/src/components/layout/Logo.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Aurum's mark: a coin with an "A" monogram. Rendered as inline SVG (not the
|
||||
* 🜚 alchemical-gold emoji this replaced) because emoji glyph coverage for
|
||||
* that character is inconsistent across platforms/fonts and rendered
|
||||
* mangled for the user — an SVG always looks the same everywhere. Kept in
|
||||
* sync with public/favicon.svg.
|
||||
*/
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Logo({ size = 24, className }: LogoProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 32 32" className={className} aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="aurumCoin" x1="5" y1="4" x2="27" y2="28" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#f7d488" />
|
||||
<stop offset="0.55" stopColor="#eda100" />
|
||||
<stop offset="1" stopColor="#a8690a" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="16" cy="16" r="14.5" fill="url(#aurumCoin)" stroke="#7a4e08" strokeWidth="1.2" />
|
||||
<circle cx="16" cy="16" r="11.5" fill="none" stroke="#7a4e08" strokeWidth="0.6" opacity="0.45" />
|
||||
<text
|
||||
x="16"
|
||||
y="21.5"
|
||||
fontFamily="Georgia, 'Times New Roman', serif"
|
||||
fontSize="15"
|
||||
fontWeight="700"
|
||||
textAnchor="middle"
|
||||
fill="#3a2405"
|
||||
>
|
||||
A
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/layout/MonthSelector.tsx
Normal file
37
frontend/src/components/layout/MonthSelector.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getMonthLabels } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MonthSelectorProps {
|
||||
month: number; // 1-12
|
||||
onChange: (month: number) => void;
|
||||
}
|
||||
|
||||
export function MonthSelector({ month, onChange }: MonthSelectorProps) {
|
||||
const { language } = useTranslation();
|
||||
const monthLabels = getMonthLabels(language);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{monthLabels.map((label, index) => {
|
||||
const value = index + 1;
|
||||
const active = value === month;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => onChange(value)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-series-6 bg-series-6 text-white"
|
||||
: "border-border bg-surface-1 text-text-secondary hover:bg-surface-2"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/layout/PillSelector.tsx
Normal file
35
frontend/src/components/layout/PillSelector.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PillOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PillSelectorProps<T extends string> {
|
||||
options: Array<PillOption<T>>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
export function PillSelector<T extends string>({ options, value, onChange }: PillSelectorProps<T>) {
|
||||
return (
|
||||
<div className="inline-flex gap-1 rounded-lg border border-border bg-surface-1 p-1">
|
||||
{options.map((option) => {
|
||||
const active = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active ? "bg-surface-2 text-text-primary" : "text-text-muted hover:text-text-primary"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/layout/RangeSelector.tsx
Normal file
21
frontend/src/components/layout/RangeSelector.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PillSelector } from "@/components/layout/PillSelector";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { NetWorthRange } from "@/types";
|
||||
|
||||
interface RangeSelectorProps {
|
||||
value: NetWorthRange;
|
||||
onChange: (value: NetWorthRange) => void;
|
||||
}
|
||||
|
||||
export function RangeSelector({ value, onChange }: RangeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const ranges: Array<{ value: NetWorthRange; label: string }> = [
|
||||
{ value: "30d", label: "30D" },
|
||||
{ value: "90d", label: "90D" },
|
||||
{ value: "1y", label: "1Y" },
|
||||
{ value: "5y", label: "5Y" },
|
||||
{ value: "all", label: t("common.allShort") },
|
||||
];
|
||||
|
||||
return <PillSelector options={ranges} value={value} onChange={onChange} />;
|
||||
}
|
||||
142
frontend/src/components/layout/Sidebar.tsx
Normal file
142
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { PanelLeftClose, PanelLeftOpen, X } from "lucide-react";
|
||||
import { Logo } from "@/components/layout/Logo";
|
||||
import { NAV_ITEMS } from "@/lib/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
interface NavListProps {
|
||||
collapsed: boolean;
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
function NavList({ collapsed, onNavigate }: NavListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2.5 py-2">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const label = t(item.labelKey);
|
||||
if (item.disabled) {
|
||||
return (
|
||||
<span
|
||||
key={item.to}
|
||||
title={collapsed ? `${label} (${t("nav.comingSoon")})` : undefined}
|
||||
className={cn(
|
||||
"flex cursor-not-allowed items-center gap-3 rounded-lg px-2.5 py-2 text-sm text-text-muted",
|
||||
collapsed && "justify-center px-0"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} className="shrink-0" />
|
||||
{!collapsed && (
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="shrink-0 rounded bg-surface-2 px-1 py-0.5 text-[10px] leading-none">
|
||||
{t("nav.comingSoon")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
onClick={onNavigate}
|
||||
title={collapsed ? label : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm font-medium text-text-secondary transition-colors hover:bg-surface-2 hover:text-text-primary",
|
||||
collapsed && "justify-center px-0",
|
||||
isActive && "bg-surface-2 text-text-primary"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={18} className="shrink-0" />
|
||||
{!collapsed && <span className="truncate">{label}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
mobileOpen: boolean;
|
||||
onCloseMobile: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed, onToggleCollapsed, mobileOpen, onCloseMobile }: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onCloseMobile();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [mobileOpen, onCloseMobile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: persistent rail, collapsible between icon-only and full width. */}
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 hidden h-screen shrink-0 flex-col border-r border-border bg-surface-1 transition-[width] duration-150 lg:flex",
|
||||
collapsed ? "w-[72px]" : "w-56"
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center gap-2 px-4 py-4", collapsed && "justify-center px-0")}>
|
||||
<Logo size={24} />
|
||||
{!collapsed && <span className="text-lg font-semibold tracking-tight text-text-primary">Aurum</span>}
|
||||
</div>
|
||||
<NavList collapsed={collapsed} />
|
||||
<div className="border-t border-border p-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
title={collapsed ? t("sidebar.expandMenu") : t("sidebar.collapseMenu")}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-sm text-text-muted hover:bg-surface-2 hover:text-text-primary",
|
||||
collapsed && "justify-center px-0"
|
||||
)}
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
|
||||
{!collapsed && <span>{t("sidebar.collapse")}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile: off-canvas drawer over a backdrop. */}
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onCloseMobile} />
|
||||
<aside className="absolute inset-y-0 left-0 flex w-64 flex-col bg-surface-1 shadow-xl">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-4">
|
||||
<span className="flex items-center gap-2 text-lg font-semibold tracking-tight text-text-primary">
|
||||
<Logo size={24} /> Aurum
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCloseMobile}
|
||||
aria-label={t("sidebar.closeMenu")}
|
||||
className="rounded-md p-1 text-text-muted hover:bg-surface-2"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<NavList collapsed={false} onNavigate={onCloseMobile} />
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/layout/Topbar.tsx
Normal file
28
frontend/src/components/layout/Topbar.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Menu } from "lucide-react";
|
||||
import { NAV_ITEMS } from "@/lib/navigation";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
interface TopbarProps {
|
||||
onOpenMobileNav: () => void;
|
||||
}
|
||||
|
||||
export function Topbar({ onOpenMobileNav }: TopbarProps) {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const activeItem = NAV_ITEMS.find((item) => (item.to === "/" ? location.pathname === "/" : location.pathname.startsWith(item.to)));
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex items-center gap-3 border-b border-border bg-surface-0/95 px-4 py-3.5 backdrop-blur sm:px-6 lg:px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMobileNav}
|
||||
aria-label={t("topbar.openMenu")}
|
||||
className="rounded-md p-1.5 text-text-secondary hover:bg-surface-2 lg:hidden"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold text-text-primary">{activeItem ? t(activeItem.labelKey) : "Aurum"}</h1>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
96
frontend/src/components/networth/AssetAllocationCard.tsx
Normal file
96
frontend/src/components/networth/AssetAllocationCard.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { getCategoryIcon } from "@/lib/icons";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||
import type { NetWorthBreakdownItem } from "@/types";
|
||||
|
||||
interface AssetAllocationCardProps {
|
||||
breakdown: NetWorthBreakdownItem[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// Breakdown items come from the backend with a machine-readable `key`
|
||||
// ("cash" or an AssetClass value) — translate the label locally instead of
|
||||
// showing the backend's (Russian-only) pre-rendered `name`.
|
||||
function breakdownLabelKey(key: string): TranslationKey {
|
||||
return `netWorth.assetClass.${key}` as TranslationKey;
|
||||
}
|
||||
|
||||
export function AssetAllocationCard({ breakdown, isLoading }: AssetAllocationCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const total = breakdown.reduce((sum, item) => sum + Number(item.amount), 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("netWorth.assetsTitlePrefix")} · {formatCurrency(total)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-surface-2">
|
||||
{breakdown.map((item) =>
|
||||
item.percent > 0 ? (
|
||||
<div
|
||||
key={item.key}
|
||||
style={{ width: `${item.percent}%`, backgroundColor: item.color }}
|
||||
className="h-full first:rounded-l-full last:rounded-r-full"
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-text-secondary">
|
||||
{breakdown.map((item) => (
|
||||
<li key={item.key} className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
{t(breakdownLabelKey(item.key))} {item.percent.toFixed(0)}%
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ul className="mt-4 divide-y divide-gridline border-t border-gridline">
|
||||
{breakdown.map((item) => {
|
||||
const Icon = getCategoryIcon(item.icon);
|
||||
return (
|
||||
<li key={item.key} className="flex items-center gap-3 py-2.5 first:pt-3">
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: `${item.color}26` }}
|
||||
>
|
||||
<Icon size={15} style={{ color: item.color }} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
||||
{t(breakdownLabelKey(item.key))}
|
||||
</span>
|
||||
<span className="hidden w-24 shrink-0 items-center gap-2 sm:flex">
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{ width: `${item.percent}%`, backgroundColor: item.color }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-9 shrink-0 text-right text-xs text-text-muted tabular-nums">
|
||||
{item.percent.toFixed(0)}%
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-14 shrink-0 text-right text-xs text-text-muted tabular-nums sm:hidden">
|
||||
{item.percent.toFixed(0)}%
|
||||
</span>
|
||||
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
|
||||
{formatCurrency(item.amount)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
229
frontend/src/components/networth/AssetFormModal.tsx
Normal file
229
frontend/src/components/networth/AssetFormModal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Label, Select } from "@/components/ui/Input";
|
||||
import { useAddAssetValuation, useCreateAsset, useUpdateAsset } from "@/hooks/useAssets";
|
||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||
import type { Asset, AssetClass, CapitalRole, RiskLevel } from "@/types";
|
||||
|
||||
interface AssetFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
asset?: Asset | null;
|
||||
}
|
||||
|
||||
const ASSET_CLASSES: AssetClass[] = ["investments", "crypto", "real_estate", "vehicles", "precious_metals", "other"];
|
||||
const CAPITAL_ROLES: CapitalRole[] = ["income", "neutral", "drain"];
|
||||
const RISK_LEVELS: RiskLevel[] = ["low", "medium", "high"];
|
||||
|
||||
function todayIso() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const EMPTY_FORM = {
|
||||
name: "",
|
||||
asset_class: "investments" as AssetClass,
|
||||
value: "",
|
||||
as_of_date: todayIso(),
|
||||
notes: "",
|
||||
capital_role: "neutral" as CapitalRole,
|
||||
monthly_cash_flow: "",
|
||||
risk_level: "medium" as RiskLevel,
|
||||
};
|
||||
|
||||
export function AssetFormModal({ open, onClose, asset }: AssetFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const createAsset = useCreateAsset();
|
||||
const updateAsset = useUpdateAsset();
|
||||
const addValuation = useAddAssetValuation();
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (asset) {
|
||||
setForm({
|
||||
name: asset.name,
|
||||
asset_class: asset.asset_class,
|
||||
value: asset.current_value,
|
||||
as_of_date: todayIso(),
|
||||
notes: asset.notes ?? "",
|
||||
capital_role: asset.capital_role,
|
||||
monthly_cash_flow: asset.monthly_cash_flow ?? "",
|
||||
risk_level: asset.risk_level,
|
||||
});
|
||||
} else {
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
setError(null);
|
||||
}, [open, asset]);
|
||||
|
||||
const isSaving = createAsset.isPending || updateAsset.isPending || addValuation.isPending;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (asset) {
|
||||
await updateAsset.mutateAsync({
|
||||
id: asset.id,
|
||||
input: {
|
||||
name: form.name,
|
||||
asset_class: form.asset_class,
|
||||
notes: form.notes || null,
|
||||
capital_role: form.capital_role,
|
||||
monthly_cash_flow: form.monthly_cash_flow || null,
|
||||
risk_level: form.risk_level,
|
||||
},
|
||||
});
|
||||
if (form.value !== asset.current_value) {
|
||||
await addValuation.mutateAsync({ id: asset.id, input: { value: form.value, as_of_date: form.as_of_date } });
|
||||
}
|
||||
} else {
|
||||
await createAsset.mutateAsync({
|
||||
name: form.name,
|
||||
asset_class: form.asset_class,
|
||||
value: form.value,
|
||||
as_of_date: form.as_of_date,
|
||||
notes: form.notes || null,
|
||||
capital_role: form.capital_role,
|
||||
monthly_cash_flow: form.monthly_cash_flow || null,
|
||||
risk_level: form.risk_level,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t("netWorth.form.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title={asset ? t("netWorth.form.editTitle") : t("netWorth.form.newTitle")}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="asset-name">{t("netWorth.form.nameLabel")}</Label>
|
||||
<Input
|
||||
id="asset-name"
|
||||
required
|
||||
placeholder={t("netWorth.form.namePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="asset-class">{t("netWorth.form.classLabel")}</Label>
|
||||
<Select
|
||||
id="asset-class"
|
||||
value={form.asset_class}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, asset_class: event.target.value as AssetClass }))}
|
||||
>
|
||||
{ASSET_CLASSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`netWorth.assetClass.${value}` as TranslationKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="asset-value">
|
||||
{asset ? t("netWorth.form.currentValueLabel") : t("netWorth.form.valueLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="asset-value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
value={form.value}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, value: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="asset-date">{t("netWorth.form.dateLabel")}</Label>
|
||||
<Input
|
||||
id="asset-date"
|
||||
type="date"
|
||||
required
|
||||
value={form.as_of_date}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, as_of_date: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="asset-role">{t("netWorth.form.roleLabel")}</Label>
|
||||
<Select
|
||||
id="asset-role"
|
||||
value={form.capital_role}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, capital_role: event.target.value as CapitalRole }))}
|
||||
>
|
||||
{CAPITAL_ROLES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`netWorth.capitalRole.${value}` as TranslationKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{t(`netWorth.capitalRoleFormHint.${form.capital_role}` as TranslationKey)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="asset-risk">{t("netWorth.form.riskLevelLabel")}</Label>
|
||||
<Select
|
||||
id="asset-risk"
|
||||
value={form.risk_level}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, risk_level: event.target.value as RiskLevel }))}
|
||||
>
|
||||
{RISK_LEVELS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`netWorth.riskLevel.${value}` as TranslationKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{t(`netWorth.riskLevelFormHint.${form.risk_level}` as TranslationKey)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="asset-cash-flow">{t("netWorth.form.cashFlowLabel")}</Label>
|
||||
<Input
|
||||
id="asset-cash-flow"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder={t("netWorth.form.cashFlowPlaceholder")}
|
||||
value={form.monthly_cash_flow}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, monthly_cash_flow: event.target.value }))}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-muted">{t("netWorth.form.cashFlowHint")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="asset-notes">{t("netWorth.form.notesLabel")}</Label>
|
||||
<Input
|
||||
id="asset-notes"
|
||||
value={form.notes}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, notes: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/networth/AssetsTable.tsx
Normal file
101
frontend/src/components/networth/AssetsTable.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { getCategoryIcon } from "@/lib/icons";
|
||||
import { formatCurrency, formatTransactionDate } from "@/lib/format";
|
||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||
import type { Asset, CapitalRole } from "@/types";
|
||||
|
||||
const CLASS_ICONS: Record<Asset["asset_class"], string> = {
|
||||
investments: "trending-up",
|
||||
crypto: "bitcoin",
|
||||
real_estate: "building-2",
|
||||
vehicles: "car",
|
||||
precious_metals: "gem",
|
||||
other: "package",
|
||||
};
|
||||
|
||||
const ROLE_COLORS: Record<CapitalRole, string> = {
|
||||
income: "var(--success)",
|
||||
neutral: "var(--text-muted)",
|
||||
drain: "var(--danger)",
|
||||
};
|
||||
|
||||
interface AssetsTableProps {
|
||||
items: Asset[];
|
||||
onEdit: (asset: Asset) => void;
|
||||
onDelete: (asset: Asset) => void;
|
||||
}
|
||||
|
||||
export function AssetsTable({ items, onEdit, onDelete }: AssetsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-text-muted">{t("netWorth.assetsTable.empty")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-gridline">
|
||||
{items.map((asset) => {
|
||||
const Icon = getCategoryIcon(CLASS_ICONS[asset.asset_class]);
|
||||
const roleColor = ROLE_COLORS[asset.capital_role];
|
||||
const cashFlow = asset.monthly_cash_flow !== null ? Number(asset.monthly_cash_flow) : null;
|
||||
const currentValue = Number(asset.current_value);
|
||||
// Annual ROI only makes sense for money coming in against what was
|
||||
// paid for the asset — same formula as RoiCalculatorCard.
|
||||
const annualRoiPercent = cashFlow !== null && cashFlow > 0 && currentValue > 0 ? (cashFlow * 12) / currentValue * 100 : null;
|
||||
|
||||
return (
|
||||
<li key={asset.id} className="flex items-center gap-3 py-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-2">
|
||||
<Icon size={16} className="text-text-secondary" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-text-primary">{asset.name}</span>
|
||||
<span className="flex items-center gap-1 truncate text-xs text-text-muted">
|
||||
{t(`netWorth.assetClass.${asset.asset_class}` as TranslationKey)} ·{" "}
|
||||
{t("netWorth.assetsTable.asOf", { date: formatTransactionDate(asset.as_of_date) })}
|
||||
<span className="inline-flex items-center gap-1" style={{ color: roleColor }}>
|
||||
· <span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: roleColor }} />
|
||||
{t(`netWorth.capitalRole.${asset.capital_role}` as TranslationKey)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block text-sm font-medium tabular-nums text-text-primary">
|
||||
{formatCurrency(asset.current_value)}
|
||||
</span>
|
||||
{cashFlow !== null && cashFlow !== 0 && (
|
||||
<span
|
||||
className="block text-xs tabular-nums"
|
||||
style={{ color: cashFlow > 0 ? "var(--success)" : "var(--danger)" }}
|
||||
>
|
||||
{cashFlow > 0 ? "+" : ""}
|
||||
{formatCurrency(cashFlow)}
|
||||
{t("common.perMonth")}
|
||||
{annualRoiPercent !== null && ` · ${t("netWorth.assetsTable.annualRoi", { percent: annualRoiPercent.toFixed(1) })}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => onEdit(asset)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => onDelete(asset)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
62
frontend/src/components/networth/CapitalRoleSummaryCard.tsx
Normal file
62
frontend/src/components/networth/CapitalRoleSummaryCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { formatCurrency, pluralizeRu } from "@/lib/format";
|
||||
import { useTranslation, type Language, type TranslationKey } from "@/lib/i18n";
|
||||
import type { CapitalRoleSummary } from "@/types";
|
||||
|
||||
interface CapitalRoleSummaryCardProps {
|
||||
roles: CapitalRoleSummary[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function assetsCountLabel(count: number, language: Language): string {
|
||||
if (language === "ru") return pluralizeRu(count, "актив", "актива", "активов");
|
||||
return count === 1 ? "asset" : "assets";
|
||||
}
|
||||
|
||||
export function CapitalRoleSummaryCard({ roles, isLoading }: CapitalRoleSummaryCardProps) {
|
||||
const { t, language } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("netWorth.capitalByType")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{roles.map((role) => {
|
||||
const cashFlow = Number(role.monthly_cash_flow);
|
||||
return (
|
||||
<div key={role.role} className="rounded-lg border border-border p-3.5">
|
||||
<p className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: role.color }} />
|
||||
{t(`netWorth.capitalRole.${role.role}` as TranslationKey)}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xl font-semibold tabular-nums text-text-primary">
|
||||
{formatCurrency(role.total_value)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{role.count} {assetsCountLabel(role.count, language)} ·{" "}
|
||||
{t(`netWorth.capitalRoleHint.${role.role}` as TranslationKey)}
|
||||
</p>
|
||||
{cashFlow !== 0 && (
|
||||
<p
|
||||
className="mt-1.5 text-sm font-medium tabular-nums"
|
||||
style={{ color: cashFlow > 0 ? "var(--success)" : "var(--danger)" }}
|
||||
>
|
||||
{cashFlow > 0 ? "+" : ""}
|
||||
{formatCurrency(cashFlow)}
|
||||
{t("common.perMonth")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
133
frontend/src/components/networth/NetWorthChart.tsx
Normal file
133
frontend/src/components/networth/NetWorthChart.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { RangeSelector } from "@/components/layout/RangeSelector";
|
||||
import { formatCurrency, formatSignedCurrency, getIntlLocale } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { NetWorthRange, NetWorthSummary } from "@/types";
|
||||
|
||||
interface NetWorthChartProps {
|
||||
summary: NetWorthSummary | undefined;
|
||||
isLoading: boolean;
|
||||
range: NetWorthRange;
|
||||
onRangeChange: (range: NetWorthRange) => void;
|
||||
}
|
||||
|
||||
function formatAxisDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", day: "numeric", year: "numeric" }).format(
|
||||
new Date(`${iso}T00:00:00`)
|
||||
);
|
||||
}
|
||||
|
||||
/** Jan-1 of every year strictly inside the range, so a multi-year chart gets
|
||||
* a year landmark on its axis instead of just two endpoint dates — lets you
|
||||
* place "5 years ago" without hovering pixel-by-pixel. Single-year ranges
|
||||
* return an empty array and the axis stays hidden, unchanged from before. */
|
||||
function computeYearTicks(dates: string[]): string[] {
|
||||
if (dates.length < 2) return [];
|
||||
const start = dates[0];
|
||||
const end = dates[dates.length - 1];
|
||||
const startYear = Number(start.slice(0, 4));
|
||||
const endYear = Number(end.slice(0, 4));
|
||||
if (startYear === endYear) return [];
|
||||
|
||||
const ticks: string[] = [];
|
||||
for (let year = startYear + 1; year <= endYear; year++) {
|
||||
const jan1 = `${year}-01-01`;
|
||||
if (jan1 >= start && jan1 <= end) ticks.push(jan1);
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { date: string; value: number } }> }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const point = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
|
||||
<p className="text-text-muted">{formatAxisDate(point.date)}</p>
|
||||
<p className="font-medium text-text-primary">{formatCurrency(point.value)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NetWorthChart({ summary, isLoading, range, onRangeChange }: NetWorthChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const isPositive = summary ? Number(summary.change_amount) >= 0 : true;
|
||||
const trendColor = isPositive ? "var(--success)" : "var(--danger)";
|
||||
const chartData = summary?.series.map((point) => ({ date: point.date, value: Number(point.value) })) ?? [];
|
||||
const yearTicks = computeYearTicks(chartData.map((point) => point.date));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="items-start">
|
||||
<div>
|
||||
<CardTitle>{t("nav.netWorth")}</CardTitle>
|
||||
<p className="mt-1.5 text-2xl font-semibold tabular-nums text-text-primary sm:text-[28px]">
|
||||
{isLoading ? "…" : formatCurrency(summary?.current ?? 0)}
|
||||
</p>
|
||||
{summary && (
|
||||
<p
|
||||
className="mt-1 flex items-center gap-1 text-sm font-medium"
|
||||
style={{ color: trendColor }}
|
||||
>
|
||||
{isPositive ? <ArrowUp size={14} /> : <ArrowDown size={14} />}
|
||||
{formatSignedCurrency(summary.change_amount)}
|
||||
{summary.change_percent !== null && ` (${isPositive ? "+" : ""}${summary.change_percent.toFixed(1)}%)`}
|
||||
<span className="font-normal text-text-muted">{t("netWorth.periodSuffix")}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<RangeSelector value={range} onChange={onRangeChange} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-56 w-full sm:h-64">
|
||||
{isLoading || chartData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
{isLoading ? t("common.loading") : t("netWorth.noChartData")}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 8, right: 4, bottom: 0, left: 4 }}>
|
||||
<defs>
|
||||
<linearGradient id="netWorthFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={trendColor} stopOpacity={0.18} />
|
||||
<stop offset="100%" stopColor={trendColor} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<YAxis hide domain={["auto", "auto"]} />
|
||||
{yearTicks.length > 0 && (
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
type="category"
|
||||
ticks={yearTicks}
|
||||
tickFormatter={(value: string) => value.slice(0, 4)}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "var(--text-muted)", fontSize: 11 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={trendColor}
|
||||
strokeWidth={2}
|
||||
fill="url(#netWorthFill)"
|
||||
isAnimationActive={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
{chartData.length > 1 && (
|
||||
<div className="mt-2 flex justify-between text-xs text-text-muted">
|
||||
<span>{formatAxisDate(chartData[0].date)}</span>
|
||||
<span>{formatAxisDate(chartData[chartData.length - 1].date)}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
62
frontend/src/components/networth/RiskAllocationCard.tsx
Normal file
62
frontend/src/components/networth/RiskAllocationCard.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||
import type { RiskLevelSummary } from "@/types";
|
||||
|
||||
interface RiskAllocationCardProps {
|
||||
riskLevels: RiskLevelSummary[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function RiskAllocationCard({ riskLevels, isLoading }: RiskAllocationCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("netWorth.riskAllocationTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{riskLevels.map((tier) => (
|
||||
<div key={tier.risk_level} className="rounded-lg border border-border p-3.5">
|
||||
<p className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: tier.color }} />
|
||||
{t(`netWorth.riskLevel.${tier.risk_level}` as TranslationKey)}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xl font-semibold tabular-nums text-text-primary">
|
||||
{formatCurrency(tier.total_value)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">{tier.percent.toFixed(0)}% {t("netWorth.riskAllocationOfCapital")}</p>
|
||||
|
||||
{tier.items.length > 0 && (
|
||||
<ul className="mt-3 space-y-1.5 border-t border-gridline pt-3">
|
||||
{tier.items.map((item) => (
|
||||
<li key={item.key} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-text-secondary">
|
||||
{item.key === "cash" ? t("netWorth.assetClass.cash") : item.name}
|
||||
</span>
|
||||
<span className="h-1 w-10 shrink-0 overflow-hidden rounded-full bg-surface-2">
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{ width: `${item.percent}%`, backgroundColor: tier.color }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-8 shrink-0 text-right text-[11px] tabular-nums text-text-muted">
|
||||
{item.percent.toFixed(0)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
263
frontend/src/components/recurring/RecurringFormModal.tsx
Normal file
263
frontend/src/components/recurring/RecurringFormModal.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Label, Select } from "@/components/ui/Input";
|
||||
import { useAccounts } from "@/hooks/useAccounts";
|
||||
import { useCategories } from "@/hooks/useCategories";
|
||||
import { useCreateRecurring, useUpdateRecurring } from "@/hooks/useRecurring";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { RecurringFrequency, RecurringTransaction, TransactionType } from "@/types";
|
||||
|
||||
interface RecurringFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
recurring?: RecurringTransaction | null;
|
||||
}
|
||||
|
||||
function todayIso() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const EMPTY_FORM = {
|
||||
type: "expense" as TransactionType,
|
||||
account_id: "",
|
||||
category_id: "",
|
||||
transfer_account_id: "",
|
||||
amount: "",
|
||||
description: "",
|
||||
merchant: "",
|
||||
frequency: "monthly" as RecurringFrequency,
|
||||
anchor_date: todayIso(),
|
||||
};
|
||||
|
||||
export function RecurringFormModal({ open, onClose, recurring }: RecurringFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: accounts } = useAccounts();
|
||||
const { data: categories } = useCategories();
|
||||
const createRecurring = useCreateRecurring();
|
||||
const updateRecurring = useUpdateRecurring();
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (recurring) {
|
||||
setForm({
|
||||
type: recurring.type,
|
||||
account_id: String(recurring.account_id),
|
||||
category_id: recurring.category_id ? String(recurring.category_id) : "",
|
||||
transfer_account_id: recurring.transfer_account_id ? String(recurring.transfer_account_id) : "",
|
||||
amount: recurring.amount,
|
||||
description: recurring.description,
|
||||
merchant: recurring.merchant ?? "",
|
||||
frequency: recurring.frequency,
|
||||
anchor_date: recurring.anchor_date,
|
||||
});
|
||||
} else {
|
||||
setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" });
|
||||
}
|
||||
setError(null);
|
||||
}, [open, recurring, accounts]);
|
||||
|
||||
const relevantCategories = (categories ?? []).filter((category) =>
|
||||
form.type === "income" ? category.kind === "income" : category.kind === "expense"
|
||||
);
|
||||
|
||||
const isSaving = createRecurring.isPending || updateRecurring.isPending;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!form.account_id) {
|
||||
setError(t("transactions.form.errorSelectAccount"));
|
||||
return;
|
||||
}
|
||||
if (form.type === "transfer" && !form.transfer_account_id) {
|
||||
setError(t("transactions.form.errorSelectDestination"));
|
||||
return;
|
||||
}
|
||||
if (form.type === "transfer" && form.transfer_account_id === form.account_id) {
|
||||
setError(t("transactions.form.errorSameAccount"));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: form.type,
|
||||
account_id: Number(form.account_id),
|
||||
category_id: form.type === "transfer" ? null : form.category_id ? Number(form.category_id) : null,
|
||||
transfer_account_id: form.type === "transfer" ? Number(form.transfer_account_id) : null,
|
||||
amount: form.amount,
|
||||
description: form.description,
|
||||
merchant: form.merchant || null,
|
||||
frequency: form.frequency,
|
||||
anchor_date: form.anchor_date,
|
||||
};
|
||||
|
||||
try {
|
||||
if (recurring) {
|
||||
await updateRecurring.mutateAsync({ id: recurring.id, input: payload });
|
||||
} else {
|
||||
await createRecurring.mutateAsync(payload);
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t("recurring.form.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={recurring ? t("recurring.form.editTitle") : t("recurring.form.newTitle")}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="recurring-type">{t("transactions.form.typeLabel")}</Label>
|
||||
<Select
|
||||
id="recurring-type"
|
||||
value={form.type}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, type: event.target.value as TransactionType, category_id: "" }))
|
||||
}
|
||||
>
|
||||
<option value="expense">{t("transactions.form.typeExpense")}</option>
|
||||
<option value="income">{t("transactions.form.typeIncome")}</option>
|
||||
<option value="transfer">{t("transactions.form.typeTransfer")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="recurring-frequency">{t("recurring.form.frequencyLabel")}</Label>
|
||||
<Select
|
||||
id="recurring-frequency"
|
||||
value={form.frequency}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, frequency: event.target.value as RecurringFrequency }))}
|
||||
>
|
||||
<option value="weekly">{t("recurring.frequency.weekly")}</option>
|
||||
<option value="monthly">{t("recurring.frequency.monthly")}</option>
|
||||
<option value="yearly">{t("recurring.frequency.yearly")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="recurring-amount">{t("transactions.form.amountLabel")}</Label>
|
||||
<Input
|
||||
id="recurring-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
value={form.amount}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, amount: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="recurring-anchor-date">{t("recurring.form.anchorDateLabel")}</Label>
|
||||
<Input
|
||||
id="recurring-anchor-date"
|
||||
type="date"
|
||||
required
|
||||
value={form.anchor_date}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, anchor_date: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="recurring-description">{t("transactions.form.descriptionLabel")}</Label>
|
||||
<Input
|
||||
id="recurring-description"
|
||||
required
|
||||
placeholder={t("transactions.form.descriptionPlaceholder")}
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="recurring-account">{t("transactions.form.accountLabel")}</Label>
|
||||
<Select
|
||||
id="recurring-account"
|
||||
required
|
||||
value={form.account_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, account_id: event.target.value }))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("transactions.form.selectAccount")}
|
||||
</option>
|
||||
{accounts?.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.type === "transfer" ? (
|
||||
<div>
|
||||
<Label htmlFor="recurring-transfer-account">{t("transactions.form.transferAccountLabel")}</Label>
|
||||
<Select
|
||||
id="recurring-transfer-account"
|
||||
required
|
||||
value={form.transfer_account_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, transfer_account_id: event.target.value }))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("transactions.form.selectAccount")}
|
||||
</option>
|
||||
{accounts
|
||||
?.filter((account) => String(account.id) !== form.account_id)
|
||||
.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="recurring-category">{t("transactions.form.categoryLabel")}</Label>
|
||||
<Select
|
||||
id="recurring-category"
|
||||
value={form.category_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, category_id: event.target.value }))}
|
||||
>
|
||||
<option value="">{t("transactions.form.noCategory")}</option>
|
||||
{relevantCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="recurring-merchant">{t("transactions.form.merchantLabel")}</Label>
|
||||
<Input
|
||||
id="recurring-merchant"
|
||||
value={form.merchant}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, merchant: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/recurring/RecurringList.tsx
Normal file
103
frontend/src/components/recurring/RecurringList.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { ArrowLeftRight, CalendarCheck, Pencil, Trash2 } from "lucide-react";
|
||||
import { getCategoryIcon } from "@/lib/icons";
|
||||
import { formatCurrency, getIntlLocale } from "@/lib/format";
|
||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { RecurringTransaction } from "@/types";
|
||||
|
||||
interface RecurringListProps {
|
||||
items: RecurringTransaction[];
|
||||
onPost: (item: RecurringTransaction) => void;
|
||||
onEdit: (item: RecurringTransaction) => void;
|
||||
onDelete: (item: RecurringTransaction) => void;
|
||||
isPosting: boolean;
|
||||
}
|
||||
|
||||
function formatDueDate(isoDate: string): string {
|
||||
return new Intl.DateTimeFormat(getIntlLocale(), { day: "numeric", month: "short", year: "numeric" }).format(
|
||||
new Date(`${isoDate}T00:00:00`)
|
||||
);
|
||||
}
|
||||
|
||||
export function RecurringList({ items, onPost, onEdit, onDelete, isPosting }: RecurringListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-text-muted">{t("recurring.empty")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-gridline">
|
||||
{items.map((item) => {
|
||||
const isTransfer = item.type === "transfer";
|
||||
const isExpense = item.type === "expense";
|
||||
const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(item.category_icon);
|
||||
const color = isTransfer ? "var(--text-muted)" : item.category_color ?? "var(--text-muted)";
|
||||
|
||||
return (
|
||||
<li key={item.id} className="flex items-center gap-3 py-3">
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: color.startsWith("#") ? `${color}26` : "var(--surface-2)" }}
|
||||
>
|
||||
<Icon size={16} style={{ color }} />
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-text-primary">{item.description}</span>
|
||||
<span className="flex flex-wrap items-center gap-x-1 truncate text-xs text-text-muted">
|
||||
<span>{t(`recurring.frequency.${item.frequency}` as TranslationKey)}</span>
|
||||
{item.category_name && <span>· {translateCategoryName(item.category_name)}</span>}
|
||||
<span style={{ color: item.is_due ? "var(--danger)" : "var(--text-muted)" }}>
|
||||
·{" "}
|
||||
{item.is_due
|
||||
? item.days_until_due < 0
|
||||
? t("recurring.overdueDays", { days: Math.abs(item.days_until_due) })
|
||||
: t("recurring.dueToday")
|
||||
: t("recurring.dueInDays", { days: item.days_until_due, date: formatDueDate(item.next_due_date) })}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={`shrink-0 text-sm font-medium tabular-nums ${
|
||||
isTransfer ? "text-text-muted" : isExpense ? "text-text-primary" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{isTransfer ? "" : isExpense ? "-" : "+"}
|
||||
{formatCurrency(item.amount)}
|
||||
</span>
|
||||
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("recurring.postLabel")}
|
||||
disabled={isPosting}
|
||||
onClick={() => onPost(item)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-success disabled:opacity-50"
|
||||
>
|
||||
<CalendarCheck size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => onEdit(item)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => onDelete(item)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/reports/CategoryRankingCard.tsx
Normal file
80
frontend/src/components/reports/CategoryRankingCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { getCategoryIcon } from "@/lib/icons";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { CategoryRankingItem } from "@/types";
|
||||
|
||||
interface CategoryRankingCardProps {
|
||||
items: CategoryRankingItem[];
|
||||
isLoading: boolean;
|
||||
selectedCategoryId: number | null;
|
||||
onSelectCategory: (categoryId: number) => void;
|
||||
}
|
||||
|
||||
/** Ranks every expense category by total spent over the currently selected
|
||||
* period — unlike the Dashboard breakdown (locked to one month) or the
|
||||
* detail chart below (locked to one category), this is "which category
|
||||
* costs the most" across the whole range at once. Clicking a row drills
|
||||
* into that category in the detail chart/transaction list below. */
|
||||
export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSelectCategory }: CategoryRankingCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("reports.categoryRankingTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("reports.noRankingData")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gridline">
|
||||
{items.map((item, index) => {
|
||||
const Icon = getCategoryIcon(item.icon);
|
||||
const isSelected = item.category_id === selectedCategoryId;
|
||||
return (
|
||||
<li key={item.category_id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectCategory(item.category_id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg py-2.5 text-left transition-colors hover:bg-surface-2 ${
|
||||
isSelected ? "bg-surface-2" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 shrink-0 text-right text-xs tabular-nums text-text-muted">{index + 1}</span>
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: `${item.color}26` }}
|
||||
>
|
||||
<Icon size={15} style={{ color: item.color }} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
||||
{translateCategoryName(item.name)}
|
||||
</span>
|
||||
<span className="hidden w-24 shrink-0 items-center gap-2 sm:flex">
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{ width: `${item.percent}%`, backgroundColor: item.color }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-9 shrink-0 text-right text-xs text-text-muted tabular-nums">
|
||||
{item.percent.toFixed(0)}%
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 pr-1 text-sm font-medium tabular-nums text-text-primary">
|
||||
{formatCurrency(item.amount)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
114
frontend/src/components/reports/CategorySpendingChart.tsx
Normal file
114
frontend/src/components/reports/CategorySpendingChart.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { formatCurrency, getIntlLocale, pluralizeRu } from "@/lib/format";
|
||||
import { useTranslation, type Language } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { CategorySpendingReport } from "@/types";
|
||||
|
||||
interface CategorySpendingChartProps {
|
||||
report: CategorySpendingReport | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function monthKey(year: number, month: number): string {
|
||||
return `${year}-${String(month).padStart(2, "0")}-01`;
|
||||
}
|
||||
|
||||
function formatMonthLabel(key: string): string {
|
||||
return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", year: "numeric" }).format(
|
||||
new Date(`${key}T00:00:00`)
|
||||
);
|
||||
}
|
||||
|
||||
function transactionsCountLabel(count: number, language: Language): string {
|
||||
if (language === "ru") return pluralizeRu(count, "транзакция", "транзакции", "транзакций");
|
||||
return count === 1 ? "transaction" : "transactions";
|
||||
}
|
||||
|
||||
/** Same landmark idea as the Net Worth chart: year ticks only when the bars
|
||||
* span more than one calendar year, so a 5-year report reads at a glance. */
|
||||
function computeYearTicks(keys: string[]): string[] {
|
||||
if (keys.length < 2) return [];
|
||||
const startYear = Number(keys[0].slice(0, 4));
|
||||
const endYear = Number(keys[keys.length - 1].slice(0, 4));
|
||||
if (startYear === endYear) return [];
|
||||
const ticks: string[] = [];
|
||||
for (let year = startYear + 1; year <= endYear; year++) {
|
||||
const jan = `${year}-01-01`;
|
||||
if (keys.includes(jan)) ticks.push(jan);
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { key: string; amount: number } }> }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const point = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
|
||||
<p className="text-text-muted">{formatMonthLabel(point.key)}</p>
|
||||
<p className="font-medium text-text-primary">{formatCurrency(point.amount)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategorySpendingChart({ report, isLoading }: CategorySpendingChartProps) {
|
||||
const { t, language } = useTranslation();
|
||||
const chartData = report?.series.map((point) => ({
|
||||
key: monthKey(point.year, point.month),
|
||||
amount: Number(point.amount),
|
||||
})) ?? [];
|
||||
const yearTicks = computeYearTicks(chartData.map((point) => point.key));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="items-start">
|
||||
<div>
|
||||
<CardTitle>{report ? translateCategoryName(report.category_name) : t("reports.defaultChartTitle")}</CardTitle>
|
||||
<p className="mt-1.5 text-2xl font-semibold tabular-nums text-text-primary sm:text-[28px]">
|
||||
{isLoading || !report ? "…" : formatCurrency(report.total_amount)}
|
||||
</p>
|
||||
{report && (
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{t("reports.averagePerMonth", { amount: formatCurrency(report.average_per_month) })} ·{" "}
|
||||
{report.transaction_count} {transactionsCountLabel(report.transaction_count, language)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-56 w-full sm:h-64">
|
||||
{isLoading || !report || chartData.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||
{isLoading ? t("common.loading") : t("reports.noDataForPeriod")}
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 4, bottom: 0, left: 4 }}>
|
||||
{yearTicks.length > 0 && (
|
||||
<XAxis
|
||||
dataKey="key"
|
||||
type="category"
|
||||
ticks={yearTicks}
|
||||
tickFormatter={(value: string) => value.slice(0, 4)}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "var(--text-muted)", fontSize: 11 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
)}
|
||||
<Tooltip content={<ChartTooltip />} cursor={{ fill: "var(--surface-2)" }} />
|
||||
<Bar dataKey="amount" fill={report.category_color} radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
{chartData.length > 1 && (
|
||||
<div className="mt-2 flex justify-between text-xs text-text-muted">
|
||||
<span>{formatMonthLabel(chartData[0].key)}</span>
|
||||
<span>{formatMonthLabel(chartData[chartData.length - 1].key)}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/roi/RoiCalculatorCard.tsx
Normal file
87
frontend/src/components/roi/RoiCalculatorCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Input, Label } from "@/components/ui/Input";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
interface RoiCalculatorCardProps {
|
||||
investment: string;
|
||||
onInvestmentChange: (value: string) => void;
|
||||
monthlyIncome: string;
|
||||
onMonthlyIncomeChange: (value: string) => void;
|
||||
annualRoiPercent: number | null;
|
||||
paybackYears: number | null;
|
||||
}
|
||||
|
||||
/** Quick "is this a good deal" check — purchase price + expected monthly
|
||||
* income in, annual return % and payback period out. Purely a scratchpad:
|
||||
* not tied to any Asset, doesn't persist, doesn't read anything else you've
|
||||
* tracked in the app — only these two numbers. Same formula AssetsTable
|
||||
* already shows inline for assets that have monthly_cash_flow set, just
|
||||
* usable before you've committed to adding one. The multi-year, compound-
|
||||
* interest projection built from these same numbers lives in
|
||||
* RoiProjectionCard, rendered alongside this one on RoiPage. */
|
||||
export function RoiCalculatorCard({
|
||||
investment,
|
||||
onInvestmentChange,
|
||||
monthlyIncome,
|
||||
onMonthlyIncomeChange,
|
||||
annualRoiPercent,
|
||||
paybackYears,
|
||||
}: RoiCalculatorCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("roi.calculator.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="roi-investment">{t("roi.calculator.investmentLabel")}</Label>
|
||||
<Input
|
||||
id="roi-investment"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="100000"
|
||||
value={investment}
|
||||
onChange={(event) => onInvestmentChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="roi-income">{t("roi.calculator.monthlyIncomeLabel")}</Label>
|
||||
<Input
|
||||
id="roi-income"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="830"
|
||||
value={monthlyIncome}
|
||||
onChange={(event) => onMonthlyIncomeChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{annualRoiPercent === null ? (
|
||||
<p className="text-xs text-text-muted">{t("roi.calculator.hint")}</p>
|
||||
) : (
|
||||
<div className="rounded-lg bg-surface-2 p-3.5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
{t("roi.calculator.annualRoiLabel")}
|
||||
</p>
|
||||
<p
|
||||
className="mt-1 text-2xl font-semibold tabular-nums"
|
||||
style={{ color: annualRoiPercent >= 0 ? "var(--success)" : "var(--danger)" }}
|
||||
>
|
||||
{annualRoiPercent.toFixed(1)}%
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{paybackYears !== null
|
||||
? t("roi.calculator.payback", { years: paybackYears.toFixed(1) })
|
||||
: t("roi.calculator.noPayback")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
162
frontend/src/components/roi/RoiProjectionCard.tsx
Normal file
162
frontend/src/components/roi/RoiProjectionCard.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
interface RoiProjectionCardProps {
|
||||
investmentAmount: number;
|
||||
annualIncome: number;
|
||||
annualRoiPercent: number;
|
||||
}
|
||||
|
||||
const CHART_MAX_YEARS = 30;
|
||||
const CHART_TICKS = [0, 5, 10, 15, 20, 25, 30];
|
||||
const TABLE_YEARS = [1, 5, 10, 20, 30];
|
||||
|
||||
interface ChartPoint {
|
||||
year: number;
|
||||
compound: number;
|
||||
simple: number;
|
||||
}
|
||||
|
||||
function ChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
compoundLabel,
|
||||
simpleLabel,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: ChartPoint }>;
|
||||
compoundLabel: string;
|
||||
simpleLabel: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const point = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
|
||||
<p className="text-text-muted">{point.year}</p>
|
||||
<p className="font-medium" style={{ color: "var(--series-1)" }}>
|
||||
{compoundLabel}: {formatCurrency(point.compound)}
|
||||
</p>
|
||||
<p className="text-text-muted">
|
||||
{simpleLabel}: {formatCurrency(point.simple)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything here is derived only from the two numbers typed into
|
||||
* RoiCalculatorCard (investment amount, monthly income) — no data from
|
||||
* Asset/AssetValuation or anywhere else in the app. Two scenarios,
|
||||
* compounded annually from the same starting point:
|
||||
* compound(year) = investment × (1 + annualRoiPercent)^year — the whole
|
||||
* annual return (rent/dividends) is assumed reinvested back into the
|
||||
* same investment every year, so next year's return is earned on a
|
||||
* larger base too.
|
||||
* simple(year) = investment + annualIncome × year — the investment
|
||||
* itself never grows, income is just set aside in cash each year.
|
||||
* The gap between the two lines *is* what compounding is worth. */
|
||||
export function RoiProjectionCard({ investmentAmount, annualIncome, annualRoiPercent }: RoiProjectionCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const rate = annualRoiPercent / 100;
|
||||
const compoundLabel = t("roi.calculator.chartCompoundLabel");
|
||||
const simpleLabel = t("roi.calculator.chartSimpleLabel");
|
||||
|
||||
const chartData: ChartPoint[] = Array.from({ length: CHART_MAX_YEARS + 1 }, (_, year) => ({
|
||||
year,
|
||||
compound: investmentAmount * Math.pow(1 + rate, year),
|
||||
simple: investmentAmount + annualIncome * year,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("roi.calculator.projectionTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<p className="text-xs text-text-muted">{t("roi.calculator.projectionHint", { percent: annualRoiPercent.toFixed(1) })}</p>
|
||||
|
||||
<div className="h-56 w-full sm:h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 4, bottom: 0, left: 4 }}>
|
||||
<XAxis
|
||||
dataKey="year"
|
||||
type="number"
|
||||
domain={[0, CHART_MAX_YEARS]}
|
||||
ticks={CHART_TICKS}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "var(--text-muted)", fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip content={<ChartTooltip compoundLabel={compoundLabel} simpleLabel={simpleLabel} />} cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={24}
|
||||
formatter={(value) => (value === "compound" ? compoundLabel : simpleLabel)}
|
||||
wrapperStyle={{ fontSize: 12, color: "var(--text-muted)" }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="compound"
|
||||
stroke="var(--series-1)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="simple"
|
||||
stroke="var(--text-muted)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">{t("roi.calculator.tableTitle")}</p>
|
||||
<ul className="mt-2 divide-y divide-gridline">
|
||||
<li className="flex items-center gap-3 py-2 text-xs font-medium text-text-muted">
|
||||
<span className="flex-1" />
|
||||
<span className="w-28 shrink-0 text-right">{t("roi.calculator.colCompound")}</span>
|
||||
<span className="hidden w-28 shrink-0 text-right sm:block">{t("roi.calculator.colSimple")}</span>
|
||||
<span className="w-24 shrink-0 text-right">{t("roi.calculator.colDifference")}</span>
|
||||
</li>
|
||||
{TABLE_YEARS.map((year) => {
|
||||
const compound = investmentAmount * Math.pow(1 + rate, year);
|
||||
const simple = investmentAmount + annualIncome * year;
|
||||
// Rounded to the cent before comparing sign — compound and simple
|
||||
// agree exactly in year 1, but Math.pow's floating-point error
|
||||
// can leave a sub-cent residue. `|| 0` also normalizes -0 (which
|
||||
// is >= 0 but still formats as "-$0") to a plain positive 0.
|
||||
const difference = Math.round((compound - simple) * 100) / 100 || 0;
|
||||
return (
|
||||
<li key={year} className="flex items-center gap-3 py-2.5">
|
||||
<span className="flex-1 text-sm text-text-secondary">
|
||||
{year === 1 ? t("roi.calculator.yearOne") : t("roi.calculator.yearN", { years: year })}
|
||||
</span>
|
||||
<span className="w-28 shrink-0 text-right text-sm font-medium tabular-nums text-text-primary">
|
||||
{formatCurrency(compound)}
|
||||
</span>
|
||||
<span className="hidden w-28 shrink-0 text-right text-sm tabular-nums text-text-muted sm:block">
|
||||
{formatCurrency(simple)}
|
||||
</span>
|
||||
<span
|
||||
className="w-24 shrink-0 text-right text-sm tabular-nums"
|
||||
style={{ color: difference >= 0 ? "var(--success)" : "var(--danger)" }}
|
||||
>
|
||||
{difference >= 0 ? "+" : ""}
|
||||
{formatCurrency(difference)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/settings/AlertThresholdsCard.tsx
Normal file
107
frontend/src/components/settings/AlertThresholdsCard.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Input, Label } from "@/components/ui/Input";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { useAppSettings, useUpdateAppSettings } from "@/hooks/useSettings";
|
||||
|
||||
const MIN_MONTHS = 1;
|
||||
const MAX_MONTHS = 24;
|
||||
const MIN_PERCENT = 1;
|
||||
const MAX_PERCENT = 100;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function AlertThresholdsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { data: settings } = useAppSettings();
|
||||
const updateSettings = useUpdateAppSettings();
|
||||
|
||||
const [cashFlowMonths, setCashFlowMonths] = useState("");
|
||||
const [netWorthMonths, setNetWorthMonths] = useState("");
|
||||
const [riskyPercent, setRiskyPercent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setCashFlowMonths(String(settings.negative_cash_flow_threshold_months));
|
||||
setNetWorthMonths(String(settings.net_worth_decline_threshold_months));
|
||||
setRiskyPercent(String(settings.risky_allocation_threshold_percent));
|
||||
}, [settings]);
|
||||
|
||||
function commitCashFlowMonths() {
|
||||
const value = clamp(Number(cashFlowMonths) || MIN_MONTHS, MIN_MONTHS, MAX_MONTHS);
|
||||
setCashFlowMonths(String(value));
|
||||
if (settings && value !== settings.negative_cash_flow_threshold_months) {
|
||||
updateSettings.mutate({ negative_cash_flow_threshold_months: value });
|
||||
}
|
||||
}
|
||||
|
||||
function commitNetWorthMonths() {
|
||||
const value = clamp(Number(netWorthMonths) || MIN_MONTHS, MIN_MONTHS, MAX_MONTHS);
|
||||
setNetWorthMonths(String(value));
|
||||
if (settings && value !== settings.net_worth_decline_threshold_months) {
|
||||
updateSettings.mutate({ net_worth_decline_threshold_months: value });
|
||||
}
|
||||
}
|
||||
|
||||
function commitRiskyPercent() {
|
||||
const value = clamp(Number(riskyPercent) || MIN_PERCENT, MIN_PERCENT, MAX_PERCENT);
|
||||
setRiskyPercent(String(value));
|
||||
if (settings && value !== settings.risky_allocation_threshold_percent) {
|
||||
updateSettings.mutate({ risky_allocation_threshold_percent: value });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings.alertsTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label htmlFor="cash-flow-threshold">{t("settings.negativeCashFlowThresholdLabel")}</Label>
|
||||
<Input
|
||||
id="cash-flow-threshold"
|
||||
type="number"
|
||||
min={MIN_MONTHS}
|
||||
max={MAX_MONTHS}
|
||||
step={1}
|
||||
value={cashFlowMonths}
|
||||
onChange={(event) => setCashFlowMonths(event.target.value)}
|
||||
onBlur={commitCashFlowMonths}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="net-worth-threshold">{t("settings.netWorthDeclineThresholdLabel")}</Label>
|
||||
<Input
|
||||
id="net-worth-threshold"
|
||||
type="number"
|
||||
min={MIN_MONTHS}
|
||||
max={MAX_MONTHS}
|
||||
step={1}
|
||||
value={netWorthMonths}
|
||||
onChange={(event) => setNetWorthMonths(event.target.value)}
|
||||
onBlur={commitNetWorthMonths}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="risky-allocation-threshold">{t("settings.riskyAllocationThresholdLabel")}</Label>
|
||||
<Input
|
||||
id="risky-allocation-threshold"
|
||||
type="number"
|
||||
min={MIN_PERCENT}
|
||||
max={MAX_PERCENT}
|
||||
step={1}
|
||||
value={riskyPercent}
|
||||
onChange={(event) => setRiskyPercent(event.target.value)}
|
||||
onBlur={commitRiskyPercent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{t("settings.alertsHint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/settings/BackupCard.tsx
Normal file
100
frontend/src/components/settings/BackupCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { exportBackup, importBackup } from "@/api/backup";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export function BackupCard() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
async function handleExport() {
|
||||
setIsExporting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await exportBackup();
|
||||
setMessage({ kind: "success", text: t("backup.exportSuccess") });
|
||||
} catch {
|
||||
// Never surface error.message here — it's the raw backend/HTTP
|
||||
// response text (English, unlocalized), unlike every other error
|
||||
// path in the app, which always shows a translated fallback.
|
||||
setMessage({ kind: "error", text: t("backup.exportError") });
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleImportClick() {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
|
||||
async function handleFileSelected(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
const confirmed = window.confirm(t("backup.confirmImport", { filename: file.name }));
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsImporting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await importBackup(file);
|
||||
await queryClient.invalidateQueries();
|
||||
setMessage({ kind: "success", text: t("backup.importSuccess") });
|
||||
} catch (error) {
|
||||
// importBackup throws a plain Error with an already-translated message
|
||||
// for a malformed file (t("backup.invalidFile")) — relay that one
|
||||
// as-is. A failed API call throws ApiError with the raw backend detail
|
||||
// text (English, unlocalized) instead — never show that, fall back to
|
||||
// the generic translated message like every other error path in the app.
|
||||
const text = error instanceof ApiError ? t("backup.importError") : error instanceof Error ? error.message : t("backup.importError");
|
||||
setMessage({ kind: "error", text });
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("backup.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">{t("backup.description")}</p>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button onClick={handleExport} disabled={isExporting} className="sm:w-auto">
|
||||
<Download size={16} />
|
||||
{isExporting ? t("backup.exporting") : t("backup.exportButton")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleImportClick} disabled={isImporting} className="sm:w-auto">
|
||||
<Upload size={16} />
|
||||
{isImporting ? t("backup.importing") : t("backup.importButton")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className={`text-sm ${message.kind === "error" ? "text-danger" : "text-success"}`}>{message.text}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">{t("backup.footerWarning")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/settings/CurrencyCard.tsx
Normal file
34
frontend/src/components/settings/CurrencyCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Select } from "@/components/ui/Input";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { CURRENCIES, getCurrencyLabel } from "@/lib/currency";
|
||||
import { useAppSettings, useUpdateAppSettings } from "@/hooks/useSettings";
|
||||
|
||||
export function CurrencyCard() {
|
||||
const { t, language } = useTranslation();
|
||||
const { data: settings, isLoading } = useAppSettings();
|
||||
const updateSettings = useUpdateAppSettings();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings.currency")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Select
|
||||
value={settings?.currency ?? ""}
|
||||
disabled={isLoading || updateSettings.isPending}
|
||||
onChange={(event) => updateSettings.mutate({ currency: event.target.value })}
|
||||
className="max-w-xs"
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<option key={currency.code} value={currency.code}>
|
||||
{getCurrencyLabel(currency.code, language)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs text-text-muted">{t("settings.currencyHint")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/settings/LanguageCard.tsx
Normal file
23
frontend/src/components/settings/LanguageCard.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { PillSelector } from "@/components/layout/PillSelector";
|
||||
import { useTranslation, type Language } from "@/lib/i18n";
|
||||
|
||||
export function LanguageCard() {
|
||||
const { t, language, setLanguage } = useTranslation();
|
||||
|
||||
const options: Array<{ value: Language; label: string }> = [
|
||||
{ value: "ru", label: t("settings.languageRussian") },
|
||||
{ value: "en", label: t("settings.languageEnglish") },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings.language")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PillSelector options={options} value={language} onChange={setLanguage} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
249
frontend/src/components/transactions/TransactionFormModal.tsx
Normal file
249
frontend/src/components/transactions/TransactionFormModal.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Label, Select } from "@/components/ui/Input";
|
||||
import { useAccounts } from "@/hooks/useAccounts";
|
||||
import { useCategories } from "@/hooks/useCategories";
|
||||
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { Transaction, TransactionInput, TransactionType } from "@/types";
|
||||
|
||||
interface TransactionFormModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
transaction?: Transaction | null;
|
||||
}
|
||||
|
||||
function todayIso() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const EMPTY_FORM = {
|
||||
type: "expense" as TransactionType,
|
||||
account_id: "",
|
||||
category_id: "",
|
||||
transfer_account_id: "",
|
||||
amount: "",
|
||||
description: "",
|
||||
merchant: "",
|
||||
notes: "",
|
||||
date: todayIso(),
|
||||
};
|
||||
|
||||
export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: accounts } = useAccounts();
|
||||
const { data: categories } = useCategories();
|
||||
const createTransaction = useCreateTransaction();
|
||||
const updateTransaction = useUpdateTransaction();
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (transaction) {
|
||||
setForm({
|
||||
type: transaction.type,
|
||||
account_id: String(transaction.account_id),
|
||||
category_id: transaction.category_id ? String(transaction.category_id) : "",
|
||||
transfer_account_id: transaction.transfer_account_id ? String(transaction.transfer_account_id) : "",
|
||||
amount: transaction.amount,
|
||||
description: transaction.description,
|
||||
merchant: transaction.merchant ?? "",
|
||||
notes: transaction.notes ?? "",
|
||||
date: transaction.date,
|
||||
});
|
||||
} else {
|
||||
setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" });
|
||||
}
|
||||
setError(null);
|
||||
}, [open, transaction, accounts]);
|
||||
|
||||
const relevantCategories = (categories ?? []).filter((category) =>
|
||||
form.type === "income" ? category.kind === "income" : category.kind === "expense"
|
||||
);
|
||||
|
||||
const isSaving = createTransaction.isPending || updateTransaction.isPending;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!form.account_id) {
|
||||
setError(t("transactions.form.errorSelectAccount"));
|
||||
return;
|
||||
}
|
||||
if (form.type === "transfer" && !form.transfer_account_id) {
|
||||
setError(t("transactions.form.errorSelectDestination"));
|
||||
return;
|
||||
}
|
||||
if (form.type === "transfer" && form.transfer_account_id === form.account_id) {
|
||||
setError(t("transactions.form.errorSameAccount"));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TransactionInput = {
|
||||
type: form.type,
|
||||
account_id: Number(form.account_id),
|
||||
category_id: form.type === "transfer" ? null : form.category_id ? Number(form.category_id) : null,
|
||||
transfer_account_id: form.type === "transfer" ? Number(form.transfer_account_id) : null,
|
||||
amount: form.amount,
|
||||
description: form.description,
|
||||
merchant: form.merchant || null,
|
||||
notes: form.notes || null,
|
||||
date: form.date,
|
||||
};
|
||||
|
||||
try {
|
||||
if (transaction) {
|
||||
await updateTransaction.mutateAsync({ id: transaction.id, input: payload });
|
||||
} else {
|
||||
await createTransaction.mutateAsync(payload);
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t("transactions.form.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={transaction ? t("transactions.form.editTitle") : t("transactions.form.newTitle")}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="type">{t("transactions.form.typeLabel")}</Label>
|
||||
<Select
|
||||
id="type"
|
||||
value={form.type}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, type: event.target.value as TransactionType, category_id: "" }))
|
||||
}
|
||||
>
|
||||
<option value="expense">{t("transactions.form.typeExpense")}</option>
|
||||
<option value="income">{t("transactions.form.typeIncome")}</option>
|
||||
<option value="transfer">{t("transactions.form.typeTransfer")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="amount">{t("transactions.form.amountLabel")}</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
required
|
||||
value={form.amount}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, amount: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="date">{t("transactions.form.dateLabel")}</Label>
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
required
|
||||
value={form.date}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, date: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">{t("transactions.form.descriptionLabel")}</Label>
|
||||
<Input
|
||||
id="description"
|
||||
required
|
||||
placeholder={t("transactions.form.descriptionPlaceholder")}
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="account">{t("transactions.form.accountLabel")}</Label>
|
||||
<Select
|
||||
id="account"
|
||||
required
|
||||
value={form.account_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, account_id: event.target.value }))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("transactions.form.selectAccount")}
|
||||
</option>
|
||||
{accounts?.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.type === "transfer" ? (
|
||||
<div>
|
||||
<Label htmlFor="transfer_account">{t("transactions.form.transferAccountLabel")}</Label>
|
||||
<Select
|
||||
id="transfer_account"
|
||||
required
|
||||
value={form.transfer_account_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, transfer_account_id: event.target.value }))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("transactions.form.selectAccount")}
|
||||
</option>
|
||||
{accounts
|
||||
?.filter((account) => String(account.id) !== form.account_id)
|
||||
.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="category">{t("transactions.form.categoryLabel")}</Label>
|
||||
<Select
|
||||
id="category"
|
||||
value={form.category_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, category_id: event.target.value }))}
|
||||
>
|
||||
<option value="">{t("transactions.form.noCategory")}</option>
|
||||
{relevantCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="merchant">{t("transactions.form.merchantLabel")}</Label>
|
||||
<Input
|
||||
id="merchant"
|
||||
value={form.merchant}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, merchant: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/transactions/TransactionsTable.tsx
Normal file
79
frontend/src/components/transactions/TransactionsTable.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ArrowLeftRight, Pencil, Trash2 } from "lucide-react";
|
||||
import { getCategoryIcon } from "@/lib/icons";
|
||||
import { formatCurrency, formatTransactionDate } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { Transaction } from "@/types";
|
||||
|
||||
interface TransactionsTableProps {
|
||||
items: Transaction[];
|
||||
onEdit: (transaction: Transaction) => void;
|
||||
onDelete: (transaction: Transaction) => void;
|
||||
}
|
||||
|
||||
export function TransactionsTable({ items, onEdit, onDelete }: TransactionsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="py-12 text-center text-sm text-text-muted">{t("transactions.noneFound")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-gridline">
|
||||
{items.map((tx) => {
|
||||
const isTransfer = tx.type === "transfer";
|
||||
const isExpense = tx.type === "expense";
|
||||
const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(tx.category?.icon);
|
||||
const color = isTransfer ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)";
|
||||
|
||||
return (
|
||||
<li key={tx.id} className="group flex items-center gap-3 py-3">
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: typeof color === "string" && color.startsWith("#") ? `${color}26` : "var(--surface-2)" }}
|
||||
>
|
||||
<Icon size={16} style={{ color }} />
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-text-primary">{tx.description}</span>
|
||||
<span className="block truncate text-xs text-text-muted">
|
||||
{formatTransactionDate(tx.date)} · {tx.account.name}
|
||||
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
|
||||
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={`shrink-0 text-sm font-medium tabular-nums ${
|
||||
isTransfer ? "text-text-muted" : isExpense ? "text-text-primary" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{isTransfer ? "" : isExpense ? "-" : "+"}
|
||||
{formatCurrency(tx.amount)}
|
||||
</span>
|
||||
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => onEdit(tx)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => onDelete(tx)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/ui/Badge.tsx
Normal file
14
frontend/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full bg-surface-2 px-2 py-0.5 text-xs font-medium text-text-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/ui/Button.tsx
Normal file
28
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||
|
||||
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||
primary: "bg-text-primary text-surface-1 hover:opacity-90",
|
||||
secondary: "bg-surface-2 text-text-primary hover:bg-surface-2/70",
|
||||
ghost: "bg-transparent text-text-secondary hover:bg-surface-2",
|
||||
danger: "bg-danger text-white hover:opacity-90",
|
||||
};
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
}
|
||||
|
||||
export function Button({ className, variant = "primary", ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
VARIANT_CLASSES[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/ui/Card.tsx
Normal file
28
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-xl border border-border bg-surface-1 shadow-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex items-center justify-between gap-2 p-4 sm:p-5", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("text-xs font-semibold uppercase tracking-wide text-text-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-4 pt-0 sm:p-5 sm:pt-0", className)} {...props} />;
|
||||
}
|
||||
53
frontend/src/components/ui/Dialog.tsx
Normal file
53
frontend/src/components/ui/Dialog.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
interface DialogProps extends PropsWithChildren {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, title, children }: DialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-0 sm:items-center sm:p-4"
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="max-h-[90vh] w-full overflow-y-auto rounded-t-2xl border border-border bg-surface-1 p-5 shadow-xl sm:max-w-md sm:rounded-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-text-primary">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("common.close")}
|
||||
className="rounded-md p-1 text-text-muted hover:bg-surface-2"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/components/ui/Input.tsx
Normal file
32
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { InputHTMLAttributes, LabelHTMLAttributes, SelectHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"h-9 w-full rounded-lg border border-border bg-surface-1 px-3 text-sm text-text-primary outline-none focus:border-series-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select({ className, children, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"h-9 w-full rounded-lg border border-border bg-surface-1 px-3 text-sm text-text-primary outline-none focus:border-series-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function Label({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return <label className={cn("mb-1 block text-xs font-medium text-text-secondary", className)} {...props} />;
|
||||
}
|
||||
46
frontend/src/hooks/useAccounts.ts
Normal file
46
frontend/src/hooks/useAccounts.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createAccount, deleteAccount, fetchAccounts, updateAccount } from "@/api/accounts";
|
||||
import type { AccountInput } from "@/types";
|
||||
|
||||
export function useAccounts(includeArchived = false) {
|
||||
return useQuery({ queryKey: ["accounts", includeArchived], queryFn: () => fetchAccounts(includeArchived) });
|
||||
}
|
||||
|
||||
export function useCreateAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: AccountInput) => createAccount(input),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: Partial<AccountInput> & { is_archived?: boolean } }) =>
|
||||
updateAccount(id, input),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteAccount(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
||||
// A cascade-deleted account takes its transaction history with it —
|
||||
// refresh everything derived from transactions, same set posting a
|
||||
// recurring transaction invalidates.
|
||||
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["category-spending-report"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["category-ranking"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["budget-status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["financial-alerts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["advice"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cash-flow"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
6
frontend/src/hooks/useAdvice.ts
Normal file
6
frontend/src/hooks/useAdvice.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchAdvice } from "@/api/advice";
|
||||
|
||||
export function useAdvice() {
|
||||
return useQuery({ queryKey: ["advice"], queryFn: fetchAdvice });
|
||||
}
|
||||
47
frontend/src/hooks/useAssets.ts
Normal file
47
frontend/src/hooks/useAssets.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { addAssetValuation, createAsset, deleteAsset, fetchAssets, updateAsset } from "@/api/assets";
|
||||
import type { AssetInput, AssetUpdateInput, AssetValuationInput } from "@/types";
|
||||
|
||||
function useInvalidateNetWorth() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
||||
};
|
||||
}
|
||||
|
||||
export function useAssets() {
|
||||
return useQuery({ queryKey: ["assets"], queryFn: fetchAssets });
|
||||
}
|
||||
|
||||
export function useCreateAsset() {
|
||||
const invalidate = useInvalidateNetWorth();
|
||||
return useMutation({
|
||||
mutationFn: (input: AssetInput) => createAsset(input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAsset() {
|
||||
const invalidate = useInvalidateNetWorth();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: AssetUpdateInput }) => updateAsset(id, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddAssetValuation() {
|
||||
const invalidate = useInvalidateNetWorth();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: AssetValuationInput }) => addAssetValuation(id, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAsset() {
|
||||
const invalidate = useInvalidateNetWorth();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteAsset(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
44
frontend/src/hooks/useBudgets.ts
Normal file
44
frontend/src/hooks/useBudgets.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createBudget, deleteBudget, fetchBudgetStatus, fetchBudgets, updateBudget } from "@/api/budgets";
|
||||
import type { BudgetInput } from "@/types";
|
||||
|
||||
function useInvalidateBudgets() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["budgets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["budget-status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["financial-alerts"] });
|
||||
};
|
||||
}
|
||||
|
||||
export function useBudgets() {
|
||||
return useQuery({ queryKey: ["budgets"], queryFn: fetchBudgets });
|
||||
}
|
||||
|
||||
export function useBudgetStatus(year: number, month: number) {
|
||||
return useQuery({ queryKey: ["budget-status", year, month], queryFn: () => fetchBudgetStatus(year, month) });
|
||||
}
|
||||
|
||||
export function useCreateBudget() {
|
||||
const invalidate = useInvalidateBudgets();
|
||||
return useMutation({
|
||||
mutationFn: (input: BudgetInput) => createBudget(input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateBudget() {
|
||||
const invalidate = useInvalidateBudgets();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, monthlyLimit }: { id: number; monthlyLimit: string }) => updateBudget(id, monthlyLimit),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBudget() {
|
||||
const invalidate = useInvalidateBudgets();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteBudget(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
9
frontend/src/hooks/useCashFlow.ts
Normal file
9
frontend/src/hooks/useCashFlow.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchCashFlow } from "@/api/cashFlow";
|
||||
|
||||
export function useCashFlow(startDate?: string, endDate?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["cash-flow", startDate, endDate],
|
||||
queryFn: () => fetchCashFlow(startDate, endDate),
|
||||
});
|
||||
}
|
||||
6
frontend/src/hooks/useCategories.ts
Normal file
6
frontend/src/hooks/useCategories.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchCategories } from "@/api/categories";
|
||||
|
||||
export function useCategories() {
|
||||
return useQuery({ queryKey: ["categories"], queryFn: fetchCategories });
|
||||
}
|
||||
9
frontend/src/hooks/useDashboard.ts
Normal file
9
frontend/src/hooks/useDashboard.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchDashboardSummary } from "@/api/dashboard";
|
||||
|
||||
export function useDashboardSummary(year: number, month: number) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard-summary", year, month],
|
||||
queryFn: () => fetchDashboardSummary(year, month),
|
||||
});
|
||||
}
|
||||
44
frontend/src/hooks/useGoals.ts
Normal file
44
frontend/src/hooks/useGoals.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { addGoalContribution, createGoal, deleteGoal, fetchGoals, updateGoal } from "@/api/goals";
|
||||
import type { GoalContributionInput, GoalInput } from "@/types";
|
||||
|
||||
function useInvalidateGoals() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: ["goals"] });
|
||||
}
|
||||
|
||||
export function useGoals() {
|
||||
return useQuery({ queryKey: ["goals"], queryFn: fetchGoals });
|
||||
}
|
||||
|
||||
export function useCreateGoal() {
|
||||
const invalidate = useInvalidateGoals();
|
||||
return useMutation({
|
||||
mutationFn: (input: GoalInput) => createGoal(input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateGoal() {
|
||||
const invalidate = useInvalidateGoals();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: Partial<GoalInput> }) => updateGoal(id, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteGoal() {
|
||||
const invalidate = useInvalidateGoals();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteGoal(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddGoalContribution() {
|
||||
const invalidate = useInvalidateGoals();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: GoalContributionInput }) => addGoalContribution(id, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
9
frontend/src/hooks/useInsights.ts
Normal file
9
frontend/src/hooks/useInsights.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchFinancialAlerts } from "@/api/insights";
|
||||
|
||||
export function useFinancialAlerts() {
|
||||
return useQuery({
|
||||
queryKey: ["financial-alerts"],
|
||||
queryFn: fetchFinancialAlerts,
|
||||
});
|
||||
}
|
||||
15
frontend/src/hooks/useLocalStorageState.ts
Normal file
15
frontend/src/hooks/useLocalStorageState.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function useLocalStorageState(key: string, initial: boolean) {
|
||||
const [value, setValue] = useState<boolean>(() => {
|
||||
const stored = localStorage.getItem(key);
|
||||
return stored === null ? initial : stored === "true";
|
||||
});
|
||||
|
||||
const update = (next: boolean) => {
|
||||
setValue(next);
|
||||
localStorage.setItem(key, String(next));
|
||||
};
|
||||
|
||||
return [value, update] as const;
|
||||
}
|
||||
10
frontend/src/hooks/useNetWorth.ts
Normal file
10
frontend/src/hooks/useNetWorth.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchNetWorthSummary } from "@/api/netWorth";
|
||||
import type { NetWorthRange } from "@/types";
|
||||
|
||||
export function useNetWorthSummary(range: NetWorthRange) {
|
||||
return useQuery({
|
||||
queryKey: ["net-worth-summary", range],
|
||||
queryFn: () => fetchNetWorthSummary(range),
|
||||
});
|
||||
}
|
||||
65
frontend/src/hooks/useRecurring.ts
Normal file
65
frontend/src/hooks/useRecurring.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createRecurring,
|
||||
deleteRecurring,
|
||||
fetchRecurring,
|
||||
postRecurring,
|
||||
updateRecurring,
|
||||
} from "@/api/recurring";
|
||||
import type { RecurringTransactionInput } from "@/types";
|
||||
|
||||
function useInvalidateRecurring() {
|
||||
const queryClient = useQueryClient();
|
||||
return (alsoInvalidateTransactions: boolean) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["recurring"] });
|
||||
if (alsoInvalidateTransactions) {
|
||||
// Posting creates a real Transaction — refresh everything derived from it.
|
||||
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["category-spending-report"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["category-ranking"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["budget-status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["financial-alerts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["advice"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cash-flow"] });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function useRecurring() {
|
||||
return useQuery({ queryKey: ["recurring"], queryFn: fetchRecurring });
|
||||
}
|
||||
|
||||
export function useCreateRecurring() {
|
||||
const invalidate = useInvalidateRecurring();
|
||||
return useMutation({
|
||||
mutationFn: (input: RecurringTransactionInput) => createRecurring(input),
|
||||
onSuccess: () => invalidate(false),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRecurring() {
|
||||
const invalidate = useInvalidateRecurring();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: Partial<RecurringTransactionInput> }) =>
|
||||
updateRecurring(id, input),
|
||||
onSuccess: () => invalidate(false),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRecurring() {
|
||||
const invalidate = useInvalidateRecurring();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteRecurring(id),
|
||||
onSuccess: () => invalidate(false),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePostRecurring() {
|
||||
const invalidate = useInvalidateRecurring();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => postRecurring(id),
|
||||
onSuccess: () => invalidate(true),
|
||||
});
|
||||
}
|
||||
18
frontend/src/hooks/useReports.ts
Normal file
18
frontend/src/hooks/useReports.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchCategoryRanking, fetchCategorySpendingReport } from "@/api/reports";
|
||||
import type { CategoryKind } from "@/types";
|
||||
|
||||
export function useCategorySpendingReport(categoryId: number | null, startDate?: string, endDate?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["category-spending-report", categoryId, startDate, endDate],
|
||||
queryFn: () => fetchCategorySpendingReport(categoryId as number, startDate, endDate),
|
||||
enabled: categoryId !== null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCategoryRanking(kind: CategoryKind, startDate?: string, endDate?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["category-ranking", kind, startDate, endDate],
|
||||
queryFn: () => fetchCategoryRanking(kind, startDate, endDate),
|
||||
});
|
||||
}
|
||||
19
frontend/src/hooks/useSettings.ts
Normal file
19
frontend/src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchSettings, updateSettings } from "@/api/settings";
|
||||
import { setCurrency } from "@/lib/i18n";
|
||||
import type { AppSettings } from "@/types";
|
||||
|
||||
export function useAppSettings() {
|
||||
return useQuery({ queryKey: ["settings"], queryFn: fetchSettings });
|
||||
}
|
||||
|
||||
export function useUpdateAppSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: Partial<AppSettings>) => updateSettings(input),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["settings"], data);
|
||||
setCurrency(data.currency);
|
||||
},
|
||||
});
|
||||
}
|
||||
56
frontend/src/hooks/useTransactions.ts
Normal file
56
frontend/src/hooks/useTransactions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createTransaction,
|
||||
deleteTransaction,
|
||||
fetchTransactions,
|
||||
updateTransaction,
|
||||
type TransactionFilters,
|
||||
} from "@/api/transactions";
|
||||
import type { TransactionInput } from "@/types";
|
||||
|
||||
function useInvalidateAfterTransactionChange() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["category-spending-report"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["category-ranking"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["budget-status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["financial-alerts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["advice"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cash-flow"] });
|
||||
};
|
||||
}
|
||||
|
||||
export function useTransactions(filters: TransactionFilters) {
|
||||
return useQuery({
|
||||
queryKey: ["transactions", filters],
|
||||
queryFn: () => fetchTransactions(filters),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTransaction() {
|
||||
const invalidate = useInvalidateAfterTransactionChange();
|
||||
return useMutation({
|
||||
mutationFn: (input: TransactionInput) => createTransaction(input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTransaction() {
|
||||
const invalidate = useInvalidateAfterTransactionChange();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: Partial<TransactionInput> }) =>
|
||||
updateTransaction(id, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTransaction() {
|
||||
const invalidate = useInvalidateAfterTransactionChange();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deleteTransaction(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
116
frontend/src/index.css
Normal file
116
frontend/src/index.css
Normal file
@@ -0,0 +1,116 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Semantic tokens, light values first. Dark values are re-declared for both
|
||||
* the OS-level `prefers-color-scheme` and an eventual manual `[data-theme]`
|
||||
* toggle, per the dataviz skill's reference palette (references/palette.md).
|
||||
*/
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-0: #f9f9f7; /* page plane */
|
||||
--surface-1: #fcfcfb; /* card surface */
|
||||
--surface-2: #f2f1ed; /* hover/active wash */
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #898781;
|
||||
--border: rgba(11, 11, 11, 0.1);
|
||||
--gridline: #e1e0d9;
|
||||
|
||||
--success: #006300;
|
||||
--danger: #c1302a;
|
||||
|
||||
--series-1: #2a78d6; /* blue */
|
||||
--series-2: #eb6834; /* orange */
|
||||
--series-3: #1baf7a; /* aqua */
|
||||
--series-4: #eda100; /* yellow */
|
||||
--series-5: #e87ba4; /* magenta */
|
||||
--series-6: #008300; /* green */
|
||||
--series-7: #4a3aa7; /* violet */
|
||||
--series-8: #e34948; /* red */
|
||||
--series-other: #898781;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--surface-0: #0d0d0d;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--gridline: #2c2c2a;
|
||||
|
||||
--success: #0ca30c;
|
||||
--danger: #e66767;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface-0: #0d0d0d;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--gridline: #2c2c2a;
|
||||
|
||||
--success: #0ca30c;
|
||||
--danger: #e66767;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-surface-0: var(--surface-0);
|
||||
--color-surface-1: var(--surface-1);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-border: var(--border);
|
||||
--color-gridline: var(--gridline);
|
||||
--color-success: var(--success);
|
||||
--color-danger: var(--danger);
|
||||
|
||||
--color-series-1: var(--series-1);
|
||||
--color-series-2: var(--series-2);
|
||||
--color-series-3: var(--series-3);
|
||||
--color-series-4: var(--series-4);
|
||||
--color-series-5: var(--series-5);
|
||||
--color-series-6: var(--series-6);
|
||||
--color-series-7: var(--series-7);
|
||||
--color-series-8: var(--series-8);
|
||||
--color-series-other: var(--series-other);
|
||||
|
||||
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--surface-0);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
27
frontend/src/lib/categoryLabels.ts
Normal file
27
frontend/src/lib/categoryLabels.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { t, type TranslationKey } from "@/lib/i18n";
|
||||
|
||||
// Maps the exact seeded English category name (backend/app/db/seed.py) to
|
||||
// its translation key. There's no category-management UI yet, so every
|
||||
// category currently in the database is one of these — anything that
|
||||
// doesn't match (a renamed default, or a future custom category) falls
|
||||
// through unchanged.
|
||||
const DEFAULT_CATEGORY_KEYS: Record<string, TranslationKey> = {
|
||||
"Housing & Utilities": "category.housingUtilities",
|
||||
Groceries: "category.groceries",
|
||||
"Dining Out": "category.diningOut",
|
||||
Transportation: "category.transportation",
|
||||
"Health & Fitness": "category.healthFitness",
|
||||
Shopping: "category.shopping",
|
||||
Entertainment: "category.entertainment",
|
||||
Subscriptions: "category.subscriptions",
|
||||
Salary: "category.salary",
|
||||
Freelance: "category.freelance",
|
||||
Investments: "category.investments",
|
||||
Gifts: "category.gifts",
|
||||
"Other Income": "category.otherIncome",
|
||||
};
|
||||
|
||||
export function translateCategoryName(name: string): string {
|
||||
const key = DEFAULT_CATEGORY_KEYS[name];
|
||||
return key ? t(key) : name;
|
||||
}
|
||||
59
frontend/src/lib/currency.ts
Normal file
59
frontend/src/lib/currency.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Language } from "@/lib/i18n";
|
||||
|
||||
interface CurrencyOption {
|
||||
code: string;
|
||||
nameRu: string;
|
||||
nameEn: string;
|
||||
}
|
||||
|
||||
// A curated set of ISO 4217 currencies, not the full ~180-code list — covers
|
||||
// the majors plus the CIS/EU currencies most relevant to this app's
|
||||
// Russian-speaking users. Intl.NumberFormat renders the correct symbol for
|
||||
// any of these on its own; this list only supplies the human-readable label
|
||||
// for the picker.
|
||||
export const CURRENCIES: CurrencyOption[] = [
|
||||
{ code: "AED", nameRu: "Дирхам ОАЭ", nameEn: "UAE Dirham" },
|
||||
{ code: "AMD", nameRu: "Армянский драм", nameEn: "Armenian Dram" },
|
||||
{ code: "AUD", nameRu: "Австралийский доллар", nameEn: "Australian Dollar" },
|
||||
{ code: "AZN", nameRu: "Азербайджанский манат", nameEn: "Azerbaijani Manat" },
|
||||
{ code: "BRL", nameRu: "Бразильский реал", nameEn: "Brazilian Real" },
|
||||
{ code: "BYN", nameRu: "Белорусский рубль", nameEn: "Belarusian Ruble" },
|
||||
{ code: "CAD", nameRu: "Канадский доллар", nameEn: "Canadian Dollar" },
|
||||
{ code: "CHF", nameRu: "Швейцарский франк", nameEn: "Swiss Franc" },
|
||||
{ code: "CNY", nameRu: "Китайский юань", nameEn: "Chinese Yuan" },
|
||||
{ code: "CZK", nameRu: "Чешская крона", nameEn: "Czech Koruna" },
|
||||
{ code: "EUR", nameRu: "Евро", nameEn: "Euro" },
|
||||
{ code: "GBP", nameRu: "Фунт стерлингов", nameEn: "British Pound" },
|
||||
{ code: "GEL", nameRu: "Грузинский лари", nameEn: "Georgian Lari" },
|
||||
{ code: "HKD", nameRu: "Гонконгский доллар", nameEn: "Hong Kong Dollar" },
|
||||
{ code: "HUF", nameRu: "Венгерский форинт", nameEn: "Hungarian Forint" },
|
||||
{ code: "IDR", nameRu: "Индонезийская рупия", nameEn: "Indonesian Rupiah" },
|
||||
{ code: "ILS", nameRu: "Израильский шекель", nameEn: "Israeli Shekel" },
|
||||
{ code: "INR", nameRu: "Индийская рупия", nameEn: "Indian Rupee" },
|
||||
{ code: "JPY", nameRu: "Японская иена", nameEn: "Japanese Yen" },
|
||||
{ code: "KGS", nameRu: "Киргизский сом", nameEn: "Kyrgyzstani Som" },
|
||||
{ code: "KRW", nameRu: "Южнокорейская вона", nameEn: "South Korean Won" },
|
||||
{ code: "KZT", nameRu: "Казахстанский тенге", nameEn: "Kazakhstani Tenge" },
|
||||
{ code: "MDL", nameRu: "Молдавский лей", nameEn: "Moldovan Leu" },
|
||||
{ code: "MXN", nameRu: "Мексиканское песо", nameEn: "Mexican Peso" },
|
||||
{ code: "NZD", nameRu: "Новозеландский доллар", nameEn: "New Zealand Dollar" },
|
||||
{ code: "PLN", nameRu: "Польский злотый", nameEn: "Polish Zloty" },
|
||||
{ code: "RON", nameRu: "Румынский лей", nameEn: "Romanian Leu" },
|
||||
{ code: "RUB", nameRu: "Российский рубль", nameEn: "Russian Ruble" },
|
||||
{ code: "SAR", nameRu: "Саудовский риял", nameEn: "Saudi Riyal" },
|
||||
{ code: "SGD", nameRu: "Сингапурский доллар", nameEn: "Singapore Dollar" },
|
||||
{ code: "THB", nameRu: "Тайский бат", nameEn: "Thai Baht" },
|
||||
{ code: "TJS", nameRu: "Таджикский сомони", nameEn: "Tajikistani Somoni" },
|
||||
{ code: "TMT", nameRu: "Туркменский манат", nameEn: "Turkmenistani Manat" },
|
||||
{ code: "TRY", nameRu: "Турецкая лира", nameEn: "Turkish Lira" },
|
||||
{ code: "UAH", nameRu: "Украинская гривна", nameEn: "Ukrainian Hryvnia" },
|
||||
{ code: "USD", nameRu: "Доллар США", nameEn: "US Dollar" },
|
||||
{ code: "UZS", nameRu: "Узбекский сум", nameEn: "Uzbekistani Som" },
|
||||
{ code: "ZAR", nameRu: "Южноафриканский рэнд", nameEn: "South African Rand" },
|
||||
];
|
||||
|
||||
export function getCurrencyLabel(code: string, language: Language): string {
|
||||
const currency = CURRENCIES.find((option) => option.code === code);
|
||||
const name = currency ? (language === "ru" ? currency.nameRu : currency.nameEn) : null;
|
||||
return name ? `${code} — ${name}` : code;
|
||||
}
|
||||
46
frontend/src/lib/format.ts
Normal file
46
frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getCurrency, getLanguage, type Language } from "@/lib/i18n";
|
||||
|
||||
/** Maps our app language to the Intl locale used for number/date formatting. */
|
||||
export function getIntlLocale(language: Language = getLanguage()): string {
|
||||
return language === "ru" ? "ru-RU" : "en-US";
|
||||
}
|
||||
|
||||
// `currency` defaults to the app's primary currency setting (Settings page)
|
||||
// — call sites only need to pass it explicitly when formatting a value known
|
||||
// to be in a *different* currency than that setting.
|
||||
export function formatCurrency(amount: number | string, currency: string = getCurrency()): string {
|
||||
const value = typeof amount === "string" ? Number(amount) : amount;
|
||||
return new Intl.NumberFormat(getIntlLocale(), {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatSignedCurrency(amount: number | string, currency: string = getCurrency()): string {
|
||||
const value = typeof amount === "string" ? Number(amount) : amount;
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${formatCurrency(value, currency)}`;
|
||||
}
|
||||
|
||||
const MONTH_LABELS_EN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
|
||||
const MONTH_LABELS_RU = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"] as const;
|
||||
|
||||
export function getMonthLabels(language: Language): readonly string[] {
|
||||
return language === "ru" ? MONTH_LABELS_RU : MONTH_LABELS_EN;
|
||||
}
|
||||
|
||||
export function formatTransactionDate(isoDate: string): string {
|
||||
const date = new Date(`${isoDate}T00:00:00`);
|
||||
return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", day: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
/** Russian noun pluralization: pick the right form for 1/2-4/5+ (with the
|
||||
* 11-14 exception), e.g. pluralizeRu(3, "актив", "актива", "активов"). */
|
||||
export function pluralizeRu(count: number, one: string, few: string, many: string): string {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return one;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few;
|
||||
return many;
|
||||
}
|
||||
714
frontend/src/lib/i18n.ts
Normal file
714
frontend/src/lib/i18n.ts
Normal file
@@ -0,0 +1,714 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type Language = "ru" | "en";
|
||||
|
||||
const STORAGE_KEY = "aurum:language";
|
||||
|
||||
const ru = {
|
||||
"nav.dashboard": "Дашборд",
|
||||
"nav.netWorth": "Капитал",
|
||||
"nav.roi": "Доходность",
|
||||
"nav.transactions": "Транзакции",
|
||||
"nav.accounts": "Счета",
|
||||
"nav.cashFlow": "Движение денег",
|
||||
"nav.reports": "Отчёты",
|
||||
"nav.budget": "Бюджет",
|
||||
"nav.recurring": "Регулярные",
|
||||
"nav.goals": "Цели",
|
||||
"nav.advice": "Советы",
|
||||
"nav.settings": "Настройки",
|
||||
"nav.comingSoon": "скоро",
|
||||
|
||||
"sidebar.collapse": "Свернуть",
|
||||
"sidebar.collapseMenu": "Свернуть меню",
|
||||
"sidebar.expandMenu": "Развернуть меню",
|
||||
"sidebar.closeMenu": "Закрыть меню",
|
||||
|
||||
"topbar.openMenu": "Открыть меню",
|
||||
|
||||
"common.loading": "Загрузка…",
|
||||
"common.back": "Назад",
|
||||
"common.next": "Далее",
|
||||
"common.cancel": "Отмена",
|
||||
"common.save": "Сохранить",
|
||||
"common.saving": "Сохранение…",
|
||||
"common.edit": "Изменить",
|
||||
"common.delete": "Удалить",
|
||||
"common.close": "Закрыть",
|
||||
"common.add": "Добавить",
|
||||
"common.pageOf": "Стр. {{page}} из {{total}}",
|
||||
"common.totalCount": "{{count}} всего",
|
||||
"common.perMonth": "/мес",
|
||||
"common.allShort": "Всё",
|
||||
|
||||
"dashboard.errorLoading": "Не удалось загрузить данные дашборда. Проверьте, что backend запущен.",
|
||||
"dashboard.statRealIncomeLabel": "Реальный доход",
|
||||
"dashboard.statRealIncomeCaption": "зарплаты, возвраты",
|
||||
"dashboard.statSpentLabel": "Расходы",
|
||||
"dashboard.statSpentCaption": "без учёта переводов",
|
||||
"dashboard.statNetLabel": "Баланс",
|
||||
"dashboard.statNetCaption": "доход − расход",
|
||||
"dashboard.statSavingsRateLabel": "Норма сбережений",
|
||||
"dashboard.statSavingsRateCaption": "доля дохода, оставшаяся после расходов",
|
||||
"dashboard.spendingByCategoryTitle": "Расходы по категориям",
|
||||
"dashboard.noExpensesThisMonth": "За этот месяц пока нет расходов. Добавьте первую транзакцию на вкладке «Транзакции».",
|
||||
"dashboard.recentTransactionsTitle": "Последние транзакции",
|
||||
"dashboard.allTransactionsLink": "Все транзакции",
|
||||
"dashboard.noTransactionsYet": "Транзакций пока нет.",
|
||||
|
||||
"netWorth.periodSuffix": "за период",
|
||||
"netWorth.noChartData": "Пока нет данных для графика.",
|
||||
"netWorth.assetsTitlePrefix": "Активы",
|
||||
"netWorth.myAssetsTitle": "Мои активы",
|
||||
"netWorth.confirmDeleteAsset": "Удалить актив «{{name}}»?",
|
||||
"netWorth.capitalByType": "Капитал по типу",
|
||||
|
||||
"netWorth.assetClass.investments": "Инвестиции",
|
||||
"netWorth.assetClass.crypto": "Криптовалюта",
|
||||
"netWorth.assetClass.real_estate": "Недвижимость",
|
||||
"netWorth.assetClass.vehicles": "Транспорт",
|
||||
"netWorth.assetClass.precious_metals": "Драгметаллы",
|
||||
"netWorth.assetClass.other": "Другое",
|
||||
"netWorth.assetClass.cash": "Наличные",
|
||||
|
||||
"netWorth.capitalRole.income": "Доходный",
|
||||
"netWorth.capitalRole.neutral": "Нейтральный",
|
||||
"netWorth.capitalRole.drain": "Убыточный",
|
||||
|
||||
"netWorth.capitalRoleHint.income": "приносит деньги",
|
||||
"netWorth.capitalRoleHint.neutral": "просто есть, не тянет и не даёт",
|
||||
"netWorth.capitalRoleHint.drain": "тянет деньги каждый месяц",
|
||||
|
||||
"netWorth.capitalRoleFormHint.income": "приносит деньги — сдаётся в аренду и т.п.",
|
||||
"netWorth.capitalRoleFormHint.neutral": "просто есть/используется — техника, мебель",
|
||||
"netWorth.capitalRoleFormHint.drain": "тянет деньги каждый месяц — машина и т.п.",
|
||||
|
||||
"netWorth.riskAllocationTitle": "Капитал по уровню риска",
|
||||
"netWorth.riskAllocationOfCapital": "от капитала",
|
||||
"netWorth.riskLevel.low": "Низкий",
|
||||
"netWorth.riskLevel.medium": "Средний",
|
||||
"netWorth.riskLevel.high": "Высокий",
|
||||
"netWorth.riskLevelFormHint.low": "риск потери минимален — депозиты, наличные, гособлигации",
|
||||
"netWorth.riskLevelFormHint.medium": "умеренные колебания стоимости — недвижимость, диверсифицированные инвестиции",
|
||||
"netWorth.riskLevelFormHint.high": "может значительно потерять в цене — крипта, отдельные акции, стартапы",
|
||||
|
||||
"netWorth.assetsTable.empty": "Активов пока нет. Добавьте инвестиции, крипту, недвижимость или другое имущество.",
|
||||
"netWorth.assetsTable.asOf": "на {{date}}",
|
||||
"netWorth.assetsTable.annualRoi": "{{percent}}% годовых",
|
||||
|
||||
"roi.calculator.title": "Калькулятор ROI",
|
||||
"roi.calculator.investmentLabel": "Сумма инвестиции",
|
||||
"roi.calculator.monthlyIncomeLabel": "Доход в месяц",
|
||||
"roi.calculator.annualRoiLabel": "Доходность в год",
|
||||
"roi.calculator.payback": "Окупится за {{years}} лет",
|
||||
"roi.calculator.noPayback": "Не окупается — доход не покрывает вложение",
|
||||
"roi.calculator.hint": "Впишите стоимость и ожидаемый доход в месяц, чтобы увидеть годовую доходность, срок окупаемости и прогноз на годы вперёд.",
|
||||
"roi.calculator.projectionTitle": "Прогноз со сложным процентом",
|
||||
"roi.calculator.projectionHint": "Если доходность {{percent}}% в год сохранится и весь доход будет реинвестироваться обратно в ту же инвестицию — расчёт только на основе введённых вами цифр, без данных из ваших активов.",
|
||||
"roi.calculator.chartCompoundLabel": "Со сложным процентом",
|
||||
"roi.calculator.chartSimpleLabel": "Без реинвестирования",
|
||||
"roi.calculator.tableTitle": "Сравнение по годам",
|
||||
"roi.calculator.colCompound": "Со сложным процентом",
|
||||
"roi.calculator.colSimple": "Без реинвестирования",
|
||||
"roi.calculator.colDifference": "Разница",
|
||||
"roi.calculator.yearOne": "Через 1 год",
|
||||
"roi.calculator.yearN": "Через {{years}} лет",
|
||||
|
||||
"netWorth.form.editTitle": "Изменить актив",
|
||||
"netWorth.form.newTitle": "Новый актив",
|
||||
"netWorth.form.nameLabel": "Название",
|
||||
"netWorth.form.namePlaceholder": "Брокерский счёт, BTC, квартира…",
|
||||
"netWorth.form.classLabel": "Категория",
|
||||
"netWorth.form.currentValueLabel": "Текущая стоимость",
|
||||
"netWorth.form.valueLabel": "Стоимость",
|
||||
"netWorth.form.dateLabel": "Дата оценки",
|
||||
"netWorth.form.roleLabel": "Тип капитала",
|
||||
"netWorth.form.riskLevelLabel": "Уровень риска",
|
||||
"netWorth.form.cashFlowLabel": "Денежный поток в месяц (необязательно)",
|
||||
"netWorth.form.cashFlowPlaceholder": "800 для дохода, -150 для расхода",
|
||||
"netWorth.form.cashFlowHint": "Ориентировочно — реальная сумма из месяца в месяц меняется, это не строгий учёт.",
|
||||
"netWorth.form.notesLabel": "Заметки (необязательно)",
|
||||
"netWorth.form.saveError": "Не удалось сохранить актив. Проверьте данные и попробуйте снова.",
|
||||
|
||||
"transactions.addButton": "Добавить",
|
||||
"transactions.allTypes": "Все типы",
|
||||
"transactions.expense": "Расходы",
|
||||
"transactions.income": "Доходы",
|
||||
"transactions.transfer": "Переводы",
|
||||
"transactions.allCategories": "Все категории",
|
||||
"transactions.failedToLoad": "Не удалось загрузить транзакции.",
|
||||
"transactions.noneFound": "Транзакций не найдено.",
|
||||
"transactions.confirmDelete": "Удалить транзакцию «{{description}}»?",
|
||||
"transactions.transferSuffix": "→ перевод",
|
||||
"transactions.sortDateDesc": "Сначала новые",
|
||||
"transactions.sortAmountDesc": "Сумма: сначала больше",
|
||||
"transactions.sortAmountAsc": "Сумма: сначала меньше",
|
||||
|
||||
"transactions.form.editTitle": "Изменить транзакцию",
|
||||
"transactions.form.newTitle": "Новая транзакция",
|
||||
"transactions.form.typeLabel": "Тип",
|
||||
"transactions.form.typeExpense": "Расход",
|
||||
"transactions.form.typeIncome": "Доход",
|
||||
"transactions.form.typeTransfer": "Перевод",
|
||||
"transactions.form.amountLabel": "Сумма",
|
||||
"transactions.form.dateLabel": "Дата",
|
||||
"transactions.form.descriptionLabel": "Описание",
|
||||
"transactions.form.descriptionPlaceholder": "Продукты, зарплата, аренда…",
|
||||
"transactions.form.accountLabel": "Счёт",
|
||||
"transactions.form.selectAccount": "Выберите счёт",
|
||||
"transactions.form.transferAccountLabel": "Счёт назначения",
|
||||
"transactions.form.categoryLabel": "Категория",
|
||||
"transactions.form.noCategory": "Без категории",
|
||||
"transactions.form.merchantLabel": "Продавец (необязательно)",
|
||||
"transactions.form.errorSelectAccount": "Выберите счёт",
|
||||
"transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода",
|
||||
"transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта",
|
||||
"transactions.form.saveError": "Не удалось сохранить транзакцию. Проверьте данные и попробуйте снова.",
|
||||
|
||||
"reports.categoryLabel": "Категория",
|
||||
"reports.expenseGroup": "Расходы",
|
||||
"reports.incomeGroup": "Доходы",
|
||||
"reports.rangeAll": "Всё время",
|
||||
"reports.range5y": "5 лет",
|
||||
"reports.range12m": "12 мес",
|
||||
"reports.rangeThisYear": "Этот год",
|
||||
"reports.transactionsTitle": "Транзакции",
|
||||
"reports.defaultChartTitle": "Расходы по категории",
|
||||
"reports.averagePerMonth": "{{amount}}/мес в среднем",
|
||||
"reports.noDataForPeriod": "Нет данных за выбранный период.",
|
||||
"reports.categoryRankingTitle": "Топ категорий за период",
|
||||
"reports.noRankingData": "Нет расходов за выбранный период.",
|
||||
|
||||
"settings.language": "Язык",
|
||||
"settings.languageRussian": "Русский",
|
||||
"settings.languageEnglish": "English",
|
||||
"settings.currency": "Основная валюта",
|
||||
"settings.currencyHint":
|
||||
"Меняет только отображение сумм — уже введённые значения не пересчитываются и не конвертируются.",
|
||||
"settings.alertsTitle": "Проактивные предупреждения",
|
||||
"settings.alertsHint": "Через сколько месяцев подряд показывать предупреждение на Dashboard и «Капитал».",
|
||||
"settings.negativeCashFlowThresholdLabel": "Расходы превышают доходы, мес. подряд",
|
||||
"settings.netWorthDeclineThresholdLabel": "Капитал снижается, мес. подряд",
|
||||
"settings.riskyAllocationThresholdLabel": "Допустимая доля рискового капитала, %",
|
||||
|
||||
"backup.title": "Резервное копирование",
|
||||
"backup.description":
|
||||
"Экспортируйте все свои данные (счета, категории, транзакции, активы) в один JSON-файл и храните его в надёжном месте. Позже этот файл можно загрузить обратно, чтобы полностью восстановить данные — например, после переустановки или переезда на другой сервер.",
|
||||
"backup.exportButton": "Экспортировать бэкап",
|
||||
"backup.exporting": "Экспорт…",
|
||||
"backup.importButton": "Восстановить из файла",
|
||||
"backup.importing": "Восстановление…",
|
||||
"backup.exportSuccess": "Бэкап скачан.",
|
||||
"backup.exportError": "Не удалось скачать бэкап.",
|
||||
"backup.importSuccess": "Данные восстановлены из бэкапа.",
|
||||
"backup.importError": "Не удалось восстановить бэкап.",
|
||||
"backup.invalidFile": "Файл повреждён или не является JSON-бэкапом Aurum.",
|
||||
"backup.confirmImport":
|
||||
"Восстановить данные из «{{filename}}»?\n\nЭто ПОЛНОСТЬЮ заменит все текущие счета, категории, транзакции и активы данными из файла. Отменить это действие нельзя — если хотите сохранить текущие данные, сначала сделайте экспорт.",
|
||||
"backup.footerWarning":
|
||||
"Восстановление полностью заменяет текущие данные содержимым файла — это нельзя отменить.",
|
||||
|
||||
"insights.negativeCashFlow": "Расходы превышают доходы {{months}} мес. подряд (по последним завершённым месяцам)",
|
||||
"insights.netWorthDecline": "Капитал снижается {{months}} мес. подряд",
|
||||
"insights.budgetExceededOne": "Превышен бюджет в 1 категории в этом месяце",
|
||||
"insights.budgetExceeded": "Превышен бюджет в {{count}} категориях в этом месяце",
|
||||
"insights.riskyAllocationExceeded": "Рискованная часть капитала — {{percent}}%, превышает установленный лимит {{threshold}}%",
|
||||
|
||||
"budget.empty": "Бюджетов пока нет. Задайте лимит для категории расходов, чтобы отслеживать её здесь.",
|
||||
"budget.remaining": "{{amount}} осталось",
|
||||
"budget.overBy": "Превышен на {{amount}}",
|
||||
"budget.confirmDelete": "Удалить бюджет для «{{name}}»?",
|
||||
"budget.form.newTitle": "Новый бюджет",
|
||||
"budget.form.editTitle": "Изменить бюджет",
|
||||
"budget.form.categoryLabel": "Категория",
|
||||
"budget.form.limitLabel": "Лимит в месяц",
|
||||
"budget.form.noCategoriesAvailable": "Все категории расходов уже добавлены.",
|
||||
"budget.form.saveError": "Не удалось сохранить бюджет. Проверьте данные и попробуйте снова.",
|
||||
|
||||
"advice.empty": "Пока нет советов — недостаточно данных, или всё в порядке.",
|
||||
"advice.risingCategory":
|
||||
"Траты на {{category}} выросли на {{percent}}% по сравнению со средним за последние 3 месяца: {{current}} против {{average}}.",
|
||||
"advice.unbudgetedTopCategory":
|
||||
"На «{{category}}» уходит больше всего в этом месяце — {{amount}}. Задайте для неё бюджет на вкладке «Бюджет», чтобы отслеживать лимит.",
|
||||
"advice.savingsRateTrend": "Норма сбережений: {{rate}}% ({{diff}} п.п. к прошлому месяцу).",
|
||||
|
||||
"goal.empty": "Целей пока нет. Добавьте цель и начните копить.",
|
||||
"goal.remaining": "{{amount}} осталось",
|
||||
"goal.remainingWithDate": "{{amount}} осталось · до {{date}}",
|
||||
"goal.reached": "Цель достигнута!",
|
||||
"goal.contributeLabel": "Добавить взнос",
|
||||
"goal.confirmDelete": "Удалить цель «{{name}}»?",
|
||||
"goal.form.newTitle": "Новая цель",
|
||||
"goal.form.editTitle": "Изменить цель",
|
||||
"goal.form.nameLabel": "Название",
|
||||
"goal.form.namePlaceholder": "Подушка безопасности, отпуск, новый ноутбук…",
|
||||
"goal.form.targetAmountLabel": "Целевая сумма",
|
||||
"goal.form.targetDateLabel": "Дата (необязательно)",
|
||||
"goal.form.saveError": "Не удалось сохранить цель. Проверьте данные и попробуйте снова.",
|
||||
"goal.contribution.title": "Взнос: {{name}}",
|
||||
"goal.contribution.amountLabel": "Сумма",
|
||||
"goal.contribution.amountPlaceholder": "100 для взноса, -50 для снятия",
|
||||
"goal.contribution.dateLabel": "Дата",
|
||||
"goal.contribution.hint": "Можно указать отрицательную сумму, если пришлось снять деньги с цели.",
|
||||
"goal.contribution.saveError": "Не удалось сохранить взнос. Проверьте данные и попробуйте снова.",
|
||||
|
||||
"recurring.empty":
|
||||
"Регулярных платежей пока нет. Добавьте зарплату, аренду или подписку, чтобы не заводить их вручную каждый раз.",
|
||||
"recurring.postLabel": "Провести платёж",
|
||||
"recurring.confirmDelete": "Удалить регулярный платёж «{{name}}»?",
|
||||
"recurring.overdueDays": "Просрочено на {{days}} дн.",
|
||||
"recurring.dueToday": "Пора оплатить сегодня",
|
||||
"recurring.dueInDays": "Через {{days}} дн. · {{date}}",
|
||||
"recurring.frequency.weekly": "Еженедельно",
|
||||
"recurring.frequency.monthly": "Ежемесячно",
|
||||
"recurring.frequency.yearly": "Ежегодно",
|
||||
"recurring.form.newTitle": "Новый регулярный платёж",
|
||||
"recurring.form.editTitle": "Изменить регулярный платёж",
|
||||
"recurring.form.frequencyLabel": "Периодичность",
|
||||
"recurring.form.anchorDateLabel": "Дата начала",
|
||||
"recurring.form.saveError": "Не удалось сохранить регулярный платёж. Проверьте данные и попробуйте снова.",
|
||||
|
||||
"account.empty": "Счетов пока нет. Добавьте первый — например, текущий счёт или наличные.",
|
||||
"account.showArchived": "Показывать архивные",
|
||||
"account.archivedBadge": "архив",
|
||||
"account.archiveLabel": "Архивировать",
|
||||
"account.unarchiveLabel": "Вернуть из архива",
|
||||
"account.confirmDelete":
|
||||
"Удалить счёт «{{name}}»?\n\nЭто безвозвратно удалит и ВСЕ транзакции по этому счёту. Если хотите сохранить историю, заархивируйте счёт вместо удаления.",
|
||||
"account.type.checking": "Текущий счёт",
|
||||
"account.type.savings": "Накопительный счёт",
|
||||
"account.type.credit_card": "Кредитная карта",
|
||||
"account.type.cash": "Наличные",
|
||||
"account.type.investment": "Инвестиционный счёт",
|
||||
"account.type.other": "Другое",
|
||||
"account.form.newTitle": "Новый счёт",
|
||||
"account.form.editTitle": "Изменить счёт",
|
||||
"account.form.nameLabel": "Название",
|
||||
"account.form.namePlaceholder": "Основной счёт, накопления, наличные…",
|
||||
"account.form.typeLabel": "Тип",
|
||||
"account.form.saveError": "Не удалось сохранить счёт. Проверьте данные и попробуйте снова.",
|
||||
|
||||
"cashFlow.income": "Доход",
|
||||
"cashFlow.expense": "Расход",
|
||||
"cashFlow.net": "Баланс",
|
||||
"cashFlow.noData": "Нет данных за выбранный период.",
|
||||
|
||||
// Default seeded categories (backend/app/db/seed.py) — there's no
|
||||
// category-management UI yet, so every category currently in the
|
||||
// database is one of these; translateCategoryName() matches on the exact
|
||||
// seeded English name and falls through unchanged for anything else
|
||||
// (a renamed default, or a future custom category).
|
||||
"category.housingUtilities": "Жильё и коммуналка",
|
||||
"category.groceries": "Продукты",
|
||||
"category.diningOut": "Кафе и рестораны",
|
||||
"category.transportation": "Транспорт",
|
||||
"category.healthFitness": "Здоровье и фитнес",
|
||||
"category.shopping": "Покупки",
|
||||
"category.entertainment": "Развлечения",
|
||||
"category.subscriptions": "Подписки",
|
||||
"category.salary": "Зарплата",
|
||||
"category.freelance": "Фриланс",
|
||||
"category.investments": "Инвестиции",
|
||||
"category.gifts": "Подарки",
|
||||
"category.otherIncome": "Прочий доход",
|
||||
|
||||
"months.jan": "Янв",
|
||||
"months.feb": "Фев",
|
||||
"months.mar": "Мар",
|
||||
"months.apr": "Апр",
|
||||
"months.may": "Май",
|
||||
"months.jun": "Июн",
|
||||
"months.jul": "Июл",
|
||||
"months.aug": "Авг",
|
||||
"months.sep": "Сен",
|
||||
"months.oct": "Окт",
|
||||
"months.nov": "Ноя",
|
||||
"months.dec": "Дек",
|
||||
};
|
||||
|
||||
const en: Record<keyof typeof ru, string> = {
|
||||
"nav.dashboard": "Dashboard",
|
||||
"nav.netWorth": "Net Worth",
|
||||
"nav.roi": "Returns",
|
||||
"nav.transactions": "Transactions",
|
||||
"nav.accounts": "Accounts",
|
||||
"nav.cashFlow": "Cash Flow",
|
||||
"nav.reports": "Reports",
|
||||
"nav.budget": "Budget",
|
||||
"nav.recurring": "Recurring",
|
||||
"nav.goals": "Goals",
|
||||
"nav.advice": "Advice",
|
||||
"nav.settings": "Settings",
|
||||
"nav.comingSoon": "soon",
|
||||
|
||||
"sidebar.collapse": "Collapse",
|
||||
"sidebar.collapseMenu": "Collapse menu",
|
||||
"sidebar.expandMenu": "Expand menu",
|
||||
"sidebar.closeMenu": "Close menu",
|
||||
|
||||
"topbar.openMenu": "Open menu",
|
||||
|
||||
"common.loading": "Loading…",
|
||||
"common.back": "Back",
|
||||
"common.next": "Next",
|
||||
"common.cancel": "Cancel",
|
||||
"common.save": "Save",
|
||||
"common.saving": "Saving…",
|
||||
"common.edit": "Edit",
|
||||
"common.delete": "Delete",
|
||||
"common.close": "Close",
|
||||
"common.add": "Add",
|
||||
"common.pageOf": "Page {{page}} of {{total}}",
|
||||
"common.totalCount": "{{count}} total",
|
||||
"common.perMonth": "/mo",
|
||||
"common.allShort": "All",
|
||||
|
||||
"dashboard.errorLoading": "Failed to load dashboard data. Check that the backend is running.",
|
||||
"dashboard.statRealIncomeLabel": "Real income",
|
||||
"dashboard.statRealIncomeCaption": "paychecks, refunds",
|
||||
"dashboard.statSpentLabel": "Spent",
|
||||
"dashboard.statSpentCaption": "excludes transfers",
|
||||
"dashboard.statNetLabel": "Net",
|
||||
"dashboard.statNetCaption": "income − spend",
|
||||
"dashboard.statSavingsRateLabel": "Savings rate",
|
||||
"dashboard.statSavingsRateCaption": "share of income left after spending",
|
||||
"dashboard.spendingByCategoryTitle": "Spending by category",
|
||||
"dashboard.noExpensesThisMonth": "No expenses yet this month. Add your first transaction on the Transactions tab.",
|
||||
"dashboard.recentTransactionsTitle": "Recent transactions",
|
||||
"dashboard.allTransactionsLink": "All transactions",
|
||||
"dashboard.noTransactionsYet": "No transactions yet.",
|
||||
|
||||
"netWorth.periodSuffix": "over period",
|
||||
"netWorth.noChartData": "No data for the chart yet.",
|
||||
"netWorth.assetsTitlePrefix": "Assets",
|
||||
"netWorth.myAssetsTitle": "My assets",
|
||||
"netWorth.confirmDeleteAsset": "Delete asset “{{name}}”?",
|
||||
"netWorth.capitalByType": "Capital by type",
|
||||
|
||||
"netWorth.assetClass.investments": "Investments",
|
||||
"netWorth.assetClass.crypto": "Crypto",
|
||||
"netWorth.assetClass.real_estate": "Real estate",
|
||||
"netWorth.assetClass.vehicles": "Vehicles",
|
||||
"netWorth.assetClass.precious_metals": "Precious metals",
|
||||
"netWorth.assetClass.other": "Other",
|
||||
"netWorth.assetClass.cash": "Cash",
|
||||
|
||||
"netWorth.capitalRole.income": "Income",
|
||||
"netWorth.capitalRole.neutral": "Neutral",
|
||||
"netWorth.capitalRole.drain": "Drain",
|
||||
|
||||
"netWorth.capitalRoleHint.income": "brings in money",
|
||||
"netWorth.capitalRoleHint.neutral": "just sits there — no gain, no drain",
|
||||
"netWorth.capitalRoleHint.drain": "costs money every month",
|
||||
|
||||
"netWorth.capitalRoleFormHint.income": "brings in money — rented out, etc.",
|
||||
"netWorth.capitalRoleFormHint.neutral": "just there / in use — electronics, furniture",
|
||||
"netWorth.capitalRoleFormHint.drain": "costs money every month — a car, etc.",
|
||||
|
||||
"netWorth.riskAllocationTitle": "Capital by risk level",
|
||||
"netWorth.riskAllocationOfCapital": "of capital",
|
||||
"netWorth.riskLevel.low": "Low",
|
||||
"netWorth.riskLevel.medium": "Medium",
|
||||
"netWorth.riskLevel.high": "High",
|
||||
"netWorth.riskLevelFormHint.low": "minimal risk of loss — deposits, cash, government bonds",
|
||||
"netWorth.riskLevelFormHint.medium": "moderate value swings — real estate, diversified investments",
|
||||
"netWorth.riskLevelFormHint.high": "can lose significant value — crypto, individual stocks, startups",
|
||||
|
||||
"netWorth.assetsTable.empty": "No assets yet. Add investments, crypto, real estate, or other property.",
|
||||
"netWorth.assetsTable.asOf": "as of {{date}}",
|
||||
"netWorth.assetsTable.annualRoi": "{{percent}}%/year",
|
||||
|
||||
"roi.calculator.title": "ROI Calculator",
|
||||
"roi.calculator.investmentLabel": "Investment amount",
|
||||
"roi.calculator.monthlyIncomeLabel": "Monthly income",
|
||||
"roi.calculator.annualRoiLabel": "Annual return",
|
||||
"roi.calculator.payback": "Pays back in {{years}} years",
|
||||
"roi.calculator.noPayback": "Doesn't pay back — income doesn't cover the investment",
|
||||
"roi.calculator.hint": "Enter the price and expected monthly income to see the annual return, payback period, and a multi-year projection.",
|
||||
"roi.calculator.projectionTitle": "Compound interest projection",
|
||||
"roi.calculator.projectionHint": "If the {{percent}}% annual return holds and all income is reinvested back into the same investment — based only on the numbers you entered, not your tracked assets.",
|
||||
"roi.calculator.chartCompoundLabel": "With compounding",
|
||||
"roi.calculator.chartSimpleLabel": "Without reinvesting",
|
||||
"roi.calculator.tableTitle": "Year-by-year comparison",
|
||||
"roi.calculator.colCompound": "With compounding",
|
||||
"roi.calculator.colSimple": "Without reinvesting",
|
||||
"roi.calculator.colDifference": "Difference",
|
||||
"roi.calculator.yearOne": "In 1 year",
|
||||
"roi.calculator.yearN": "In {{years}} years",
|
||||
|
||||
"netWorth.form.editTitle": "Edit asset",
|
||||
"netWorth.form.newTitle": "New asset",
|
||||
"netWorth.form.nameLabel": "Name",
|
||||
"netWorth.form.namePlaceholder": "Brokerage account, BTC, apartment…",
|
||||
"netWorth.form.classLabel": "Category",
|
||||
"netWorth.form.currentValueLabel": "Current value",
|
||||
"netWorth.form.valueLabel": "Value",
|
||||
"netWorth.form.dateLabel": "Valuation date",
|
||||
"netWorth.form.roleLabel": "Capital type",
|
||||
"netWorth.form.riskLevelLabel": "Risk level",
|
||||
"netWorth.form.cashFlowLabel": "Monthly cash flow (optional)",
|
||||
"netWorth.form.cashFlowPlaceholder": "800 for income, -150 for expense",
|
||||
"netWorth.form.cashFlowHint": "Approximate — the real amount varies month to month, this isn't strict accounting.",
|
||||
"netWorth.form.notesLabel": "Notes (optional)",
|
||||
"netWorth.form.saveError": "Failed to save the asset. Check the data and try again.",
|
||||
|
||||
"transactions.addButton": "Add",
|
||||
"transactions.allTypes": "All types",
|
||||
"transactions.expense": "Expenses",
|
||||
"transactions.income": "Income",
|
||||
"transactions.transfer": "Transfers",
|
||||
"transactions.allCategories": "All categories",
|
||||
"transactions.failedToLoad": "Failed to load transactions.",
|
||||
"transactions.noneFound": "No transactions found.",
|
||||
"transactions.confirmDelete": "Delete transaction “{{description}}”?",
|
||||
"transactions.transferSuffix": "→ transfer",
|
||||
"transactions.sortDateDesc": "Newest first",
|
||||
"transactions.sortAmountDesc": "Amount: high to low",
|
||||
"transactions.sortAmountAsc": "Amount: low to high",
|
||||
|
||||
"transactions.form.editTitle": "Edit transaction",
|
||||
"transactions.form.newTitle": "New transaction",
|
||||
"transactions.form.typeLabel": "Type",
|
||||
"transactions.form.typeExpense": "Expense",
|
||||
"transactions.form.typeIncome": "Income",
|
||||
"transactions.form.typeTransfer": "Transfer",
|
||||
"transactions.form.amountLabel": "Amount",
|
||||
"transactions.form.dateLabel": "Date",
|
||||
"transactions.form.descriptionLabel": "Description",
|
||||
"transactions.form.descriptionPlaceholder": "Groceries, salary, rent…",
|
||||
"transactions.form.accountLabel": "Account",
|
||||
"transactions.form.selectAccount": "Select account",
|
||||
"transactions.form.transferAccountLabel": "Destination account",
|
||||
"transactions.form.categoryLabel": "Category",
|
||||
"transactions.form.noCategory": "No category",
|
||||
"transactions.form.merchantLabel": "Merchant (optional)",
|
||||
"transactions.form.errorSelectAccount": "Select an account",
|
||||
"transactions.form.errorSelectDestination": "Select a destination account for the transfer",
|
||||
"transactions.form.errorSameAccount": "The destination account must be different from the source account",
|
||||
"transactions.form.saveError": "Failed to save the transaction. Check the data and try again.",
|
||||
|
||||
"reports.categoryLabel": "Category",
|
||||
"reports.expenseGroup": "Expenses",
|
||||
"reports.incomeGroup": "Income",
|
||||
"reports.rangeAll": "All time",
|
||||
"reports.range5y": "5 years",
|
||||
"reports.range12m": "12 mo",
|
||||
"reports.rangeThisYear": "This year",
|
||||
"reports.transactionsTitle": "Transactions",
|
||||
"reports.defaultChartTitle": "Spending by category",
|
||||
"reports.averagePerMonth": "{{amount}}/mo average",
|
||||
"reports.noDataForPeriod": "No data for the selected period.",
|
||||
"reports.categoryRankingTitle": "Top categories for period",
|
||||
"reports.noRankingData": "No expenses for the selected period.",
|
||||
|
||||
"settings.language": "Language",
|
||||
"settings.languageRussian": "Русский",
|
||||
"settings.languageEnglish": "English",
|
||||
"settings.currency": "Primary currency",
|
||||
"settings.currencyHint":
|
||||
"Only changes how amounts are displayed — values you've already entered aren't recalculated or converted.",
|
||||
"settings.alertsTitle": "Proactive alerts",
|
||||
"settings.alertsHint": "How many consecutive months before showing a warning on Dashboard and Net Worth.",
|
||||
"settings.negativeCashFlowThresholdLabel": "Expenses exceed income, consecutive months",
|
||||
"settings.netWorthDeclineThresholdLabel": "Net worth declining, consecutive months",
|
||||
"settings.riskyAllocationThresholdLabel": "Max % of capital allowed at risk",
|
||||
|
||||
"backup.title": "Backup & Restore",
|
||||
"backup.description":
|
||||
"Export all your data (accounts, categories, transactions, assets) into a single JSON file and keep it somewhere safe. Later, you can load that file back in to fully restore your data — for example, after a reinstall or moving to a different server.",
|
||||
"backup.exportButton": "Export backup",
|
||||
"backup.exporting": "Exporting…",
|
||||
"backup.importButton": "Restore from file",
|
||||
"backup.importing": "Restoring…",
|
||||
"backup.exportSuccess": "Backup downloaded.",
|
||||
"backup.exportError": "Failed to download the backup.",
|
||||
"backup.importSuccess": "Data restored from backup.",
|
||||
"backup.importError": "Failed to restore the backup.",
|
||||
"backup.invalidFile": "The file is corrupted or is not an Aurum JSON backup.",
|
||||
"backup.confirmImport":
|
||||
"Restore data from “{{filename}}”?\n\nThis will COMPLETELY replace all current accounts, categories, transactions, and assets with the data from the file. This action cannot be undone — if you want to keep your current data, export it first.",
|
||||
"backup.footerWarning":
|
||||
"Restoring completely replaces your current data with the file's contents — this cannot be undone.",
|
||||
|
||||
"insights.negativeCashFlow": "Expenses have exceeded income for {{months}} months in a row (based on the last completed months)",
|
||||
"insights.netWorthDecline": "Net worth has been declining for {{months}} months in a row",
|
||||
"insights.budgetExceededOne": "Budget exceeded in 1 category this month",
|
||||
"insights.budgetExceeded": "Budget exceeded in {{count}} categories this month",
|
||||
"insights.riskyAllocationExceeded": "Risky share of capital is {{percent}}%, above your {{threshold}}% limit",
|
||||
|
||||
"budget.empty": "No budgets yet. Set a limit for an expense category to start tracking it here.",
|
||||
"budget.remaining": "{{amount}} left",
|
||||
"budget.overBy": "Over by {{amount}}",
|
||||
"budget.confirmDelete": "Delete the budget for “{{name}}”?",
|
||||
"budget.form.newTitle": "New budget",
|
||||
"budget.form.editTitle": "Edit budget",
|
||||
"budget.form.categoryLabel": "Category",
|
||||
"budget.form.limitLabel": "Monthly limit",
|
||||
"budget.form.noCategoriesAvailable": "Every expense category already has a budget.",
|
||||
"budget.form.saveError": "Failed to save the budget. Check the data and try again.",
|
||||
|
||||
"advice.empty": "No advice right now — not enough data, or everything looks fine.",
|
||||
"advice.risingCategory":
|
||||
"Spending on {{category}} is up {{percent}}% vs. the average of the last 3 months: {{current}} vs {{average}}.",
|
||||
"advice.unbudgetedTopCategory":
|
||||
"\"{{category}}\" is your biggest expense this month — {{amount}}. Set a budget for it on the Budget tab to track a limit.",
|
||||
"advice.savingsRateTrend": "Savings rate: {{rate}}% ({{diff}} pts vs last month).",
|
||||
|
||||
"goal.empty": "No goals yet. Add one and start saving.",
|
||||
"goal.remaining": "{{amount}} left",
|
||||
"goal.remainingWithDate": "{{amount}} left · by {{date}}",
|
||||
"goal.reached": "Goal reached!",
|
||||
"goal.contributeLabel": "Add contribution",
|
||||
"goal.confirmDelete": "Delete the goal “{{name}}”?",
|
||||
"goal.form.newTitle": "New goal",
|
||||
"goal.form.editTitle": "Edit goal",
|
||||
"goal.form.nameLabel": "Name",
|
||||
"goal.form.namePlaceholder": "Emergency fund, vacation, new laptop…",
|
||||
"goal.form.targetAmountLabel": "Target amount",
|
||||
"goal.form.targetDateLabel": "Target date (optional)",
|
||||
"goal.form.saveError": "Failed to save the goal. Check the data and try again.",
|
||||
"goal.contribution.title": "Contribution: {{name}}",
|
||||
"goal.contribution.amountLabel": "Amount",
|
||||
"goal.contribution.amountPlaceholder": "100 to deposit, -50 to withdraw",
|
||||
"goal.contribution.dateLabel": "Date",
|
||||
"goal.contribution.hint": "You can enter a negative amount if you had to withdraw from the goal.",
|
||||
"goal.contribution.saveError": "Failed to save the contribution. Check the data and try again.",
|
||||
|
||||
"recurring.empty":
|
||||
"No recurring transactions yet. Add your salary, rent, or a subscription so you don't have to enter it manually every time.",
|
||||
"recurring.postLabel": "Post now",
|
||||
"recurring.confirmDelete": "Delete the recurring transaction “{{name}}”?",
|
||||
"recurring.overdueDays": "{{days}} days overdue",
|
||||
"recurring.dueToday": "Due today",
|
||||
"recurring.dueInDays": "In {{days}} days · {{date}}",
|
||||
"recurring.frequency.weekly": "Weekly",
|
||||
"recurring.frequency.monthly": "Monthly",
|
||||
"recurring.frequency.yearly": "Yearly",
|
||||
"recurring.form.newTitle": "New recurring transaction",
|
||||
"recurring.form.editTitle": "Edit recurring transaction",
|
||||
"recurring.form.frequencyLabel": "Frequency",
|
||||
"recurring.form.anchorDateLabel": "Start date",
|
||||
"recurring.form.saveError": "Failed to save the recurring transaction. Check the data and try again.",
|
||||
|
||||
"account.empty": "No accounts yet. Add your first one — a checking account or cash, for example.",
|
||||
"account.showArchived": "Show archived",
|
||||
"account.archivedBadge": "archived",
|
||||
"account.archiveLabel": "Archive",
|
||||
"account.unarchiveLabel": "Unarchive",
|
||||
"account.confirmDelete":
|
||||
"Delete the account “{{name}}”?\n\nThis will permanently delete ALL transactions on this account too. If you want to keep the history, archive the account instead of deleting it.",
|
||||
"account.type.checking": "Checking",
|
||||
"account.type.savings": "Savings",
|
||||
"account.type.credit_card": "Credit card",
|
||||
"account.type.cash": "Cash",
|
||||
"account.type.investment": "Investment",
|
||||
"account.type.other": "Other",
|
||||
"account.form.newTitle": "New account",
|
||||
"account.form.editTitle": "Edit account",
|
||||
"account.form.nameLabel": "Name",
|
||||
"account.form.namePlaceholder": "Main account, savings, cash…",
|
||||
"account.form.typeLabel": "Type",
|
||||
"account.form.saveError": "Failed to save the account. Check the data and try again.",
|
||||
|
||||
"cashFlow.income": "Income",
|
||||
"cashFlow.expense": "Expense",
|
||||
"cashFlow.net": "Net",
|
||||
"cashFlow.noData": "No data for the selected period.",
|
||||
|
||||
"category.housingUtilities": "Housing & Utilities",
|
||||
"category.groceries": "Groceries",
|
||||
"category.diningOut": "Dining Out",
|
||||
"category.transportation": "Transportation",
|
||||
"category.healthFitness": "Health & Fitness",
|
||||
"category.shopping": "Shopping",
|
||||
"category.entertainment": "Entertainment",
|
||||
"category.subscriptions": "Subscriptions",
|
||||
"category.salary": "Salary",
|
||||
"category.freelance": "Freelance",
|
||||
"category.investments": "Investments",
|
||||
"category.gifts": "Gifts",
|
||||
"category.otherIncome": "Other Income",
|
||||
|
||||
"months.jan": "Jan",
|
||||
"months.feb": "Feb",
|
||||
"months.mar": "Mar",
|
||||
"months.apr": "Apr",
|
||||
"months.may": "May",
|
||||
"months.jun": "Jun",
|
||||
"months.jul": "Jul",
|
||||
"months.aug": "Aug",
|
||||
"months.sep": "Sep",
|
||||
"months.oct": "Oct",
|
||||
"months.nov": "Nov",
|
||||
"months.dec": "Dec",
|
||||
};
|
||||
|
||||
const translations: Record<Language, Record<keyof typeof ru, string>> = { ru, en };
|
||||
|
||||
export type TranslationKey = keyof typeof ru;
|
||||
|
||||
function readInitialLanguage(): Language {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "ru" || stored === "en") return stored;
|
||||
return "ru";
|
||||
}
|
||||
|
||||
let currentLanguage: Language = readInitialLanguage();
|
||||
// The primary display currency (ISO 4217 code, e.g. "USD"/"UAH"/"EUR"). Unlike
|
||||
// language this isn't client-only — it's persisted server-side (GET/PATCH
|
||||
// /api/settings) since it also drives what new accounts/assets are created
|
||||
// with. App.tsx syncs this from the server once on boot via setCurrency();
|
||||
// until that resolves, formatCurrency() falls back to this default.
|
||||
let currentCurrency = "USD";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getLanguage(): Language {
|
||||
return currentLanguage;
|
||||
}
|
||||
|
||||
export function setLanguage(language: Language): void {
|
||||
if (language === currentLanguage) return;
|
||||
currentLanguage = language;
|
||||
localStorage.setItem(STORAGE_KEY, language);
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function getCurrency(): string {
|
||||
return currentCurrency;
|
||||
}
|
||||
|
||||
/** Updates the reactive local mirror only — call this after the server
|
||||
* fetch/update succeeds, not in place of it. */
|
||||
export function setCurrency(currency: string): void {
|
||||
if (currency === currentCurrency) return;
|
||||
currentCurrency = currency;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
const value = params[key];
|
||||
return value === undefined ? match : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
/** Usable outside React (e.g. api/ modules) — reads the current language at call time. */
|
||||
export function t(key: TranslationKey, params?: Record<string, string | number>): string {
|
||||
return interpolate(translations[currentLanguage][key], params);
|
||||
}
|
||||
|
||||
/** Subscribes the component to language AND currency changes so it re-renders
|
||||
* on either toggle — most components that call formatCurrency() already call
|
||||
* this hook for text, so that alone is enough to pick up a fresh currency on
|
||||
* their next render even without reading `currency` from the return value. */
|
||||
export function useTranslation() {
|
||||
const language = useSyncExternalStore(subscribe, () => currentLanguage);
|
||||
const currency = useSyncExternalStore(subscribe, () => currentCurrency);
|
||||
return { t, language, setLanguage, currency, setCurrency };
|
||||
}
|
||||
52
frontend/src/lib/icons.tsx
Normal file
52
frontend/src/lib/icons.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Banknote,
|
||||
Bitcoin,
|
||||
Briefcase,
|
||||
Building2,
|
||||
Car,
|
||||
Clapperboard,
|
||||
Gem,
|
||||
Gift,
|
||||
HeartPulse,
|
||||
Home,
|
||||
MoreHorizontal,
|
||||
Package,
|
||||
PlusCircle,
|
||||
Repeat,
|
||||
ShoppingBag,
|
||||
ShoppingBasket,
|
||||
TrendingUp,
|
||||
Utensils,
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
// Maps the backend's plain-string icon keys (Category.icon) to a concrete
|
||||
// lucide component. New categories fall back to a generic wallet glyph so an
|
||||
// unmapped icon key never breaks rendering.
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
home: Home,
|
||||
"shopping-basket": ShoppingBasket,
|
||||
utensils: Utensils,
|
||||
car: Car,
|
||||
"heart-pulse": HeartPulse,
|
||||
"shopping-bag": ShoppingBag,
|
||||
clapperboard: Clapperboard,
|
||||
repeat: Repeat,
|
||||
banknote: Banknote,
|
||||
briefcase: Briefcase,
|
||||
"trending-up": TrendingUp,
|
||||
gift: Gift,
|
||||
"plus-circle": PlusCircle,
|
||||
"more-horizontal": MoreHorizontal,
|
||||
wallet: Wallet,
|
||||
bitcoin: Bitcoin,
|
||||
"building-2": Building2,
|
||||
package: Package,
|
||||
gem: Gem,
|
||||
};
|
||||
|
||||
export function getCategoryIcon(icon: string | null | undefined): LucideIcon {
|
||||
if (!icon) return Wallet;
|
||||
return ICON_MAP[icon] ?? Wallet;
|
||||
}
|
||||
39
frontend/src/lib/navigation.ts
Normal file
39
frontend/src/lib/navigation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeftRight,
|
||||
Calculator,
|
||||
Flag,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
LayoutDashboard,
|
||||
PieChart,
|
||||
Repeat,
|
||||
Settings,
|
||||
Target,
|
||||
TrendingUp,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { TranslationKey } from "@/lib/i18n";
|
||||
|
||||
export interface NavItem {
|
||||
labelKey: TranslationKey;
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ labelKey: "nav.dashboard", to: "/", icon: LayoutDashboard },
|
||||
{ labelKey: "nav.netWorth", to: "/net-worth", icon: TrendingUp },
|
||||
{ labelKey: "nav.roi", to: "/roi", icon: Calculator },
|
||||
{ labelKey: "nav.transactions", to: "/transactions", icon: ArrowLeftRight },
|
||||
{ labelKey: "nav.accounts", to: "/accounts", icon: Layers },
|
||||
{ labelKey: "nav.cashFlow", to: "/cash-flow", icon: Activity },
|
||||
{ labelKey: "nav.reports", to: "/reports", icon: PieChart },
|
||||
{ labelKey: "nav.budget", to: "/budget", icon: Target },
|
||||
{ labelKey: "nav.recurring", to: "/recurring", icon: Repeat },
|
||||
{ labelKey: "nav.goals", to: "/goals", icon: Flag },
|
||||
{ labelKey: "nav.advice", to: "/advice", icon: Lightbulb },
|
||||
{ labelKey: "nav.settings", to: "/settings", icon: Settings },
|
||||
];
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
25
frontend/src/main.tsx
Normal file
25
frontend/src/main.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "@/App";
|
||||
import "@/index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
77
frontend/src/pages/AccountsPage.tsx
Normal file
77
frontend/src/pages/AccountsPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { AccountList } from "@/components/accounts/AccountList";
|
||||
import { AccountFormModal } from "@/components/accounts/AccountFormModal";
|
||||
import { useAccounts, useDeleteAccount, useUpdateAccount } from "@/hooks/useAccounts";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { Account, AccountWithBalance } from "@/types";
|
||||
|
||||
export function AccountsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const { data: accounts, isLoading } = useAccounts(showArchived);
|
||||
const updateAccount = useUpdateAccount();
|
||||
const deleteAccount = useDeleteAccount();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingAccount(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(account: Account) {
|
||||
setEditingAccount(account);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleToggleArchived(account: AccountWithBalance) {
|
||||
updateAccount.mutate({ id: account.id, input: { is_archived: !account.is_archived } });
|
||||
}
|
||||
|
||||
function handleDelete(account: AccountWithBalance) {
|
||||
if (window.confirm(t("account.confirmDelete", { name: account.name }))) {
|
||||
deleteAccount.mutate(account.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.accounts")}</CardTitle>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus size={16} />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<label className="mb-3 flex items-center gap-2 text-xs text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(event) => setShowArchived(event.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-text-primary"
|
||||
/>
|
||||
{t("account.showArchived")}
|
||||
</label>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<AccountList
|
||||
items={accounts ?? []}
|
||||
onEdit={openEditModal}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AccountFormModal open={modalOpen} onClose={() => setModalOpen(false)} account={editingAccount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
frontend/src/pages/AdvicePage.tsx
Normal file
84
frontend/src/pages/AdvicePage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Lightbulb, TrendingUp, TriangleAlert } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { useAdvice } from "@/hooks/useAdvice";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { AdviceItem } from "@/types";
|
||||
|
||||
const TONE_STYLES: Record<AdviceItem["tone"], string> = {
|
||||
positive: "border-success/30 bg-success/10 text-success",
|
||||
warning: "border-danger/30 bg-danger/10 text-danger",
|
||||
neutral: "border-border bg-surface-1 text-text-primary",
|
||||
};
|
||||
|
||||
const TONE_ICONS: Record<AdviceItem["tone"], typeof Lightbulb> = {
|
||||
positive: TrendingUp,
|
||||
warning: TriangleAlert,
|
||||
neutral: Lightbulb,
|
||||
};
|
||||
|
||||
// Each advice key needs its own params → translated message mapping since
|
||||
// some params (amounts) must be currency-formatted and category names
|
||||
// translated before interpolation — unlike AlertBanner's alerts, which only
|
||||
// ever interpolate raw numbers.
|
||||
function adviceMessage(item: AdviceItem, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||
switch (item.key) {
|
||||
case "rising_category":
|
||||
return t("advice.risingCategory", {
|
||||
category: translateCategoryName(String(item.params.category)),
|
||||
percent: item.params.percent,
|
||||
current: formatCurrency(Number(item.params.current)),
|
||||
average: formatCurrency(Number(item.params.average)),
|
||||
});
|
||||
case "unbudgeted_top_category":
|
||||
return t("advice.unbudgetedTopCategory", {
|
||||
category: translateCategoryName(String(item.params.category)),
|
||||
amount: formatCurrency(Number(item.params.amount)),
|
||||
});
|
||||
case "savings_rate_trend": {
|
||||
const diff = Number(item.params.diff);
|
||||
return t("advice.savingsRateTrend", { rate: item.params.rate, diff: `${diff > 0 ? "+" : ""}${diff}` });
|
||||
}
|
||||
default:
|
||||
return item.key;
|
||||
}
|
||||
}
|
||||
|
||||
export function AdvicePage() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useAdvice();
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.advice")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("advice.empty")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const Icon = TONE_ICONS[item.tone];
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`flex items-start gap-2.5 rounded-lg border px-4 py-3 text-sm ${TONE_STYLES[item.tone]}`}
|
||||
>
|
||||
<Icon size={16} className="mt-0.5 shrink-0" />
|
||||
<span>{adviceMessage(item, t)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/BudgetPage.tsx
Normal file
81
frontend/src/pages/BudgetPage.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { MonthSelector } from "@/components/layout/MonthSelector";
|
||||
import { AlertBanner } from "@/components/insights/AlertBanner";
|
||||
import { BudgetList } from "@/components/budget/BudgetList";
|
||||
import { BudgetFormModal } from "@/components/budget/BudgetFormModal";
|
||||
import { useBudgets, useBudgetStatus, useDeleteBudget } from "@/hooks/useBudgets";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { Budget, BudgetStatus } from "@/types";
|
||||
|
||||
export function BudgetPage() {
|
||||
const { t } = useTranslation();
|
||||
const now = new Date();
|
||||
const [year] = useState(now.getFullYear());
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
|
||||
const { data: budgets } = useBudgets();
|
||||
const { data: status, isLoading } = useBudgetStatus(year, month);
|
||||
const deleteBudget = useDeleteBudget();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingBudget(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(item: BudgetStatus) {
|
||||
setEditingBudget({
|
||||
id: item.budget_id,
|
||||
category_id: item.category_id,
|
||||
category_name: item.category_name,
|
||||
category_color: item.category_color,
|
||||
category_icon: item.category_icon,
|
||||
monthly_limit: item.monthly_limit,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(item: BudgetStatus) {
|
||||
if (window.confirm(t("budget.confirmDelete", { name: translateCategoryName(item.category_name) }))) {
|
||||
deleteBudget.mutate(item.budget_id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<AlertBanner />
|
||||
|
||||
<MonthSelector month={month} onChange={setMonth} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.budget")}</CardTitle>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus size={16} />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<BudgetList items={status?.items ?? []} onEdit={openEditModal} onDelete={handleDelete} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<BudgetFormModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
budget={editingBudget}
|
||||
excludeCategoryIds={budgets?.map((budget) => budget.category_id) ?? []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/pages/CashFlowPage.tsx
Normal file
54
frontend/src/pages/CashFlowPage.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { PillSelector } from "@/components/layout/PillSelector";
|
||||
import { CashFlowChart } from "@/components/cashflow/CashFlowChart";
|
||||
import { useCashFlow } from "@/hooks/useCashFlow";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
type RangePreset = "all" | "5y" | "12m" | "this_year";
|
||||
|
||||
function isoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function computeRange(preset: RangePreset): { startDate?: string; endDate?: string } {
|
||||
const today = new Date();
|
||||
const end = isoDate(today);
|
||||
switch (preset) {
|
||||
case "all":
|
||||
return {};
|
||||
case "this_year":
|
||||
return { startDate: `${today.getFullYear()}-01-01`, endDate: end };
|
||||
case "12m": {
|
||||
const start = new Date(today.getFullYear(), today.getMonth() - 11, 1);
|
||||
return { startDate: isoDate(start), endDate: end };
|
||||
}
|
||||
case "5y": {
|
||||
const start = new Date(today.getFullYear() - 5, today.getMonth(), 1);
|
||||
return { startDate: isoDate(start), endDate: end };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function CashFlowPage() {
|
||||
const { t } = useTranslation();
|
||||
const [range, setRange] = useState<RangePreset>("12m");
|
||||
const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [
|
||||
{ value: "all", label: t("reports.rangeAll") },
|
||||
{ value: "5y", label: t("reports.range5y") },
|
||||
{ value: "12m", label: t("reports.range12m") },
|
||||
{ value: "this_year", label: t("reports.rangeThisYear") },
|
||||
];
|
||||
|
||||
const { startDate, endDate } = computeRange(range);
|
||||
const { data: cashFlow, isLoading } = useCashFlow(startDate, endDate);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex justify-end">
|
||||
<PillSelector options={RANGE_OPTIONS} value={range} onChange={setRange} />
|
||||
</div>
|
||||
|
||||
<CashFlowChart cashFlow={cashFlow} isLoading={isLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/DashboardPage.tsx
Normal file
76
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { MonthSelector } from "@/components/layout/MonthSelector";
|
||||
import { StatCard } from "@/components/dashboard/StatCard";
|
||||
import { SpendingByCategoryCard } from "@/components/dashboard/SpendingByCategoryCard";
|
||||
import { RecentTransactionsCard } from "@/components/dashboard/RecentTransactionsCard";
|
||||
import { AlertBanner } from "@/components/insights/AlertBanner";
|
||||
import { useDashboardSummary } from "@/hooks/useDashboard";
|
||||
import { formatCurrency, formatSignedCurrency } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
/** Share of income left over after spending (net / real_income). `null` when
|
||||
* there was no income to take a share of, rather than a misleading 0%. */
|
||||
function savingsRate(realIncome: number, net: number): number | null {
|
||||
return realIncome > 0 ? (net / realIncome) * 100 : null;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${value.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const now = new Date();
|
||||
const [year] = useState(now.getFullYear());
|
||||
const [month, setMonth] = useState(now.getMonth() + 1);
|
||||
|
||||
const { data, isLoading, isError } = useDashboardSummary(year, month);
|
||||
const rate = data ? savingsRate(Number(data.real_income), Number(data.net)) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<AlertBanner excludeKeys={["risky_allocation_exceeded"]} />
|
||||
|
||||
<MonthSelector month={month} onChange={setMonth} />
|
||||
|
||||
{isError && (
|
||||
<p className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
|
||||
{t("dashboard.errorLoading")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label={t("dashboard.statRealIncomeLabel")}
|
||||
value={isLoading ? "…" : formatCurrency(data?.real_income ?? 0)}
|
||||
caption={t("dashboard.statRealIncomeCaption")}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.statSpentLabel")}
|
||||
value={isLoading ? "…" : formatCurrency(data?.spent ?? 0)}
|
||||
caption={t("dashboard.statSpentCaption")}
|
||||
tone="danger"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.statNetLabel")}
|
||||
value={isLoading ? "…" : formatSignedCurrency(data?.net ?? 0)}
|
||||
caption={t("dashboard.statNetCaption")}
|
||||
tone={Number(data?.net ?? 0) >= 0 ? "success" : "danger"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.statSavingsRateLabel")}
|
||||
value={isLoading ? "…" : rate === null ? "—" : formatPercent(rate)}
|
||||
caption={t("dashboard.statSavingsRateCaption")}
|
||||
tone={rate === null ? "default" : rate >= 0 ? "success" : "danger"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.4fr_1fr]">
|
||||
<SpendingByCategoryCard items={data?.spending_by_category ?? []} />
|
||||
<RecentTransactionsCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/pages/GoalsPage.tsx
Normal file
75
frontend/src/pages/GoalsPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { GoalList } from "@/components/goals/GoalList";
|
||||
import { GoalFormModal } from "@/components/goals/GoalFormModal";
|
||||
import { GoalContributionModal } from "@/components/goals/GoalContributionModal";
|
||||
import { useDeleteGoal, useGoals } from "@/hooks/useGoals";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { Goal } from "@/types";
|
||||
|
||||
export function GoalsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: goals, isLoading } = useGoals();
|
||||
const deleteGoal = useDeleteGoal();
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingGoal, setEditingGoal] = useState<Goal | null>(null);
|
||||
const [contributionOpen, setContributionOpen] = useState(false);
|
||||
const [contributingGoal, setContributingGoal] = useState<Goal | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingGoal(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(goal: Goal) {
|
||||
setEditingGoal(goal);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openContributionModal(goal: Goal) {
|
||||
setContributingGoal(goal);
|
||||
setContributionOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(goal: Goal) {
|
||||
if (window.confirm(t("goal.confirmDelete", { name: goal.name }))) {
|
||||
deleteGoal.mutate(goal.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.goals")}</CardTitle>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus size={16} />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<GoalList
|
||||
items={goals ?? []}
|
||||
onContribute={openContributionModal}
|
||||
onEdit={openEditModal}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<GoalFormModal open={formOpen} onClose={() => setFormOpen(false)} goal={editingGoal} />
|
||||
<GoalContributionModal
|
||||
open={contributionOpen}
|
||||
onClose={() => setContributionOpen(false)}
|
||||
goal={contributingGoal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/pages/NetWorthPage.tsx
Normal file
75
frontend/src/pages/NetWorthPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { NetWorthChart } from "@/components/networth/NetWorthChart";
|
||||
import { AssetAllocationCard } from "@/components/networth/AssetAllocationCard";
|
||||
import { CapitalRoleSummaryCard } from "@/components/networth/CapitalRoleSummaryCard";
|
||||
import { RiskAllocationCard } from "@/components/networth/RiskAllocationCard";
|
||||
import { AssetsTable } from "@/components/networth/AssetsTable";
|
||||
import { AssetFormModal } from "@/components/networth/AssetFormModal";
|
||||
import { AlertBanner } from "@/components/insights/AlertBanner";
|
||||
import { useNetWorthSummary } from "@/hooks/useNetWorth";
|
||||
import { useAssets, useDeleteAsset } from "@/hooks/useAssets";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { Asset, NetWorthRange } from "@/types";
|
||||
|
||||
export function NetWorthPage() {
|
||||
const { t } = useTranslation();
|
||||
const [range, setRange] = useState<NetWorthRange>("30d");
|
||||
const { data: summary, isLoading: isSummaryLoading } = useNetWorthSummary(range);
|
||||
const { data: assets, isLoading: isAssetsLoading } = useAssets();
|
||||
const deleteAsset = useDeleteAsset();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingAsset, setEditingAsset] = useState<Asset | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingAsset(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(asset: Asset) {
|
||||
setEditingAsset(asset);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(asset: Asset) {
|
||||
if (window.confirm(t("netWorth.confirmDeleteAsset", { name: asset.name }))) {
|
||||
deleteAsset.mutate(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<AlertBanner />
|
||||
|
||||
<NetWorthChart summary={summary} isLoading={isSummaryLoading} range={range} onRangeChange={setRange} />
|
||||
|
||||
<AssetAllocationCard breakdown={summary?.breakdown ?? []} isLoading={isSummaryLoading} />
|
||||
|
||||
<CapitalRoleSummaryCard roles={summary?.capital_roles ?? []} isLoading={isSummaryLoading} />
|
||||
|
||||
<RiskAllocationCard riskLevels={summary?.risk_levels ?? []} isLoading={isSummaryLoading} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("netWorth.myAssetsTitle")}</CardTitle>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus size={16} />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isAssetsLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<AssetsTable items={assets ?? []} onEdit={openEditModal} onDelete={handleDelete} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AssetFormModal open={modalOpen} onClose={() => setModalOpen(false)} asset={editingAsset} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
frontend/src/pages/RecurringPage.tsx
Normal file
68
frontend/src/pages/RecurringPage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { RecurringList } from "@/components/recurring/RecurringList";
|
||||
import { RecurringFormModal } from "@/components/recurring/RecurringFormModal";
|
||||
import { useDeleteRecurring, usePostRecurring, useRecurring } from "@/hooks/useRecurring";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { RecurringTransaction } from "@/types";
|
||||
|
||||
export function RecurringPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: items, isLoading } = useRecurring();
|
||||
const deleteRecurring = useDeleteRecurring();
|
||||
const postRecurring = usePostRecurring();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<RecurringTransaction | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingItem(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(item: RecurringTransaction) {
|
||||
setEditingItem(item);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(item: RecurringTransaction) {
|
||||
if (window.confirm(t("recurring.confirmDelete", { name: item.description }))) {
|
||||
deleteRecurring.mutate(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePost(item: RecurringTransaction) {
|
||||
postRecurring.mutate(item.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("nav.recurring")}</CardTitle>
|
||||
<Button onClick={openCreateModal}>
|
||||
<Plus size={16} />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<RecurringList
|
||||
items={items ?? []}
|
||||
onPost={handlePost}
|
||||
onEdit={openEditModal}
|
||||
onDelete={handleDelete}
|
||||
isPosting={postRecurring.isPending}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RecurringFormModal open={modalOpen} onClose={() => setModalOpen(false)} recurring={editingItem} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
frontend/src/pages/ReportsPage.tsx
Normal file
198
frontend/src/pages/ReportsPage.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Label, Select } from "@/components/ui/Input";
|
||||
import { PillSelector } from "@/components/layout/PillSelector";
|
||||
import { CategoryRankingCard } from "@/components/reports/CategoryRankingCard";
|
||||
import { CategorySpendingChart } from "@/components/reports/CategorySpendingChart";
|
||||
import { TransactionsTable } from "@/components/transactions/TransactionsTable";
|
||||
import { TransactionFormModal } from "@/components/transactions/TransactionFormModal";
|
||||
import { useCategories } from "@/hooks/useCategories";
|
||||
import { useCategoryRanking, useCategorySpendingReport } from "@/hooks/useReports";
|
||||
import { useDeleteTransaction, useTransactions } from "@/hooks/useTransactions";
|
||||
import type { TransactionSort } from "@/api/transactions";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { Transaction } from "@/types";
|
||||
|
||||
type RangePreset = "all" | "5y" | "12m" | "this_year";
|
||||
|
||||
function isoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function computeRange(preset: RangePreset): { startDate?: string; endDate?: string } {
|
||||
const today = new Date();
|
||||
const end = isoDate(today);
|
||||
switch (preset) {
|
||||
case "all":
|
||||
return {};
|
||||
case "this_year":
|
||||
return { startDate: `${today.getFullYear()}-01-01`, endDate: end };
|
||||
case "12m": {
|
||||
const start = new Date(today.getFullYear(), today.getMonth() - 11, 1);
|
||||
return { startDate: isoDate(start), endDate: end };
|
||||
}
|
||||
case "5y": {
|
||||
const start = new Date(today.getFullYear() - 5, today.getMonth(), 1);
|
||||
return { startDate: isoDate(start), endDate: end };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function ReportsPage() {
|
||||
const { t } = useTranslation();
|
||||
const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [
|
||||
{ value: "all", label: t("reports.rangeAll") },
|
||||
{ value: "5y", label: t("reports.range5y") },
|
||||
{ value: "12m", label: t("reports.range12m") },
|
||||
{ value: "this_year", label: t("reports.rangeThisYear") },
|
||||
];
|
||||
const { data: categories } = useCategories();
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||
const [range, setRange] = useState<RangePreset>("all");
|
||||
const [sort, setSort] = useState<TransactionSort>("date_desc");
|
||||
const [page, setPage] = useState(1);
|
||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryId === null && categories && categories.length > 0) {
|
||||
const firstExpense = categories.find((category) => category.kind === "expense");
|
||||
setCategoryId((firstExpense ?? categories[0]).id);
|
||||
}
|
||||
}, [categories, categoryId]);
|
||||
|
||||
const { startDate, endDate } = computeRange(range);
|
||||
const { data: ranking, isLoading: isRankingLoading } = useCategoryRanking("expense", startDate, endDate);
|
||||
const { data: report, isLoading: isReportLoading } = useCategorySpendingReport(categoryId, startDate, endDate);
|
||||
const { data: transactions, isLoading: isTransactionsLoading } = useTransactions({
|
||||
category_id: categoryId ?? undefined,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
sort,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
const deleteTransaction = useDeleteTransaction();
|
||||
|
||||
const expenseCategories = categories?.filter((category) => category.kind === "expense") ?? [];
|
||||
const incomeCategories = categories?.filter((category) => category.kind === "income") ?? [];
|
||||
const totalPages = transactions ? Math.max(1, Math.ceil(transactions.total / PAGE_SIZE)) : 1;
|
||||
|
||||
function handleEdit(transaction: Transaction) {
|
||||
setEditingTransaction(transaction);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleDelete(transaction: Transaction) {
|
||||
if (window.confirm(t("transactions.confirmDelete", { description: transaction.description }))) {
|
||||
deleteTransaction.mutate(transaction.id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="max-w-xs flex-1">
|
||||
<Label htmlFor="report-category">{t("reports.categoryLabel")}</Label>
|
||||
<Select
|
||||
id="report-category"
|
||||
value={categoryId ?? ""}
|
||||
onChange={(event) => {
|
||||
setCategoryId(Number(event.target.value));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{expenseCategories.length > 0 && (
|
||||
<optgroup label={t("reports.expenseGroup")}>
|
||||
{expenseCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{incomeCategories.length > 0 && (
|
||||
<optgroup label={t("reports.incomeGroup")}>
|
||||
{incomeCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
<PillSelector
|
||||
options={RANGE_OPTIONS}
|
||||
value={range}
|
||||
onChange={(value) => {
|
||||
setRange(value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CategoryRankingCard
|
||||
items={ranking?.items ?? []}
|
||||
isLoading={isRankingLoading}
|
||||
selectedCategoryId={categoryId}
|
||||
onSelectCategory={(id) => {
|
||||
setCategoryId(id);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CategorySpendingChart report={report} isLoading={isReportLoading} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("reports.transactionsTitle")}</CardTitle>
|
||||
{transactions && (
|
||||
<span className="text-xs text-text-muted">{t("common.totalCount", { count: transactions.total })}</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
setSort(event.target.value as TransactionSort);
|
||||
setPage(1);
|
||||
}}
|
||||
className="mb-3 sm:w-56"
|
||||
>
|
||||
<option value="date_desc">{t("transactions.sortDateDesc")}</option>
|
||||
<option value="amount_desc">{t("transactions.sortAmountDesc")}</option>
|
||||
<option value="amount_asc">{t("transactions.sortAmountAsc")}</option>
|
||||
</Select>
|
||||
{isTransactionsLoading ? (
|
||||
<p className="py-12 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<TransactionsTable items={transactions?.items ?? []} onEdit={handleEdit} onDelete={handleDelete} />
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-3 text-sm">
|
||||
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<span className="text-text-muted">{t("common.pageOf", { page, total: totalPages })}</span>
|
||||
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TransactionFormModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
transaction={editingTransaction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/pages/RoiPage.tsx
Normal file
37
frontend/src/pages/RoiPage.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import { RoiCalculatorCard } from "@/components/roi/RoiCalculatorCard";
|
||||
import { RoiProjectionCard } from "@/components/roi/RoiProjectionCard";
|
||||
|
||||
export function RoiPage() {
|
||||
const [investment, setInvestment] = useState("");
|
||||
const [monthlyIncome, setMonthlyIncome] = useState("");
|
||||
|
||||
const investmentAmount = Number(investment);
|
||||
const monthlyAmount = Number(monthlyIncome);
|
||||
const hasInputs = investment !== "" && monthlyIncome !== "" && investmentAmount > 0;
|
||||
|
||||
const annualIncome = monthlyAmount * 12;
|
||||
const annualRoiPercent = hasInputs ? (annualIncome / investmentAmount) * 100 : null;
|
||||
const paybackYears = annualRoiPercent !== null && annualIncome > 0 ? investmentAmount / annualIncome : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<RoiCalculatorCard
|
||||
investment={investment}
|
||||
onInvestmentChange={setInvestment}
|
||||
monthlyIncome={monthlyIncome}
|
||||
onMonthlyIncomeChange={setMonthlyIncome}
|
||||
annualRoiPercent={annualRoiPercent}
|
||||
paybackYears={paybackYears}
|
||||
/>
|
||||
|
||||
{annualRoiPercent !== null && (
|
||||
<RoiProjectionCard
|
||||
investmentAmount={investmentAmount}
|
||||
annualIncome={annualIncome}
|
||||
annualRoiPercent={annualRoiPercent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user