feat: creats frontend for the project
This commit is contained in:
114
frontend/README.md
Normal file
114
frontend/README.md
Normal file
@@ -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` — редактирование транзакций
|
||||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Семейный бюджет</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/src/App.tsx
Normal file
31
frontend/src/App.tsx
Normal file
@@ -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 <div className="app-loading">Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <LoginPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/history" replace />} />
|
||||||
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/history" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/src/api/accounts.ts
Normal file
13
frontend/src/api/accounts.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Account, UpdateAccountRequest } from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export async function getAccounts(): Promise<Account[]> {
|
||||||
|
return api.get<Account[]>('/api/accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAccount(
|
||||||
|
id: number,
|
||||||
|
data: UpdateAccountRequest,
|
||||||
|
): Promise<Account> {
|
||||||
|
return api.put<Account>(`/api/accounts/${id}`, data);
|
||||||
|
}
|
||||||
38
frontend/src/api/analytics.ts
Normal file
38
frontend/src/api/analytics.ts
Normal file
@@ -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<AnalyticsSummaryResponse> {
|
||||||
|
return api.get(`/api/analytics/summary${toQuery(params)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getByCategory(
|
||||||
|
params: ByCategoryParams,
|
||||||
|
): Promise<ByCategoryItem[]> {
|
||||||
|
return api.get(`/api/analytics/by-category${toQuery(params)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTimeseries(
|
||||||
|
params: TimeseriesParams,
|
||||||
|
): Promise<TimeseriesItem[]> {
|
||||||
|
return api.get(`/api/analytics/timeseries${toQuery(params)}`);
|
||||||
|
}
|
||||||
14
frontend/src/api/auth.ts
Normal file
14
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { LoginRequest, MeResponse } from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export async function login(data: LoginRequest): Promise<void> {
|
||||||
|
await api.post('/api/auth/login', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await api.post('/api/auth/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<MeResponse> {
|
||||||
|
return api.get<MeResponse>('/api/auth/me');
|
||||||
|
}
|
||||||
13
frontend/src/api/categories.ts
Normal file
13
frontend/src/api/categories.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Category, GetCategoriesParams } from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export async function getCategories(
|
||||||
|
params?: GetCategoriesParams,
|
||||||
|
): Promise<Category[]> {
|
||||||
|
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}` : ''}`);
|
||||||
|
}
|
||||||
62
frontend/src/api/client.ts
Normal file
62
frontend/src/api/client.ts
Normal file
@@ -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<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
},
|
||||||
|
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: <T>(url: string) => request<T>(url),
|
||||||
|
|
||||||
|
post: <T>(url: string, body?: unknown) =>
|
||||||
|
request<T>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
put: <T>(url: string, body: unknown) =>
|
||||||
|
request<T>(url, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
patch: <T>(url: string, body: unknown) =>
|
||||||
|
request<T>(url, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
};
|
||||||
8
frontend/src/api/import.ts
Normal file
8
frontend/src/api/import.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export async function importStatement(
|
||||||
|
file: unknown,
|
||||||
|
): Promise<ImportStatementResponse> {
|
||||||
|
return api.post<ImportStatementResponse>('/api/import/statement', file);
|
||||||
|
}
|
||||||
40
frontend/src/api/rules.ts
Normal file
40
frontend/src/api/rules.ts
Normal file
@@ -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<CategoryRule[]> {
|
||||||
|
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<CategoryRule> {
|
||||||
|
return api.post<CategoryRule>('/api/category-rules', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCategoryRule(
|
||||||
|
id: number,
|
||||||
|
data: UpdateCategoryRuleRequest,
|
||||||
|
): Promise<CategoryRule> {
|
||||||
|
return api.patch<CategoryRule>(`/api/category-rules/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyRule(id: number): Promise<ApplyRuleResponse> {
|
||||||
|
return api.post<ApplyRuleResponse>(`/api/category-rules/${id}/apply`);
|
||||||
|
}
|
||||||
27
frontend/src/api/transactions.ts
Normal file
27
frontend/src/api/transactions.ts
Normal file
@@ -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<PaginatedResponse<Transaction>> {
|
||||||
|
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<Transaction> {
|
||||||
|
return api.put<Transaction>(`/api/transactions/${id}`, data);
|
||||||
|
}
|
||||||
117
frontend/src/components/AccountsList.tsx
Normal file
117
frontend/src/components/AccountsList.tsx
Normal file
@@ -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<Account[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(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 <div className="section-loading">Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Банк</th>
|
||||||
|
<th>Номер счёта</th>
|
||||||
|
<th>Валюта</th>
|
||||||
|
<th>Алиас</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{a.bank}</td>
|
||||||
|
<td>{a.accountNumberMasked}</td>
|
||||||
|
<td>{a.currency}</td>
|
||||||
|
<td>
|
||||||
|
{editingId === a.id ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editAlias}
|
||||||
|
onChange={(e) => setEditAlias(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSave(a.id);
|
||||||
|
if (e.key === 'Escape') setEditingId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
a.alias || (
|
||||||
|
<span className="text-muted">не задан</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editingId === a.id ? (
|
||||||
|
<div className="btn-group">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => handleSave(a.id)}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-secondary"
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-secondary"
|
||||||
|
onClick={() => handleEdit(a)}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{accounts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="td-center text-muted">
|
||||||
|
Нет счетов. Импортируйте выписку.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/CategoriesList.tsx
Normal file
51
frontend/src/components/CategoriesList.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
expense: 'Расход',
|
||||||
|
income: 'Доход',
|
||||||
|
transfer: 'Перевод',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CategoriesList() {
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getCategories({ isActive: true })
|
||||||
|
.then(setCategories)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="section-loading">Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge badge-${c.type}`}>
|
||||||
|
{TYPE_LABELS[c.type] ?? c.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
frontend/src/components/CategoryChart.tsx
Normal file
101
frontend/src/components/CategoryChart.tsx
Normal file
@@ -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 <div className="chart-empty">Нет данных за период</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = data.map((item) => ({
|
||||||
|
name: item.categoryName,
|
||||||
|
value: Math.abs(item.amount) / 100,
|
||||||
|
txCount: item.txCount,
|
||||||
|
share: item.share,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="category-chart-wrapper">
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
label={({ name, share }: { name: string; share: number }) =>
|
||||||
|
`${name} ${(share * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
labelLine
|
||||||
|
>
|
||||||
|
{chartData.map((_, idx) => (
|
||||||
|
<Cell
|
||||||
|
key={idx}
|
||||||
|
fill={COLORS[idx % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => rubFormatter.format(value)}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
<table className="category-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th className="th-center">Операций</th>
|
||||||
|
<th className="th-center">Доля</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item, idx) => (
|
||||||
|
<tr key={item.categoryId}>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className="color-dot"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
COLORS[idx % COLORS.length],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{item.categoryName}
|
||||||
|
</td>
|
||||||
|
<td>{formatAmount(item.amount)}</td>
|
||||||
|
<td className="td-center">{item.txCount}</td>
|
||||||
|
<td className="td-center">
|
||||||
|
{(item.share * 100).toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend/src/components/EditTransactionModal.tsx
Normal file
197
frontend/src/components/EditTransactionModal.tsx
Normal file
@@ -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<string>(
|
||||||
|
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 (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Редактирование операции</h2>
|
||||||
|
<button className="btn-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="modal-tx-info">
|
||||||
|
<div className="modal-tx-row">
|
||||||
|
<span className="modal-tx-label">Дата</span>
|
||||||
|
<span>{formatDateTime(transaction.operationAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="modal-tx-row">
|
||||||
|
<span className="modal-tx-label">Сумма</span>
|
||||||
|
<span>{formatAmount(transaction.amountSigned)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="modal-tx-row">
|
||||||
|
<span className="modal-tx-label">Описание</span>
|
||||||
|
<span className="modal-tx-description">
|
||||||
|
{transaction.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="edit-category">Категория</label>
|
||||||
|
<select
|
||||||
|
id="edit-category"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— Без категории —</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="edit-comment">Комментарий</label>
|
||||||
|
<textarea
|
||||||
|
id="edit-comment"
|
||||||
|
rows={2}
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Комментарий к операции..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-divider" />
|
||||||
|
|
||||||
|
<div className="form-group form-group-checkbox">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createRule}
|
||||||
|
onChange={(e) => setCreateRule(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Создать правило для похожих транзакций
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createRule && (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="edit-pattern">
|
||||||
|
Шаблон (ключевая строка)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="edit-pattern"
|
||||||
|
type="text"
|
||||||
|
value={pattern}
|
||||||
|
onChange={(e) => setPattern(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group form-group-checkbox">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={requiresConfirmation}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRequiresConfirmation(e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Требовать подтверждения категории
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
frontend/src/components/ImportModal.tsx
Normal file
164
frontend/src/components/ImportModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||||
|
import { importStatement } from '../api/import';
|
||||||
|
import { updateAccount } from '../api/accounts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportModal({ onClose, onDone }: Props) {
|
||||||
|
const [result, setResult] = useState<ImportStatementResponse | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [alias, setAlias] = useState('');
|
||||||
|
const [aliasSaved, setAliasSaved] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = async (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const resp = await importStatement(json);
|
||||||
|
setResult(resp);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof SyntaxError) {
|
||||||
|
setError('Некорректный JSON-файл');
|
||||||
|
} else {
|
||||||
|
const msg =
|
||||||
|
err instanceof Error ? err.message : 'Ошибка импорта';
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAlias = async () => {
|
||||||
|
if (!result || !alias.trim()) return;
|
||||||
|
try {
|
||||||
|
await updateAccount(result.accountId, { alias: alias.trim() });
|
||||||
|
setAliasSaved(true);
|
||||||
|
} catch {
|
||||||
|
// handled globally
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Импорт выписки</h2>
|
||||||
|
<button className="btn-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
{!result && (
|
||||||
|
<div className="import-upload">
|
||||||
|
<p>Выберите JSON-файл выписки (формат 1.0)</p>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="file-input"
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<div className="import-loading">Импорт...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="import-result">
|
||||||
|
<div className="import-result-icon">✓</div>
|
||||||
|
<h3>Импорт завершён</h3>
|
||||||
|
<table className="import-stats">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Счёт</td>
|
||||||
|
<td>{result.accountNumberMasked}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Новый счёт</td>
|
||||||
|
<td>{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Импортировано</td>
|
||||||
|
<td>{result.imported}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Дубликатов пропущено</td>
|
||||||
|
<td>{result.duplicatesSkipped}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Всего в файле</td>
|
||||||
|
<td>{result.totalInFile}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{result.isNewAccount && !aliasSaved && (
|
||||||
|
<div className="import-alias">
|
||||||
|
<label>Алиас для нового счёта</label>
|
||||||
|
<div className="import-alias-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Напр.: Текущий, Накопительный"
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={handleSaveAlias}
|
||||||
|
disabled={!alias.trim()}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aliasSaved && (
|
||||||
|
<div className="import-alias-saved">
|
||||||
|
Алиас сохранён
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
{result ? (
|
||||||
|
<button className="btn btn-primary" onClick={onDone}>
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/src/components/Layout.tsx
Normal file
72
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-brand">
|
||||||
|
<span className="sidebar-brand-icon">₽</span>
|
||||||
|
<span className="sidebar-brand-text">Семейный бюджет</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
<NavLink
|
||||||
|
to="/history"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-link${isActive ? ' active' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10,9 9,9 8,9" />
|
||||||
|
</svg>
|
||||||
|
Операции
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/analytics"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-link${isActive ? ' active' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="20" x2="18" y2="10" />
|
||||||
|
<line x1="12" y1="20" x2="12" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="14" />
|
||||||
|
</svg>
|
||||||
|
Аналитика
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/settings"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-link${isActive ? ' active' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
Настройки
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<span className="sidebar-user">{user?.login}</span>
|
||||||
|
<button className="btn-logout" onClick={() => logout()}>
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="main-content">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/components/Pagination.tsx
Normal file
58
frontend/src/components/Pagination.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
interface Props {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalItems,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: Props) {
|
||||||
|
const from = (page - 1) * pageSize + 1;
|
||||||
|
const to = Math.min(page * pageSize, totalItems);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pagination">
|
||||||
|
<div className="pagination-info">
|
||||||
|
{totalItems > 0
|
||||||
|
? `Показано ${from}–${to} из ${totalItems}`
|
||||||
|
: 'Нет записей'}
|
||||||
|
</div>
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<select
|
||||||
|
className="pagination-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn-page"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span className="pagination-current">
|
||||||
|
{page} / {totalPages || 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn-page"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/components/PeriodSelector.tsx
Normal file
134
frontend/src/components/PeriodSelector.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { toISODate } from '../utils/format';
|
||||||
|
|
||||||
|
export type PeriodMode = 'week' | 'month' | 'year' | 'custom';
|
||||||
|
|
||||||
|
export interface PeriodState {
|
||||||
|
mode: PeriodMode;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
period: PeriodState;
|
||||||
|
onChange: (period: PeriodState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<PeriodMode, string> = {
|
||||||
|
week: 'Неделя',
|
||||||
|
month: 'Месяц',
|
||||||
|
year: 'Год',
|
||||||
|
custom: 'Период',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PeriodSelector({ period, onChange }: Props) {
|
||||||
|
const setMode = (mode: PeriodMode) => {
|
||||||
|
const now = new Date();
|
||||||
|
let from: Date;
|
||||||
|
switch (mode) {
|
||||||
|
case 'week': {
|
||||||
|
const day = now.getDay();
|
||||||
|
const diff = day === 0 ? 6 : day - 1;
|
||||||
|
from = new Date(now);
|
||||||
|
from.setDate(now.getDate() - diff);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'month':
|
||||||
|
from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
from = new Date(now.getFullYear(), 0, 1);
|
||||||
|
break;
|
||||||
|
case 'custom':
|
||||||
|
onChange({ mode, from: period.from, to: period.to });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({ mode, from: toISODate(from), to: toISODate(now) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigate = (direction: -1 | 1) => {
|
||||||
|
const fromDate = new Date(period.from);
|
||||||
|
let newFrom: Date;
|
||||||
|
let newTo: Date;
|
||||||
|
|
||||||
|
switch (period.mode) {
|
||||||
|
case 'week':
|
||||||
|
newFrom = new Date(fromDate);
|
||||||
|
newFrom.setDate(fromDate.getDate() + 7 * direction);
|
||||||
|
newTo = new Date(newFrom);
|
||||||
|
newTo.setDate(newFrom.getDate() + 6);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
newFrom = new Date(
|
||||||
|
fromDate.getFullYear(),
|
||||||
|
fromDate.getMonth() + direction,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
newTo = new Date(
|
||||||
|
newFrom.getFullYear(),
|
||||||
|
newFrom.getMonth() + 1,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
newFrom = new Date(fromDate.getFullYear() + direction, 0, 1);
|
||||||
|
newTo = new Date(newFrom.getFullYear(), 11, 31);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
mode: period.mode,
|
||||||
|
from: toISODate(newFrom),
|
||||||
|
to: toISODate(newTo),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="period-selector">
|
||||||
|
<div className="period-modes">
|
||||||
|
{(['week', 'month', 'year', 'custom'] as PeriodMode[]).map(
|
||||||
|
(m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
className={`btn-preset ${period.mode === m ? 'active' : ''}`}
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
>
|
||||||
|
{MODE_LABELS[m]}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="period-nav">
|
||||||
|
{period.mode !== 'custom' && (
|
||||||
|
<button className="btn-page" onClick={() => navigate(-1)}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="period-dates">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={period.from}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...period, mode: 'custom', from: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="filter-separator">—</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={period.to}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...period, mode: 'custom', to: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{period.mode !== 'custom' && (
|
||||||
|
<button className="btn-page" onClick={() => navigate(1)}>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/src/components/RulesList.tsx
Normal file
130
frontend/src/components/RulesList.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { CategoryRule } from '@family-budget/shared';
|
||||||
|
import {
|
||||||
|
getCategoryRules,
|
||||||
|
updateCategoryRule,
|
||||||
|
applyRule,
|
||||||
|
} from '../api/rules';
|
||||||
|
import { formatDate } from '../utils/format';
|
||||||
|
|
||||||
|
export function RulesList() {
|
||||||
|
const [rules, setRules] = useState<CategoryRule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [applyingId, setApplyingId] = useState<number | null>(null);
|
||||||
|
const [applyResult, setApplyResult] = useState<{
|
||||||
|
id: number;
|
||||||
|
count: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getCategoryRules()
|
||||||
|
.then(setRules)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = async (rule: CategoryRule) => {
|
||||||
|
try {
|
||||||
|
const updated = await updateCategoryRule(rule.id, {
|
||||||
|
isActive: !rule.isActive,
|
||||||
|
});
|
||||||
|
setRules((prev) =>
|
||||||
|
prev.map((r) => (r.id === rule.id ? updated : r)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// error handled globally
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = async (id: number) => {
|
||||||
|
setApplyingId(id);
|
||||||
|
try {
|
||||||
|
const resp = await applyRule(id);
|
||||||
|
setApplyResult({ id, count: resp.applied });
|
||||||
|
setTimeout(() => setApplyResult(null), 4000);
|
||||||
|
} catch {
|
||||||
|
// error handled globally
|
||||||
|
} finally {
|
||||||
|
setApplyingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="section-loading">Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Шаблон</th>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th className="th-center">Приоритет</th>
|
||||||
|
<th className="th-center">Подтверждение</th>
|
||||||
|
<th>Создано</th>
|
||||||
|
<th className="th-center">Активно</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rules.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className={!r.isActive ? 'row-inactive' : ''}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<code>{r.pattern}</code>
|
||||||
|
</td>
|
||||||
|
<td>{r.categoryName}</td>
|
||||||
|
<td className="td-center">{r.priority}</td>
|
||||||
|
<td className="td-center">
|
||||||
|
{r.requiresConfirmation ? 'Да' : 'Нет'}
|
||||||
|
</td>
|
||||||
|
<td className="td-nowrap">
|
||||||
|
{formatDate(r.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="td-center">
|
||||||
|
<button
|
||||||
|
className={`toggle ${r.isActive ? 'toggle-on' : 'toggle-off'}`}
|
||||||
|
onClick={() => handleToggle(r)}
|
||||||
|
title={
|
||||||
|
r.isActive ? 'Деактивировать' : 'Активировать'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.isActive ? 'Вкл' : 'Выкл'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="rules-actions">
|
||||||
|
{r.isActive && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-secondary"
|
||||||
|
onClick={() => handleApply(r.id)}
|
||||||
|
disabled={applyingId === r.id}
|
||||||
|
>
|
||||||
|
{applyingId === r.id ? '...' : 'Применить'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{applyResult?.id === r.id && (
|
||||||
|
<span className="apply-result">
|
||||||
|
Применено: {applyResult.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rules.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="td-center text-muted">
|
||||||
|
Нет правил
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
frontend/src/components/SummaryCards.tsx
Normal file
59
frontend/src/components/SummaryCards.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { AnalyticsSummaryResponse } from '@family-budget/shared';
|
||||||
|
import { formatAmount } from '../utils/format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
summary: AnalyticsSummaryResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryCards({ summary }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="summary-cards">
|
||||||
|
<div className="summary-card summary-card-income">
|
||||||
|
<div className="summary-label">Доходы</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{formatAmount(summary.totalIncome)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="summary-card summary-card-expense">
|
||||||
|
<div className="summary-label">Расходы</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{formatAmount(summary.totalExpense)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`summary-card ${summary.net >= 0 ? 'summary-card-positive' : 'summary-card-negative'}`}
|
||||||
|
>
|
||||||
|
<div className="summary-label">Баланс</div>
|
||||||
|
<div className="summary-value">
|
||||||
|
{formatAmount(summary.net)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary.topCategories.length > 0 && (
|
||||||
|
<div className="summary-card summary-card-top">
|
||||||
|
<div className="summary-label">Топ расходов</div>
|
||||||
|
<div className="summary-top-list">
|
||||||
|
{summary.topCategories.map((cat) => (
|
||||||
|
<div
|
||||||
|
key={cat.categoryId}
|
||||||
|
className="top-category-item"
|
||||||
|
>
|
||||||
|
<span className="top-category-name">
|
||||||
|
{cat.categoryName}
|
||||||
|
</span>
|
||||||
|
<span className="top-category-amount">
|
||||||
|
{formatAmount(cat.amount)}
|
||||||
|
</span>
|
||||||
|
<span className="top-category-share">
|
||||||
|
{(cat.share * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
frontend/src/components/TimeseriesChart.tsx
Normal file
71
frontend/src/components/TimeseriesChart.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { TimeseriesItem } from '@family-budget/shared';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: TimeseriesItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rubFormatter = new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function TimeseriesChart({ data }: Props) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <div className="chart-empty">Нет данных за период</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = data.map((item) => ({
|
||||||
|
period: item.periodStart,
|
||||||
|
Расходы: Math.abs(item.expenseAmount) / 100,
|
||||||
|
Доходы: item.incomeAmount / 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="period"
|
||||||
|
tickFormatter={(v: string) => {
|
||||||
|
const d = new Date(v);
|
||||||
|
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}}
|
||||||
|
fontSize={12}
|
||||||
|
stroke="#64748b"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(v: number) =>
|
||||||
|
v >= 1000 ? `${(v / 1000).toFixed(0)}к` : String(v)
|
||||||
|
}
|
||||||
|
fontSize={12}
|
||||||
|
stroke="#64748b"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => rubFormatter.format(value)}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Bar
|
||||||
|
dataKey="Расходы"
|
||||||
|
fill="#ef4444"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="Доходы"
|
||||||
|
fill="#10b981"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
frontend/src/components/TransactionFilters.tsx
Normal file
223
frontend/src/components/TransactionFilters.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
Category,
|
||||||
|
SortOrder,
|
||||||
|
TransactionSortBy,
|
||||||
|
} from '@family-budget/shared';
|
||||||
|
import { toISODate } from '../utils/format';
|
||||||
|
|
||||||
|
export interface FiltersState {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
accountId: string;
|
||||||
|
direction: string;
|
||||||
|
categoryId: string;
|
||||||
|
search: string;
|
||||||
|
amountMin: string;
|
||||||
|
amountMax: string;
|
||||||
|
onlyUnconfirmed: boolean;
|
||||||
|
sortBy: TransactionSortBy;
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
filters: FiltersState;
|
||||||
|
onChange: (filters: FiltersState) => void;
|
||||||
|
accounts: Account[];
|
||||||
|
categories: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransactionFilters({
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
accounts,
|
||||||
|
categories,
|
||||||
|
}: Props) {
|
||||||
|
const set = <K extends keyof FiltersState>(
|
||||||
|
key: K,
|
||||||
|
value: FiltersState[K],
|
||||||
|
) => {
|
||||||
|
onChange({ ...filters, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (preset: 'week' | 'month' | 'year') => {
|
||||||
|
const now = new Date();
|
||||||
|
let from: Date;
|
||||||
|
switch (preset) {
|
||||||
|
case 'week': {
|
||||||
|
const day = now.getDay();
|
||||||
|
const diff = day === 0 ? 6 : day - 1;
|
||||||
|
from = new Date(now);
|
||||||
|
from.setDate(now.getDate() - diff);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'month':
|
||||||
|
from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
from = new Date(now.getFullYear(), 0, 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
onChange({ ...filters, from: toISODate(from), to: toISODate(now) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="filters-panel">
|
||||||
|
<div className="filters-row">
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Период</label>
|
||||||
|
<div className="filter-dates">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.from}
|
||||||
|
onChange={(e) => set('from', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="filter-separator">—</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.to}
|
||||||
|
onChange={(e) => set('to', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="filter-presets">
|
||||||
|
<button
|
||||||
|
className="btn-preset"
|
||||||
|
onClick={() => applyPreset('week')}
|
||||||
|
>
|
||||||
|
Неделя
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-preset"
|
||||||
|
onClick={() => applyPreset('month')}
|
||||||
|
>
|
||||||
|
Месяц
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-preset"
|
||||||
|
onClick={() => applyPreset('year')}
|
||||||
|
>
|
||||||
|
Год
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Счёт</label>
|
||||||
|
<select
|
||||||
|
value={filters.accountId}
|
||||||
|
onChange={(e) => set('accountId', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Все счета</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.alias || a.accountNumberMasked}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Тип</label>
|
||||||
|
<select
|
||||||
|
value={filters.direction}
|
||||||
|
onChange={(e) => set('direction', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="expense">Расход</option>
|
||||||
|
<option value="income">Приход</option>
|
||||||
|
<option value="transfer">Перевод</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Категория</label>
|
||||||
|
<select
|
||||||
|
value={filters.categoryId}
|
||||||
|
onChange={(e) => set('categoryId', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filters-row">
|
||||||
|
<div className="filter-group filter-group-wide">
|
||||||
|
<label>Поиск</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по описанию..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => set('search', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Сумма от (₽)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="мин"
|
||||||
|
value={filters.amountMin}
|
||||||
|
onChange={(e) => set('amountMin', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Сумма до (₽)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="макс"
|
||||||
|
value={filters.amountMax}
|
||||||
|
onChange={(e) => set('amountMax', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group filter-group-checkbox">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.onlyUnconfirmed}
|
||||||
|
onChange={(e) => set('onlyUnconfirmed', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Только неподтверждённые
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Сортировка</label>
|
||||||
|
<div className="filter-sort">
|
||||||
|
<select
|
||||||
|
value={filters.sortBy}
|
||||||
|
onChange={(e) =>
|
||||||
|
set('sortBy', e.target.value as TransactionSortBy)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="date">По дате</option>
|
||||||
|
<option value="amount">По сумме</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn-sort-order"
|
||||||
|
onClick={() =>
|
||||||
|
set(
|
||||||
|
'sortOrder',
|
||||||
|
filters.sortOrder === 'asc' ? 'desc' : 'asc',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
filters.sortOrder === 'asc'
|
||||||
|
? 'По возрастанию'
|
||||||
|
: 'По убыванию'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filters.sortOrder === 'asc' ? '↑' : '↓'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
frontend/src/components/TransactionTable.tsx
Normal file
107
frontend/src/components/TransactionTable.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Transaction } from '@family-budget/shared';
|
||||||
|
import { formatAmount, formatDateTime } from '../utils/format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
transactions: Transaction[];
|
||||||
|
loading: boolean;
|
||||||
|
onEdit: (tx: Transaction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIRECTION_LABELS: Record<string, string> = {
|
||||||
|
income: 'Приход',
|
||||||
|
expense: 'Расход',
|
||||||
|
transfer: 'Перевод',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIRECTION_CLASSES: Record<string, string> = {
|
||||||
|
income: 'amount-income',
|
||||||
|
expense: 'amount-expense',
|
||||||
|
transfer: 'amount-transfer',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="table-loading">Загрузка операций...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactions.length === 0) {
|
||||||
|
return <div className="table-empty">Операции не найдены</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Счёт</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th className="th-center">Статус</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{transactions.map((tx) => (
|
||||||
|
<tr
|
||||||
|
key={tx.id}
|
||||||
|
className={
|
||||||
|
!tx.isCategoryConfirmed && tx.categoryId
|
||||||
|
? 'row-unconfirmed'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="td-nowrap">
|
||||||
|
{formatDateTime(tx.operationAt)}
|
||||||
|
</td>
|
||||||
|
<td className="td-nowrap">{tx.accountAlias || '—'}</td>
|
||||||
|
<td
|
||||||
|
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
|
||||||
|
>
|
||||||
|
{formatAmount(tx.amountSigned)}
|
||||||
|
</td>
|
||||||
|
<td className="td-description">
|
||||||
|
<span className="description-text">{tx.description}</span>
|
||||||
|
{tx.comment && (
|
||||||
|
<span className="comment-badge" title={tx.comment}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td-nowrap">
|
||||||
|
{tx.categoryName || (
|
||||||
|
<span className="text-muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td-center">
|
||||||
|
{tx.categoryId != null && !tx.isCategoryConfirmed && (
|
||||||
|
<span
|
||||||
|
className="badge badge-warning"
|
||||||
|
title="Категория не подтверждена"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => onEdit(tx)}
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/src/context/AuthContext.tsx
Normal file
72
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { getMe, login as apiLogin, logout as apiLogout } from '../api/auth';
|
||||||
|
import { setOnUnauthorized } from '../api/client';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: { login: string } | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<{ login: string } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const clearUser = useCallback(() => {
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOnUnauthorized(clearUser);
|
||||||
|
getMe()
|
||||||
|
.then((me) => setUser({ login: me.login }))
|
||||||
|
.catch(() => setUser(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [clearUser]);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiLogin({ login: username, password });
|
||||||
|
const me = await getMe();
|
||||||
|
setUser({ login: me.login });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
e instanceof Error ? e.message : 'Ошибка входа';
|
||||||
|
setError(msg);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await apiLogout();
|
||||||
|
} finally {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, error, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
16
frontend/src/main.tsx
Normal file
16
frontend/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import { App } from './App';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
144
frontend/src/pages/AnalyticsPage.tsx
Normal file
144
frontend/src/pages/AnalyticsPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
AnalyticsSummaryResponse,
|
||||||
|
ByCategoryItem,
|
||||||
|
TimeseriesItem,
|
||||||
|
Granularity,
|
||||||
|
} from '@family-budget/shared';
|
||||||
|
import { getAccounts } from '../api/accounts';
|
||||||
|
import { getSummary, getByCategory, getTimeseries } from '../api/analytics';
|
||||||
|
import {
|
||||||
|
PeriodSelector,
|
||||||
|
type PeriodState,
|
||||||
|
} from '../components/PeriodSelector';
|
||||||
|
import { SummaryCards } from '../components/SummaryCards';
|
||||||
|
import { TimeseriesChart } from '../components/TimeseriesChart';
|
||||||
|
import { CategoryChart } from '../components/CategoryChart';
|
||||||
|
import { toISODate } from '../utils/format';
|
||||||
|
|
||||||
|
function getDefaultPeriod(): PeriodState {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
mode: 'month',
|
||||||
|
from: toISODate(new Date(now.getFullYear(), now.getMonth(), 1)),
|
||||||
|
to: toISODate(now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsPage() {
|
||||||
|
const [period, setPeriod] = useState<PeriodState>(getDefaultPeriod);
|
||||||
|
const [accountId, setAccountId] = useState<string>('');
|
||||||
|
const [onlyConfirmed, setOnlyConfirmed] = useState(false);
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [summary, setSummary] = useState<AnalyticsSummaryResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [byCategory, setByCategory] = useState<ByCategoryItem[]>([]);
|
||||||
|
const [timeseries, setTimeseries] = useState<TimeseriesItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAccounts().then(setAccounts).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
if (!period.from || !period.to) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
let gran: Granularity;
|
||||||
|
switch (period.mode) {
|
||||||
|
case 'week':
|
||||||
|
gran = 'day';
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
gran = 'week';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
gran = 'month';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
from: period.from,
|
||||||
|
to: period.to,
|
||||||
|
...(accountId ? { accountId: Number(accountId) } : {}),
|
||||||
|
...(onlyConfirmed ? { onlyConfirmed: true } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [s, bc, ts] = await Promise.all([
|
||||||
|
getSummary(base),
|
||||||
|
getByCategory(base),
|
||||||
|
getTimeseries({ ...base, granularity: gran }),
|
||||||
|
]);
|
||||||
|
setSummary(s);
|
||||||
|
setByCategory(bc);
|
||||||
|
setTimeseries(ts);
|
||||||
|
} catch {
|
||||||
|
// 401 handled by interceptor
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [period, accountId, onlyConfirmed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAll();
|
||||||
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Аналитика</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="analytics-controls">
|
||||||
|
<PeriodSelector period={period} onChange={setPeriod} />
|
||||||
|
<div className="analytics-filters">
|
||||||
|
<div className="filter-group">
|
||||||
|
<label>Счёт</label>
|
||||||
|
<select
|
||||||
|
value={accountId}
|
||||||
|
onChange={(e) => setAccountId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Все счета</option>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.alias || a.accountNumberMasked}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="filter-group filter-group-checkbox">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyConfirmed}
|
||||||
|
onChange={(e) => setOnlyConfirmed(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Только подтверждённые
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="section-loading">Загрузка данных...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{summary && <SummaryCards summary={summary} />}
|
||||||
|
<div className="analytics-charts">
|
||||||
|
<div className="chart-card">
|
||||||
|
<h3>Динамика</h3>
|
||||||
|
<TimeseriesChart data={timeseries} />
|
||||||
|
</div>
|
||||||
|
<div className="chart-card">
|
||||||
|
<h3>По категориям</h3>
|
||||||
|
<CategoryChart data={byCategory} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
frontend/src/pages/HistoryPage.tsx
Normal file
164
frontend/src/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type {
|
||||||
|
Transaction,
|
||||||
|
Account,
|
||||||
|
Category,
|
||||||
|
PaginatedResponse,
|
||||||
|
GetTransactionsParams,
|
||||||
|
} from '@family-budget/shared';
|
||||||
|
import { getTransactions } from '../api/transactions';
|
||||||
|
import { getAccounts } from '../api/accounts';
|
||||||
|
import { getCategories } from '../api/categories';
|
||||||
|
import {
|
||||||
|
TransactionFilters,
|
||||||
|
type FiltersState,
|
||||||
|
} from '../components/TransactionFilters';
|
||||||
|
import { TransactionTable } from '../components/TransactionTable';
|
||||||
|
import { Pagination } from '../components/Pagination';
|
||||||
|
import { EditTransactionModal } from '../components/EditTransactionModal';
|
||||||
|
import { ImportModal } from '../components/ImportModal';
|
||||||
|
import { toISODate } from '../utils/format';
|
||||||
|
|
||||||
|
function getDefaultFilters(): FiltersState {
|
||||||
|
const now = new Date();
|
||||||
|
const from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
return {
|
||||||
|
from: toISODate(from),
|
||||||
|
to: toISODate(now),
|
||||||
|
accountId: '',
|
||||||
|
direction: '',
|
||||||
|
categoryId: '',
|
||||||
|
search: '',
|
||||||
|
amountMin: '',
|
||||||
|
amountMax: '',
|
||||||
|
onlyUnconfirmed: false,
|
||||||
|
sortBy: 'date',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryPage() {
|
||||||
|
const [filters, setFilters] = useState<FiltersState>(getDefaultFilters);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(50);
|
||||||
|
const [data, setData] = useState<PaginatedResponse<Transaction> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [editingTx, setEditingTx] = useState<Transaction | null>(null);
|
||||||
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAccounts().then(setAccounts).catch(() => {});
|
||||||
|
getCategories().then(setCategories).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: GetTransactionsParams = {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortBy: filters.sortBy,
|
||||||
|
sortOrder: filters.sortOrder,
|
||||||
|
};
|
||||||
|
if (filters.from) params.from = filters.from;
|
||||||
|
if (filters.to) params.to = filters.to;
|
||||||
|
if (filters.accountId) params.accountId = Number(filters.accountId);
|
||||||
|
if (filters.direction) params.direction = filters.direction;
|
||||||
|
if (filters.categoryId) params.categoryId = Number(filters.categoryId);
|
||||||
|
if (filters.search) params.search = filters.search;
|
||||||
|
if (filters.amountMin)
|
||||||
|
params.amountMin = Math.round(Number(filters.amountMin) * 100);
|
||||||
|
if (filters.amountMax)
|
||||||
|
params.amountMax = Math.round(Number(filters.amountMax) * 100);
|
||||||
|
if (filters.onlyUnconfirmed) params.onlyUnconfirmed = true;
|
||||||
|
|
||||||
|
const result = await getTransactions(params);
|
||||||
|
setData(result);
|
||||||
|
} catch {
|
||||||
|
// 401 handled by interceptor
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters, page, pageSize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleFiltersChange = (newFilters: FiltersState) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = () => {
|
||||||
|
setEditingTx(null);
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportDone = () => {
|
||||||
|
setShowImport(false);
|
||||||
|
fetchData();
|
||||||
|
getAccounts().then(setAccounts).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>История операций</h1>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowImport(true)}
|
||||||
|
>
|
||||||
|
Импорт выписки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransactionFilters
|
||||||
|
filters={filters}
|
||||||
|
onChange={handleFiltersChange}
|
||||||
|
accounts={accounts}
|
||||||
|
categories={categories}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TransactionTable
|
||||||
|
transactions={data?.items ?? []}
|
||||||
|
loading={loading}
|
||||||
|
onEdit={setEditingTx}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<Pagination
|
||||||
|
page={data.page}
|
||||||
|
pageSize={data.pageSize}
|
||||||
|
totalItems={data.totalItems}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingTx && (
|
||||||
|
<EditTransactionModal
|
||||||
|
transaction={editingTx}
|
||||||
|
categories={categories}
|
||||||
|
onClose={() => setEditingTx(null)}
|
||||||
|
onSave={handleEditSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showImport && (
|
||||||
|
<ImportModal
|
||||||
|
onClose={() => setShowImport(false)}
|
||||||
|
onDone={handleImportDone}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/pages/LoginPage.tsx
Normal file
66
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login, error } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
} catch {
|
||||||
|
// error state managed by AuthContext
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-header">
|
||||||
|
<span className="login-icon">₽</span>
|
||||||
|
<h1>Семейный бюджет</h1>
|
||||||
|
<p>Войдите для продолжения</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="login">Логин</label>
|
||||||
|
<input
|
||||||
|
id="login"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Пароль</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-block"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Вход...' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/pages/SettingsPage.tsx
Normal file
45
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AccountsList } from '../components/AccountsList';
|
||||||
|
import { CategoriesList } from '../components/CategoriesList';
|
||||||
|
import { RulesList } from '../components/RulesList';
|
||||||
|
|
||||||
|
type Tab = 'accounts' | 'categories' | 'rules';
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>('accounts');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Настройки</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
<button
|
||||||
|
className={`tab ${tab === 'accounts' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('accounts')}
|
||||||
|
>
|
||||||
|
Счета
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab ${tab === 'categories' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('categories')}
|
||||||
|
>
|
||||||
|
Категории
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab ${tab === 'rules' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('rules')}
|
||||||
|
>
|
||||||
|
Правила
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tab-content">
|
||||||
|
{tab === 'accounts' && <AccountsList />}
|
||||||
|
{tab === 'categories' && <CategoriesList />}
|
||||||
|
{tab === 'rules' && <RulesList />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1286
frontend/src/styles/index.css
Normal file
1286
frontend/src/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend/src/utils/format.ts
Normal file
38
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const amountFormatter = new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatAmount(kopecks: number): string {
|
||||||
|
return amountFormatter.format(kopecks / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateTimeFormatter = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return dateFormatter.format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string): string {
|
||||||
|
return dateTimeFormatter.format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toISODate(date: Date): string {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
18
frontend/tsconfig.json
Normal file
18
frontend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
13
frontend/tsconfig.node.json
Normal file
13
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"composite": true,
|
||||||
|
"outDir": "./dist-node"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user