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:
ZProger
2026-08-16 18:39:52 +03:00
commit fe9b079c12
199 changed files with 14410 additions and 0 deletions

14
frontend/.dockerignore Normal file
View 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
View 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

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View 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"
}
}

View 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
View 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>
);
}

View 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}`);
}

View File

@@ -0,0 +1,6 @@
import { api } from "@/api/client";
import type { AdviceResponse } from "@/types";
export function fetchAdvice() {
return api.get<AdviceResponse>("/advice");
}

View 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}`);
}

View 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);
}

View 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}`);
}

View 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}` : ""}`);
}

View File

@@ -0,0 +1,6 @@
import { api } from "@/api/client";
import type { Category } from "@/types";
export function fetchCategories() {
return api.get<Category[]>("/categories");
}

View 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" }),
};

View 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
View 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);
}

View 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");
}

View 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}`);
}

View 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`, {});
}

View 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()}`);
}

View 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);
}

View 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}`);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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}
/>
);
}

View 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} />;
}

View 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>
);
}

View 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} />;
}

View 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"] });
},
});
}

View 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 });
}

View 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,
});
}

View 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,
});
}

View 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),
});
}

View 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 });
}

View 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),
});
}

View 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,
});
}

View 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,
});
}

View 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;
}

View 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),
});
}

View 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),
});
}

View 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),
});
}

View 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);
},
});
}

View 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
View 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);
}

View 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;
}

View 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;
}

View 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
View 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 };
}

View 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;
}

View 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 },
];

View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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