diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..9bd6831 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,114 @@ +# Frontend — Family Budget SPA + +React SPA для учёта семейного бюджета: импорт банковских выписок, категоризация операций, аналитика. + +## Стек + +- **React 19** + **TypeScript** +- **Vite** — сборка и dev-сервер +- **React Router 7** — маршрутизация +- **Recharts** — графики (столбиковые, круговые) +- **@family-budget/shared** — общие TypeScript-типы (API-контракты) + +## Структура + +``` +frontend/ +├── index.html — точка входа +├── vite.config.ts — конфигурация Vite (прокси на backend) +├── tsconfig.json +└── src/ + ├── main.tsx — рендер корневого компонента + ├── App.tsx — маршруты и проверка авторизации + ├── styles/ + │ └── index.css — глобальные стили, CSS-переменные + ├── api/ — модули запросов к backend + │ ├── client.ts — fetch-обёртка с обработкой 401 + │ ├── auth.ts — login, logout, me + │ ├── transactions.ts — GET/PUT /api/transactions + │ ├── accounts.ts — GET/PUT /api/accounts + │ ├── categories.ts — GET /api/categories + │ ├── rules.ts — CRUD /api/category-rules + │ ├── analytics.ts — summary, by-category, timeseries + │ └── import.ts — POST /api/import/statement + ├── context/ + │ └── AuthContext.tsx — провайдер авторизации + ├── pages/ + │ ├── LoginPage.tsx — форма входа + │ ├── HistoryPage.tsx — таблица операций с фильтрами + │ ├── AnalyticsPage.tsx — сводка, графики, категории + │ └── SettingsPage.tsx — счета, категории, правила + ├── components/ + │ ├── Layout.tsx — боковое меню, обёртка + │ ├── TransactionFilters.tsx + │ ├── TransactionTable.tsx + │ ├── Pagination.tsx + │ ├── EditTransactionModal.tsx + │ ├── ImportModal.tsx + │ ├── PeriodSelector.tsx + │ ├── SummaryCards.tsx + │ ├── TimeseriesChart.tsx + │ ├── CategoryChart.tsx + │ ├── AccountsList.tsx + │ ├── CategoriesList.tsx + │ └── RulesList.tsx + └── utils/ + └── format.ts — форматирование сумм и дат +``` + +## Экраны + +| Экран | Маршрут | Описание | +| ------------| -------------| -------------------------------------------------------| +| Логин | — | Отображается при отсутствии сессии | +| Операции | `/history` | Таблица транзакций, фильтры, импорт, редактирование | +| Аналитика | `/analytics` | Сводка, динамика (bar chart), расходы по категориям | +| Настройки | `/settings` | Счета (алиасы), категории (просмотр), правила | + +## Требования + +- Node.js >= 20 +- Собранный пакет `@family-budget/shared` (см. корневой README) +- Запущенный backend на `http://localhost:3000` + +## Команды + +```bash +# Установка зависимостей (из корня монорепо) +npm install + +# Сборка shared-типов (если ещё не собраны) +npm run build -w shared + +# Запуск dev-сервера (порт 5173, прокси /api → localhost:3000) +npm run dev -w frontend + +# Production-сборка +npm run build -w frontend + +# Предпросмотр production-сборки +npm run preview -w frontend +``` + +## Прокси + +В dev-режиме Vite проксирует все запросы `/api/*` на `http://localhost:3000` (см. `vite.config.ts`). В production фронтенд отдаётся как статика, а проксирование настраивается на уровне reverse proxy (nginx и т.п.). + +## Авторизация + +- При загрузке SPA выполняется `GET /api/auth/me`. +- Если сессия не активна — отображается форма логина. +- При получении `401` от любого API-запроса — автоматический сброс состояния и возврат к форме логина. +- Таймаут сессии — 3 часа бездействия (управляется backend). + +## API-контракты + +Типы запросов и ответов определены в `@family-budget/shared` и описаны в спецификациях: + +- `docs/backlog/auth.md` — авторизация +- `docs/backlog/api_history.md` — история операций +- `docs/backlog/api_import.md` — импорт выписок +- `docs/backlog/api_reference_accounts_categories.md` — справочники +- `docs/backlog/api_rules.md` — правила категоризации +- `docs/backlog/analytics.md` — аналитика +- `docs/backlog/edit_and_rules.md` — редактирование транзакций diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..efdac18 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + Семейный бюджет + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c399da6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "@family-budget/frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@family-budget/shared": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.2.0", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.7.0", + "vite": "^6.2.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..135747f --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,31 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuth } from './context/AuthContext'; +import { Layout } from './components/Layout'; +import { LoginPage } from './pages/LoginPage'; +import { HistoryPage } from './pages/HistoryPage'; +import { AnalyticsPage } from './pages/AnalyticsPage'; +import { SettingsPage } from './pages/SettingsPage'; + +export function App() { + const { user, loading } = useAuth(); + + if (loading) { + return
Загрузка...
; + } + + if (!user) { + return ; + } + + return ( + + + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/api/accounts.ts b/frontend/src/api/accounts.ts new file mode 100644 index 0000000..b7eba60 --- /dev/null +++ b/frontend/src/api/accounts.ts @@ -0,0 +1,13 @@ +import type { Account, UpdateAccountRequest } from '@family-budget/shared'; +import { api } from './client'; + +export async function getAccounts(): Promise { + return api.get('/api/accounts'); +} + +export async function updateAccount( + id: number, + data: UpdateAccountRequest, +): Promise { + return api.put(`/api/accounts/${id}`, data); +} diff --git a/frontend/src/api/analytics.ts b/frontend/src/api/analytics.ts new file mode 100644 index 0000000..389ddd1 --- /dev/null +++ b/frontend/src/api/analytics.ts @@ -0,0 +1,38 @@ +import type { + AnalyticsSummaryParams, + AnalyticsSummaryResponse, + ByCategoryParams, + ByCategoryItem, + TimeseriesParams, + TimeseriesItem, +} from '@family-budget/shared'; +import { api } from './client'; + +function toQuery(params: object): string { + const sp = new URLSearchParams(); + for (const [key, value] of Object.entries(params) as [string, unknown][]) { + if (value != null && value !== '') { + sp.set(key, String(value)); + } + } + const qs = sp.toString(); + return qs ? `?${qs}` : ''; +} + +export async function getSummary( + params: AnalyticsSummaryParams, +): Promise { + return api.get(`/api/analytics/summary${toQuery(params)}`); +} + +export async function getByCategory( + params: ByCategoryParams, +): Promise { + return api.get(`/api/analytics/by-category${toQuery(params)}`); +} + +export async function getTimeseries( + params: TimeseriesParams, +): Promise { + return api.get(`/api/analytics/timeseries${toQuery(params)}`); +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..2da7ab7 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,14 @@ +import type { LoginRequest, MeResponse } from '@family-budget/shared'; +import { api } from './client'; + +export async function login(data: LoginRequest): Promise { + await api.post('/api/auth/login', data); +} + +export async function logout(): Promise { + await api.post('/api/auth/logout'); +} + +export async function getMe(): Promise { + return api.get('/api/auth/me'); +} diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts new file mode 100644 index 0000000..782802d --- /dev/null +++ b/frontend/src/api/categories.ts @@ -0,0 +1,13 @@ +import type { Category, GetCategoriesParams } from '@family-budget/shared'; +import { api } from './client'; + +export async function getCategories( + params?: GetCategoriesParams, +): Promise { + const sp = new URLSearchParams(); + if (params?.isActive != null) { + sp.set('isActive', String(params.isActive)); + } + const qs = sp.toString(); + return api.get(`/api/categories${qs ? `?${qs}` : ''}`); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..ccfeede --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,62 @@ +import type { ApiError } from '@family-budget/shared'; + +let onUnauthorized: (() => void) | null = null; + +export function setOnUnauthorized(cb: () => void) { + onUnauthorized = cb; +} + +export class ApiException extends Error { + constructor( + public status: number, + public body: ApiError, + ) { + super(body.message); + } +} + +async function request(url: string, options: RequestInit = {}): Promise { + const res = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }, + credentials: 'include', + }); + + if (res.status === 401) { + onUnauthorized?.(); + throw new ApiException(401, { error: 'UNAUTHORIZED', message: 'Сессия истекла' }); + } + + if (!res.ok) { + let body: ApiError; + try { + body = await res.json(); + } catch { + body = { error: 'UNKNOWN', message: res.statusText }; + } + throw new ApiException(res.status, body); + } + + if (res.status === 204) return undefined as T; + + return res.json(); +} + +export const api = { + get: (url: string) => request(url), + + post: (url: string, body?: unknown) => + request(url, { + method: 'POST', + body: body != null ? JSON.stringify(body) : undefined, + }), + + put: (url: string, body: unknown) => + request(url, { method: 'PUT', body: JSON.stringify(body) }), + + patch: (url: string, body: unknown) => + request(url, { method: 'PATCH', body: JSON.stringify(body) }), +}; diff --git a/frontend/src/api/import.ts b/frontend/src/api/import.ts new file mode 100644 index 0000000..64bf447 --- /dev/null +++ b/frontend/src/api/import.ts @@ -0,0 +1,8 @@ +import type { ImportStatementResponse } from '@family-budget/shared'; +import { api } from './client'; + +export async function importStatement( + file: unknown, +): Promise { + return api.post('/api/import/statement', file); +} diff --git a/frontend/src/api/rules.ts b/frontend/src/api/rules.ts new file mode 100644 index 0000000..37e9d41 --- /dev/null +++ b/frontend/src/api/rules.ts @@ -0,0 +1,40 @@ +import type { + CategoryRule, + GetCategoryRulesParams, + CreateCategoryRuleRequest, + UpdateCategoryRuleRequest, + ApplyRuleResponse, +} from '@family-budget/shared'; +import { api } from './client'; + +export async function getCategoryRules( + params?: GetCategoryRulesParams, +): Promise { + const sp = new URLSearchParams(); + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value != null && value !== '') { + sp.set(key, String(value)); + } + } + } + const qs = sp.toString(); + return api.get(`/api/category-rules${qs ? `?${qs}` : ''}`); +} + +export async function createCategoryRule( + data: CreateCategoryRuleRequest, +): Promise { + return api.post('/api/category-rules', data); +} + +export async function updateCategoryRule( + id: number, + data: UpdateCategoryRuleRequest, +): Promise { + return api.patch(`/api/category-rules/${id}`, data); +} + +export async function applyRule(id: number): Promise { + return api.post(`/api/category-rules/${id}/apply`); +} diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts new file mode 100644 index 0000000..c36980c --- /dev/null +++ b/frontend/src/api/transactions.ts @@ -0,0 +1,27 @@ +import type { + Transaction, + GetTransactionsParams, + PaginatedResponse, + UpdateTransactionRequest, +} from '@family-budget/shared'; +import { api } from './client'; + +export async function getTransactions( + params: GetTransactionsParams, +): Promise> { + const sp = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value != null && value !== '') { + sp.set(key, String(value)); + } + } + const qs = sp.toString(); + return api.get(`/api/transactions${qs ? `?${qs}` : ''}`); +} + +export async function updateTransaction( + id: number, + data: UpdateTransactionRequest, +): Promise { + return api.put(`/api/transactions/${id}`, data); +} diff --git a/frontend/src/components/AccountsList.tsx b/frontend/src/components/AccountsList.tsx new file mode 100644 index 0000000..dbf88e2 --- /dev/null +++ b/frontend/src/components/AccountsList.tsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from 'react'; +import type { Account } from '@family-budget/shared'; +import { getAccounts, updateAccount } from '../api/accounts'; + +export function AccountsList() { + const [accounts, setAccounts] = useState([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editAlias, setEditAlias] = useState(''); + + useEffect(() => { + setLoading(true); + getAccounts() + .then(setAccounts) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const handleEdit = (account: Account) => { + setEditingId(account.id); + setEditAlias(account.alias || ''); + }; + + const handleSave = async (id: number) => { + try { + const updated = await updateAccount(id, { + alias: editAlias.trim(), + }); + setAccounts((prev) => + prev.map((a) => (a.id === id ? updated : a)), + ); + setEditingId(null); + } catch { + // error handled globally + } + }; + + if (loading) { + return
Загрузка...
; + } + + return ( +
+ + + + + + + + + + + + {accounts.map((a) => ( + + + + + + + + ))} + {accounts.length === 0 && ( + + + + )} + +
БанкНомер счётаВалютаАлиас
{a.bank}{a.accountNumberMasked}{a.currency} + {editingId === a.id ? ( + setEditAlias(e.target.value)} + maxLength={50} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(a.id); + if (e.key === 'Escape') setEditingId(null); + }} + /> + ) : ( + a.alias || ( + не задан + ) + )} + + {editingId === a.id ? ( +
+ + +
+ ) : ( + + )} +
+ Нет счетов. Импортируйте выписку. +
+
+ ); +} diff --git a/frontend/src/components/CategoriesList.tsx b/frontend/src/components/CategoriesList.tsx new file mode 100644 index 0000000..ac7c77f --- /dev/null +++ b/frontend/src/components/CategoriesList.tsx @@ -0,0 +1,51 @@ +import { useState, useEffect } from 'react'; +import type { Category } from '@family-budget/shared'; +import { getCategories } from '../api/categories'; + +const TYPE_LABELS: Record = { + expense: 'Расход', + income: 'Доход', + transfer: 'Перевод', +}; + +export function CategoriesList() { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + getCategories({ isActive: true }) + .then(setCategories) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return
Загрузка...
; + } + + return ( +
+ + + + + + + + + {categories.map((c) => ( + + + + + ))} + +
КатегорияТип
{c.name} + + {TYPE_LABELS[c.type] ?? c.type} + +
+
+ ); +} diff --git a/frontend/src/components/CategoryChart.tsx b/frontend/src/components/CategoryChart.tsx new file mode 100644 index 0000000..9d65e5c --- /dev/null +++ b/frontend/src/components/CategoryChart.tsx @@ -0,0 +1,101 @@ +import { + PieChart, + Pie, + Cell, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import type { ByCategoryItem } from '@family-budget/shared'; +import { formatAmount } from '../utils/format'; + +interface Props { + data: ByCategoryItem[]; +} + +const COLORS = [ + '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', + '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1', + '#14b8a6', '#e11d48', '#0ea5e9', '#a855f7', '#22c55e', +]; + +const rubFormatter = new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + maximumFractionDigits: 0, +}); + +export function CategoryChart({ data }: Props) { + if (data.length === 0) { + return
Нет данных за период
; + } + + const chartData = data.map((item) => ({ + name: item.categoryName, + value: Math.abs(item.amount) / 100, + txCount: item.txCount, + share: item.share, + })); + + return ( +
+ + + + `${name} ${(share * 100).toFixed(0)}%` + } + labelLine + > + {chartData.map((_, idx) => ( + + ))} + + rubFormatter.format(value)} + /> + + + + + + + + + + + + + + {data.map((item, idx) => ( + + + + + + + ))} + +
КатегорияСуммаОперацийДоля
+ + {item.categoryName} + {formatAmount(item.amount)}{item.txCount} + {(item.share * 100).toFixed(1)}% +
+
+ ); +} diff --git a/frontend/src/components/EditTransactionModal.tsx b/frontend/src/components/EditTransactionModal.tsx new file mode 100644 index 0000000..5455b0c --- /dev/null +++ b/frontend/src/components/EditTransactionModal.tsx @@ -0,0 +1,197 @@ +import { useState, type FormEvent } from 'react'; +import type { + Transaction, + Category, + CreateCategoryRuleRequest, +} from '@family-budget/shared'; +import { updateTransaction } from '../api/transactions'; +import { createCategoryRule } from '../api/rules'; +import { formatAmount, formatDateTime } from '../utils/format'; + +interface Props { + transaction: Transaction; + categories: Category[]; + onClose: () => void; + onSave: () => void; +} + +function extractPattern(description: string): string { + return description + .replace(/Оплата товаров и услуг\.\s*/i, '') + .replace(/\s*по карте\s*\*\d+.*/i, '') + .replace(/\s*Перевод средств.*/i, '') + .trim() + .slice(0, 50); +} + +export function EditTransactionModal({ + transaction, + categories, + onClose, + onSave, +}: Props) { + const [categoryId, setCategoryId] = useState( + transaction.categoryId != null ? String(transaction.categoryId) : '', + ); + const [comment, setComment] = useState(transaction.comment || ''); + const [createRule, setCreateRule] = useState(true); + const [pattern, setPattern] = useState( + extractPattern(transaction.description), + ); + const [requiresConfirmation, setRequiresConfirmation] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setSaving(true); + setError(''); + + try { + await updateTransaction(transaction.id, { + categoryId: categoryId ? Number(categoryId) : null, + comment: comment || null, + }); + + if (createRule && categoryId && pattern.trim()) { + const ruleData: CreateCategoryRuleRequest = { + pattern: pattern.trim(), + matchType: 'contains', + categoryId: Number(categoryId), + priority: 100, + requiresConfirmation, + }; + await createCategoryRule(ruleData); + } + + onSave(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Ошибка сохранения'; + setError(msg); + } finally { + setSaving(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Редактирование операции

+ +
+ +
+
+ {error &&
{error}
} + +
+
+ Дата + {formatDateTime(transaction.operationAt)} +
+
+ Сумма + {formatAmount(transaction.amountSigned)} +
+
+ Описание + + {transaction.description} + +
+
+ +
+ + +
+ +
+ +