Compare commits
39 Commits
a895bb4b2f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e97203c4ab | |||
|
|
6dd8c01f5e | ||
| 495c1e89bb | |||
|
|
032a9f4e3b | ||
| d5f49fd86f | |||
|
|
ab88a0553d | ||
| 0589da5005 | |||
| c50e48d564 | |||
|
|
01b1f26553 | ||
|
|
ba3105bbe5 | ||
| f32a21f87a | |||
|
|
8b57dd987e | ||
| ea234ea007 | |||
|
|
db4d5e4d00 | ||
| 358fcaeff5 | |||
|
|
67fed57118 | ||
| 45a6f3d374 | |||
|
|
aaf8cacf75 | ||
|
|
e28d0f46d0 | ||
| 22be09c101 | |||
|
|
78c4730196 | ||
| f2d0c91488 | |||
|
|
1d7fbea9ef | ||
|
|
627706228b | ||
|
|
a5f2294440 | ||
| 25ddd6b7ed | |||
|
|
3e481d5a55 | ||
| cf24d5dc26 | |||
|
|
723df494ca | ||
| 5fa6b921d8 | |||
|
|
feb756cfe2 | ||
|
|
b598216d24 | ||
| 0638885812 | |||
|
|
975f2c4fd2 | ||
|
|
50154f304c | ||
|
|
8625f7f6cf | ||
|
|
d1536b8872 | ||
|
|
20d2a2b497 | ||
|
|
56b5c81ec5 |
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Единые правила переносов строк для всех машин
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows-скрипты — исключение, им нужен CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-node/
|
||||
build/
|
||||
|
||||
.env
|
||||
@@ -17,3 +18,9 @@ Thumbs.db
|
||||
coverage/
|
||||
|
||||
jan_feb.json
|
||||
jan-feb.json
|
||||
history.xlsx
|
||||
match_analysis.py
|
||||
match_report.txt
|
||||
statements/
|
||||
.cursor/
|
||||
|
||||
@@ -5,6 +5,11 @@ COPY package.json package-lock.json* ./
|
||||
COPY shared ./shared
|
||||
COPY backend ./backend
|
||||
|
||||
# Увеличенные таймауты для нестабильной сети
|
||||
RUN npm config set fetch-retry-mintimeout 20000 && \
|
||||
npm config set fetch-retry-maxtimeout 120000 && \
|
||||
npm config set fetch-timeout 300000
|
||||
|
||||
RUN npm install
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
@@ -25,6 +30,7 @@ ENV NODE_ENV=production
|
||||
COPY --from=build /app/backend/dist ./dist
|
||||
COPY --from=build /app/backend/package.json ./package.json
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/backend/node_modules/ ./node_modules/
|
||||
COPY backend/.env .env
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -9,6 +9,11 @@ COPY package.json package-lock.json* tsconfig.json* ./
|
||||
COPY shared ./shared
|
||||
COPY frontend ./frontend
|
||||
|
||||
# Увеличиваем таймауты и повторы для нестабильной сети
|
||||
RUN npm config set fetch-retry-mintimeout 20000 && \
|
||||
npm config set fetch-retry-maxtimeout 120000 && \
|
||||
npm config set fetch-timeout 300000
|
||||
|
||||
# Устанавливаем зависимости из корня
|
||||
RUN npm install
|
||||
|
||||
|
||||
@@ -10,3 +10,14 @@ APP_USER_PASSWORD=changeme
|
||||
SESSION_TIMEOUT_MS=10800000
|
||||
|
||||
PORT=3000
|
||||
|
||||
# Ключ OpenAI API для конвертации PDF-выписок в JSON (опционально)
|
||||
# Без него импорт PDF будет недоступен (503)
|
||||
LLM_API_KEY=
|
||||
|
||||
# Базовый URL API LLM (опционально). По умолчанию https://api.openai.com
|
||||
# Примеры: Ollama — http://localhost:11434/v1, Azure — https://YOUR_RESOURCE.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT
|
||||
LLM_API_BASE_URL=
|
||||
|
||||
# Имя модели LLM (опционально). Для OpenAI: gpt-4o-mini. Для Ollama: qwen2.5:7b, qwen3:7b
|
||||
LLM_MODEL=
|
||||
|
||||
@@ -45,6 +45,8 @@ npm run dev -w backend
|
||||
| `APP_USER_PASSWORD` | `changeme` | Пароль для входа в приложение |
|
||||
| `SESSION_TIMEOUT_MS` | `10800000` | Таймаут сессии по бездействию (3 часа) |
|
||||
| `PORT` | `3000` | Порт HTTP-сервера |
|
||||
| `LLM_API_KEY` | — | Ключ OpenAI API для конвертации PDF в JSON; без него импорт PDF возвращает 503 |
|
||||
| `LLM_API_BASE_URL` | `https://api.openai.com/v1` | Адрес LLM API (OpenAI-совместимый); для локальной модели, напр. Ollama: `http://localhost:11434/v1` |
|
||||
|
||||
## Структура проекта
|
||||
|
||||
@@ -61,6 +63,7 @@ backend/src/
|
||||
├── services/
|
||||
│ ├── auth.ts — login / logout / me
|
||||
│ ├── import.ts — валидация, fingerprint, direction, атомарный импорт
|
||||
│ ├── pdfToStatement.ts — конвертация PDF → JSON 1.0 через LLM (OpenAI)
|
||||
│ ├── transactions.ts — список с фильтрами + обновление (categoryId, comment)
|
||||
│ ├── accounts.ts — список счетов, обновление алиаса
|
||||
│ ├── categories.ts — список категорий (фильтр isActive)
|
||||
@@ -90,7 +93,7 @@ backend/src/
|
||||
|
||||
| Метод | URL | Описание |
|
||||
|--------|----------------------------|-----------------------------------------|
|
||||
| POST | `/api/import/statement` | Импорт банковской выписки (JSON 1.0) |
|
||||
| POST | `/api/import/statement` | Импорт банковской выписки (PDF или JSON 1.0; PDF конвертируется через LLM) |
|
||||
|
||||
### Транзакции
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/app.js",
|
||||
"migrate": "tsx src/db/migrate.ts"
|
||||
"migrate": "tsx src/db/migrate.ts",
|
||||
"migrate:prod": "node dist/db/migrate.js",
|
||||
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@family-budget/shared": "*",
|
||||
@@ -14,6 +16,9 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"openai": "^6.27.0",
|
||||
"pdf-parse": "1.1.1",
|
||||
"pg": "^8.19.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
@@ -21,6 +26,7 @@
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
@@ -5,8 +7,13 @@ import { config } from './config';
|
||||
import { runMigrations } from './db/migrate';
|
||||
import { requireAuth } from './middleware/auth';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'),
|
||||
) as { version: string };
|
||||
|
||||
import authRouter from './routes/auth';
|
||||
import importRouter from './routes/import';
|
||||
import importsRouter from './routes/imports';
|
||||
import transactionsRouter from './routes/transactions';
|
||||
import accountsRouter from './routes/accounts';
|
||||
import categoriesRouter from './routes/categories';
|
||||
@@ -23,9 +30,14 @@ app.use(cors({ origin: true, credentials: true }));
|
||||
// Auth routes (login is public; me/logout apply auth internally)
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
app.get('/api/version', (_req, res) => {
|
||||
res.json({ version: pkg.version });
|
||||
});
|
||||
|
||||
// All remaining /api routes require authentication
|
||||
app.use('/api', requireAuth);
|
||||
app.use('/api/import', importRouter);
|
||||
app.use('/api/imports', importsRouter);
|
||||
app.use('/api/transactions', transactionsRouter);
|
||||
app.use('/api/accounts', accountsRouter);
|
||||
app.use('/api/categories', categoriesRouter);
|
||||
|
||||
@@ -18,4 +18,13 @@ export const config = {
|
||||
appUserPassword: process.env.APP_USER_PASSWORD || 'changeme',
|
||||
|
||||
sessionTimeoutMs: parseInt(process.env.SESSION_TIMEOUT_MS || '10800000', 10),
|
||||
|
||||
/** API-ключ для LLM (OpenAI или совместимый). Обязателен для конвертации PDF. */
|
||||
llmApiKey: process.env.LLM_API_KEY || '',
|
||||
|
||||
/** Базовый URL API LLM. По умолчанию https://api.openai.com. Для Ollama: http://localhost:11434/v1 */
|
||||
llmApiBaseUrl: process.env.LLM_API_BASE_URL || undefined,
|
||||
|
||||
/** Имя модели LLM. Для OpenAI: gpt-4o-mini. Для Ollama: qwen2.5:7b, qwen3:7b и т.п. */
|
||||
llmModel: process.env.LLM_MODEL || 'gpt-4o-mini',
|
||||
};
|
||||
|
||||
@@ -133,6 +133,85 @@ const migrations: { name: string; sql: string }[] = [
|
||||
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '005_imports_table',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
account_id BIGINT REFERENCES accounts(id),
|
||||
bank TEXT NOT NULL,
|
||||
account_number_masked TEXT NOT NULL,
|
||||
imported_count INT NOT NULL,
|
||||
duplicates_skipped INT NOT NULL,
|
||||
total_in_file INT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '004_seed_category_rules_extended',
|
||||
sql: `
|
||||
INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
|
||||
SELECT pattern, match_type, category_id, priority, requires_confirmation
|
||||
FROM (VALUES
|
||||
('STOLOVAYA', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
|
||||
('KOFEJNYA', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
|
||||
('JEFFREY S COFFEESHOP', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
|
||||
('TA TORRRO GRIL', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
|
||||
('OUSHEN FRENDS', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' LIMIT 1), 25, false),
|
||||
('mkad', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false),
|
||||
('RNAZK', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false),
|
||||
('IP SHEVELEV', 'contains', (SELECT id FROM categories WHERE name = 'Авто' LIMIT 1), 20, false),
|
||||
('PAVELETSKAYA', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false),
|
||||
('CPPK-', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false),
|
||||
('MOS.TRANSPORT', 'contains', (SELECT id FROM categories WHERE name = 'Проезд' LIMIT 1), 25, false),
|
||||
('Lab4uru', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' LIMIT 1), 25, false),
|
||||
('APTEKA', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' LIMIT 1), 20, false),
|
||||
('IP SHARAFETDINOV', 'contains', (SELECT id FROM categories WHERE name = 'Арчи' LIMIT 1), 25, false),
|
||||
('ZOOMAGAZIN CHETYRE LAP', 'contains', (SELECT id FROM categories WHERE name = 'Арчи' LIMIT 1), 25, false),
|
||||
('VKUSVILL', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false),
|
||||
('GLOBUS', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false),
|
||||
('VERNYJ', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' LIMIT 1), 20, false),
|
||||
('GOLD APPLE', 'contains', (SELECT id FROM categories WHERE name = 'Косметика' LIMIT 1), 25, false),
|
||||
('SPIRITFIT', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false),
|
||||
('insanity', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false),
|
||||
('anta-sport', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' LIMIT 1), 25, false),
|
||||
('VANYAVPN', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 25, false),
|
||||
('ГРАНЛАЙН', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 20, false),
|
||||
('Мобильная связь', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' LIMIT 1), 25, false),
|
||||
('OSTROVOK', 'contains', (SELECT id FROM categories WHERE name = 'Отпуск' LIMIT 1), 25, false),
|
||||
('sutochno', 'contains', (SELECT id FROM categories WHERE name = 'Отпуск' LIMIT 1), 25, false),
|
||||
('УФК', 'contains', (SELECT id FROM categories WHERE name = 'Штрафы' LIMIT 1), 30, false),
|
||||
('ГИБДД', 'contains', (SELECT id FROM categories WHERE name = 'Штрафы' LIMIT 1), 30, false),
|
||||
('Поступление заработной платы', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 30, false),
|
||||
('avito', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 25, false),
|
||||
('Init payout', 'contains', (SELECT id FROM categories WHERE name = 'Поступления' LIMIT 1), 25, false)
|
||||
) AS v(pattern, match_type, category_id, priority, requires_confirmation)
|
||||
WHERE category_id IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM categories LIMIT 1);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '005_imports_table',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
account_id BIGINT REFERENCES accounts(id),
|
||||
bank TEXT NOT NULL,
|
||||
account_number_masked TEXT NOT NULL,
|
||||
imported_count INT NOT NULL,
|
||||
duplicates_skipped INT NOT NULL,
|
||||
total_in_file INT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
|
||||
@@ -7,4 +7,5 @@ export const pool = new Pool({
|
||||
database: config.db.database,
|
||||
user: config.db.user,
|
||||
password: config.db.password,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
@@ -1,13 +1,81 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { asyncHandler } from '../utils';
|
||||
import { importStatement, isValidationError } from '../services/import';
|
||||
import {
|
||||
convertPdfToStatement,
|
||||
isPdfConversionError,
|
||||
} from '../services/pdfToStatement';
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 15 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
function isPdfFile(file: { mimetype: string; originalname: string }): boolean {
|
||||
const name = file.originalname.toLowerCase();
|
||||
return (
|
||||
file.mimetype === 'application/pdf' ||
|
||||
name.endsWith('.pdf')
|
||||
);
|
||||
}
|
||||
|
||||
function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
|
||||
const name = file.originalname.toLowerCase();
|
||||
return (
|
||||
file.mimetype === 'application/json' ||
|
||||
name.endsWith('.json')
|
||||
);
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/statement',
|
||||
upload.single('file'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const result = await importStatement(req.body);
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
res.status(400).json({
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Файл не загружен',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPdfFile(file) && !isJsonFile(file)) {
|
||||
res.status(400).json({
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Допустимы только файлы PDF или JSON',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
|
||||
if (isPdfFile(file)) {
|
||||
const converted = await convertPdfToStatement(file.buffer);
|
||||
if (isPdfConversionError(converted)) {
|
||||
res.status(converted.status).json({
|
||||
error: converted.error,
|
||||
message: converted.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
body = converted;
|
||||
} else {
|
||||
try {
|
||||
body = JSON.parse(file.buffer.toString('utf-8'));
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Некорректный JSON-файл',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await importStatement(body);
|
||||
if (isValidationError(result)) {
|
||||
res.status((result as { status: number }).status).json({
|
||||
error: (result as { error: string }).error,
|
||||
|
||||
36
backend/src/routes/imports.ts
Normal file
36
backend/src/routes/imports.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Router } from 'express';
|
||||
import { pool } from '../db/pool';
|
||||
import { asyncHandler } from '../utils';
|
||||
import * as importsService from '../services/imports';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const imports = await importsService.getImports();
|
||||
res.json(imports);
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid import id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query('SELECT 1 FROM imports WHERE id = $1', [id]);
|
||||
if (rows.length === 0) {
|
||||
res.status(404).json({ error: 'NOT_FOUND', message: 'Import not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await importsService.deleteImport(id);
|
||||
res.json(result);
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
42
backend/src/scripts/testLlm.ts
Normal file
42
backend/src/scripts/testLlm.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Тестовый запрос к LLM серверу.
|
||||
* Запуск: npm run test:llm
|
||||
*/
|
||||
import OpenAI from 'openai';
|
||||
import { config } from '../config';
|
||||
|
||||
async function main() {
|
||||
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
|
||||
console.error('Ошибка: LLM_API_KEY не задан в .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: config.llmApiKey,
|
||||
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
|
||||
});
|
||||
|
||||
const url = config.llmApiBaseUrl || 'https://api.openai.com/v1';
|
||||
console.log('LLM сервер:', url);
|
||||
console.log('Модель:', config.llmModel);
|
||||
console.log('---');
|
||||
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: config.llmModel,
|
||||
messages: [{ role: 'user', content: 'Ответь одним словом: какой цвет у неба?' }],
|
||||
temperature: 0,
|
||||
max_tokens: 50,
|
||||
});
|
||||
|
||||
const content = completion.choices[0]?.message?.content;
|
||||
console.log('Ответ:', content || '(пусто)');
|
||||
console.log('---');
|
||||
console.log('OK');
|
||||
} catch (err) {
|
||||
console.error('Ошибка:', err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -155,6 +155,16 @@ export async function importStatement(
|
||||
isNewAccount = true;
|
||||
}
|
||||
|
||||
// Create import record (counts updated after loop)
|
||||
const accountNumberMasked = maskAccountNumber(data.statement.accountNumber);
|
||||
const importResult = await client.query(
|
||||
`INSERT INTO imports (account_id, bank, account_number_masked, imported_count, duplicates_skipped, total_in_file)
|
||||
VALUES ($1, $2, $3, 0, 0, $4)
|
||||
RETURNING id`,
|
||||
[accountId, data.bank, accountNumberMasked, data.transactions.length],
|
||||
);
|
||||
const importId = Number(importResult.rows[0].id);
|
||||
|
||||
// Insert transactions
|
||||
const insertedIds: number[] = [];
|
||||
|
||||
@@ -164,11 +174,11 @@ export async function importStatement(
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO transactions
|
||||
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE)
|
||||
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE, $8)
|
||||
ON CONFLICT (account_id, fingerprint) DO NOTHING
|
||||
RETURNING id`,
|
||||
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp],
|
||||
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, importId],
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
@@ -176,6 +186,13 @@ export async function importStatement(
|
||||
}
|
||||
}
|
||||
|
||||
// Update import record with actual counts
|
||||
const duplicatesSkipped = data.transactions.length - insertedIds.length;
|
||||
await client.query(
|
||||
`UPDATE imports SET imported_count = $1, duplicates_skipped = $2 WHERE id = $3`,
|
||||
[insertedIds.length, duplicatesSkipped, importId],
|
||||
);
|
||||
|
||||
// Auto-categorize newly inserted transactions
|
||||
if (insertedIds.length > 0) {
|
||||
await client.query(
|
||||
@@ -208,7 +225,7 @@ export async function importStatement(
|
||||
return {
|
||||
accountId,
|
||||
isNewAccount,
|
||||
accountNumberMasked: maskAccountNumber(data.statement.accountNumber),
|
||||
accountNumberMasked,
|
||||
imported: insertedIds.length,
|
||||
duplicatesSkipped: data.transactions.length - insertedIds.length,
|
||||
totalInFile: data.transactions.length,
|
||||
|
||||
44
backend/src/services/imports.ts
Normal file
44
backend/src/services/imports.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { pool } from '../db/pool';
|
||||
import type { Import } from '@family-budget/shared';
|
||||
|
||||
export async function getImports(): Promise<Import[]> {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
i.id,
|
||||
i.imported_at,
|
||||
i.account_id,
|
||||
a.alias AS account_alias,
|
||||
i.bank,
|
||||
i.account_number_masked,
|
||||
i.imported_count,
|
||||
i.duplicates_skipped,
|
||||
i.total_in_file
|
||||
FROM imports i
|
||||
LEFT JOIN accounts a ON a.id = i.account_id
|
||||
ORDER BY i.imported_at DESC`,
|
||||
);
|
||||
|
||||
return result.rows.map((r) => ({
|
||||
id: Number(r.id),
|
||||
importedAt: r.imported_at.toISOString(),
|
||||
accountId: r.account_id != null ? Number(r.account_id) : null,
|
||||
accountAlias: r.account_alias ?? null,
|
||||
bank: r.bank,
|
||||
accountNumberMasked: r.account_number_masked,
|
||||
importedCount: Number(r.imported_count),
|
||||
duplicatesSkipped: Number(r.duplicates_skipped),
|
||||
totalInFile: Number(r.total_in_file),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteImport(id: number): Promise<{ deleted: number }> {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM transactions WHERE import_id = $1 RETURNING id',
|
||||
[id],
|
||||
);
|
||||
const deleted = result.rowCount ?? 0;
|
||||
|
||||
await pool.query('DELETE FROM imports WHERE id = $1', [id]);
|
||||
|
||||
return { deleted };
|
||||
}
|
||||
179
backend/src/services/pdfToStatement.ts
Normal file
179
backend/src/services/pdfToStatement.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import OpenAI from 'openai';
|
||||
import { config } from '../config';
|
||||
import type { StatementFile } from '@family-budget/shared';
|
||||
|
||||
const PDF2JSON_PROMPT = `Ты — конвертер банковских выписок. Твоя задача: извлечь данные из текста банковской выписки ниже и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
|
||||
|
||||
## Структура выходного JSON
|
||||
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"bank": "<название банка из выписки>",
|
||||
"statement": {
|
||||
"accountNumber": "<номер счёта, только цифры, без пробелов>",
|
||||
"currency": "RUB",
|
||||
"openingBalance": <число в копейках, целое>,
|
||||
"closingBalance": <число в копейках, целое>,
|
||||
"exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>"
|
||||
},
|
||||
"transactions": [
|
||||
{
|
||||
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
|
||||
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
|
||||
"commission": <число, целое, >= 0, в копейках>,
|
||||
"description": "<полное описание операции из выписки>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
## Правила конвертации
|
||||
|
||||
1. Суммы — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
|
||||
2. amountSigned: приход — положительное, расход — отрицательное.
|
||||
3. operationAt — дата и время, если не указано — 00:00:00, offset +03:00 для МСК.
|
||||
4. commission — если не указана — 0.
|
||||
5. description — полный текст операции как в выписке.
|
||||
6. accountNumber — только цифры, без пробелов и дефисов.
|
||||
7. openingBalance / closingBalance — в копейках.
|
||||
8. bank — краткое название (VTB, Sberbank, Тинькофф).
|
||||
9. exportedAt — дата формирования выписки.
|
||||
10. transactions — хронологический порядок.
|
||||
|
||||
## Требования
|
||||
|
||||
- transactions не должен быть пустым.
|
||||
- Все числа — целые.
|
||||
- Даты — ISO 8601 с offset.
|
||||
- currency всегда "RUB".
|
||||
- schemaVersion всегда "1.0".`;
|
||||
|
||||
export interface PdfConversionError {
|
||||
status: number;
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function isPdfConversionError(r: unknown): r is PdfConversionError {
|
||||
return (
|
||||
typeof r === 'object' &&
|
||||
r !== null &&
|
||||
'status' in r &&
|
||||
'error' in r &&
|
||||
'message' in r
|
||||
);
|
||||
}
|
||||
|
||||
// Lazy-loaded so the app starts even if pdf-parse is missing from node_modules.
|
||||
let _pdfParse: ((buf: Buffer) => Promise<{ text: string }>) | undefined;
|
||||
function getPdfParse() {
|
||||
if (!_pdfParse) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
_pdfParse = require('pdf-parse') as (buf: Buffer) => Promise<{ text: string }>;
|
||||
}
|
||||
return _pdfParse;
|
||||
}
|
||||
|
||||
export async function convertPdfToStatement(
|
||||
buffer: Buffer,
|
||||
): Promise<StatementFile | PdfConversionError> {
|
||||
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
|
||||
return {
|
||||
status: 503,
|
||||
error: 'SERVICE_UNAVAILABLE',
|
||||
message: 'Конвертация PDF недоступна: не задан LLM_API_KEY',
|
||||
};
|
||||
}
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
const result = await getPdfParse()(buffer);
|
||||
text = result.text || '';
|
||||
} catch (err) {
|
||||
console.error('PDF extraction error:', err);
|
||||
return {
|
||||
status: 400,
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Не удалось обработать PDF-файл',
|
||||
};
|
||||
}
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
return {
|
||||
status: 400,
|
||||
error: 'BAD_REQUEST',
|
||||
message: 'Не удалось извлечь текст из PDF',
|
||||
};
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: config.llmApiKey,
|
||||
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
|
||||
timeout: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: config.llmModel,
|
||||
messages: [
|
||||
{ role: 'system', content: PDF2JSON_PROMPT },
|
||||
{ role: 'user', content: `Текст выписки:\n\n${text}` },
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 32768,
|
||||
});
|
||||
|
||||
const content = completion.choices[0]?.message?.content?.trim();
|
||||
if (!content) {
|
||||
return {
|
||||
status: 422,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Результат конвертации пуст',
|
||||
};
|
||||
}
|
||||
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[0] : content;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
return {
|
||||
status: 422,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Результат конвертации не является валидным JSON',
|
||||
};
|
||||
}
|
||||
|
||||
const data = parsed as Record<string, unknown>;
|
||||
if (data.schemaVersion !== '1.0') {
|
||||
return {
|
||||
status: 422,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Результат конвертации не соответствует схеме 1.0',
|
||||
};
|
||||
}
|
||||
|
||||
return parsed as StatementFile;
|
||||
} catch (err) {
|
||||
console.error('LLM conversion error:', err);
|
||||
return {
|
||||
status: 502,
|
||||
error: 'BAD_GATEWAY',
|
||||
message: extractLlmErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function extractLlmErrorMessage(err: unknown): string {
|
||||
const raw = String(
|
||||
(err as Record<string, unknown>)?.message ??
|
||||
(err as Record<string, Record<string, unknown>>)?.error?.message ?? '',
|
||||
);
|
||||
if (/context.length|n_ctx|too.many.tokens|maximum.context/i.test(raw)) {
|
||||
return 'PDF-файл слишком большой для обработки. Попробуйте файл с меньшим количеством операций или используйте модель с большим контекстным окном.';
|
||||
}
|
||||
if (/timeout|timed?\s*out|ETIMEDOUT|ECONNREFUSED/i.test(raw)) {
|
||||
return 'LLM-сервер не отвечает. Проверьте, что сервер запущен и доступен.';
|
||||
}
|
||||
return 'Временная ошибка конвертации';
|
||||
}
|
||||
@@ -171,5 +171,6 @@ export async function updateTransaction(
|
||||
|
||||
export async function clearAllTransactions(): Promise<{ deleted: number }> {
|
||||
const result = await pool.query('DELETE FROM transactions RETURNING id');
|
||||
await pool.query('DELETE FROM imports');
|
||||
return { deleted: result.rowCount ?? 0 };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Назначение
|
||||
|
||||
Эндпоинт принимает банковскую выписку в формате JSON 1.0 (см. `format.md`) и атомарно импортирует транзакции в БД.
|
||||
Эндпоинт принимает банковскую выписку (PDF или JSON) и атомарно импортирует транзакции в БД.
|
||||
|
||||
## Метод и URL
|
||||
|
||||
@@ -16,7 +16,15 @@
|
||||
|
||||
## Тело запроса
|
||||
|
||||
JSON строго по схеме 1.0 (`format.md`). Content-Type: `application/json`.
|
||||
**Content-Type:** `multipart/form-data`. Поле: `file`.
|
||||
|
||||
Допустимые типы файлов:
|
||||
- **PDF** — банковская выписка. Конвертируется в JSON 1.0 через LLM (требуется `LLM_API_KEY`).
|
||||
- **JSON** — файл по схеме 1.0 (см. `format.md`).
|
||||
|
||||
При другом типе файла — `400 Bad Request`: «Допустимы только файлы PDF или JSON».
|
||||
|
||||
Пример структуры JSON 1.0:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -207,6 +215,8 @@ accountNumber|operationAt|amountSigned|commission|normalizedDescription
|
||||
| Код | Ситуация |
|
||||
|-----|--------------------------------------------------------------------------|
|
||||
| 200 | Импорт выполнен успешно |
|
||||
| 400 | Невалидный JSON или нарушение структуры схемы 1.0 |
|
||||
| 400 | Файл не загружен; неверный тип (не PDF и не JSON); некорректный JSON; ошибка извлечения текста из PDF |
|
||||
| 401 | Нет действующей сессии |
|
||||
| 422 | Семантическая ошибка валидации (некорректные данные, ошибка при вставке) |
|
||||
| 422 | Семантическая ошибка валидации; результат конвертации PDF не соответствует схеме 1.0 |
|
||||
| 502 | Ошибка LLM при конвертации PDF |
|
||||
| 503 | Конвертация PDF недоступна (не задан LLM_API_KEY) |
|
||||
|
||||
2
frontend/dist-node/vite.config.d.ts
vendored
2
frontend/dist-node/vite.config.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -1,14 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>Семейный бюджет</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
@@ -3,6 +3,20 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Import endpoint — long timeout for LLM processing
|
||||
location /api/import {
|
||||
proxy_pass http://family-budget-backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cookie_path / /;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 600s;
|
||||
client_max_body_size 15m;
|
||||
}
|
||||
|
||||
# API — проксируем на backend (сервис из docker-compose)
|
||||
location /api {
|
||||
proxy_pass http://family-budget-backend:3000;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#0f172a"/>
|
||||
<text x="16" y="23" font-family="Georgia, serif" font-size="20" font-weight="bold" fill="#3b82f6" text-anchor="middle">₽</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 271 B |
@@ -53,6 +53,40 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function requestFormData<T>(url: string, formData: FormData): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
// Do not set Content-Type — browser sets multipart/form-data with boundary
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
let body: ApiError;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = { error: 'UNAUTHORIZED', message: 'Сессия истекла' };
|
||||
}
|
||||
if (!url.includes('/api/auth/login')) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
throw new ApiException(401, body);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let body: ApiError;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = { error: 'UNKNOWN', message: res.statusText };
|
||||
}
|
||||
throw new ApiException(res.status, body);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string) => request<T>(url),
|
||||
|
||||
@@ -62,6 +96,9 @@ export const api = {
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
postFormData: <T>(url: string, formData: FormData) =>
|
||||
requestFormData<T>(url, formData),
|
||||
|
||||
put: <T>(url: string, body: unknown) =>
|
||||
request<T>(url, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||
import type {
|
||||
ImportStatementResponse,
|
||||
Import,
|
||||
} from '@family-budget/shared';
|
||||
import { api } from './client';
|
||||
|
||||
export async function importStatement(
|
||||
file: unknown,
|
||||
file: File,
|
||||
): Promise<ImportStatementResponse> {
|
||||
return api.post<ImportStatementResponse>('/api/import/statement', file);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.postFormData<ImportStatementResponse>(
|
||||
'/api/import/statement',
|
||||
formData,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getImports(): Promise<Import[]> {
|
||||
return api.get<Import[]>('/api/imports');
|
||||
}
|
||||
|
||||
export async function deleteImport(
|
||||
id: number,
|
||||
): Promise<{ deleted: number }> {
|
||||
return api.delete<{ deleted: number }>(`/api/imports/${id}`);
|
||||
}
|
||||
|
||||
5
frontend/src/api/version.ts
Normal file
5
frontend/src/api/version.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { api } from './client';
|
||||
|
||||
export async function getBackendVersion(): Promise<{ version: string }> {
|
||||
return api.get<{ version: string }>('/api/version');
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'recharts';
|
||||
import type { ByCategoryItem } from '@family-budget/shared';
|
||||
import { formatAmount } from '../utils/format';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
|
||||
interface Props {
|
||||
data: ByCategoryItem[];
|
||||
@@ -25,6 +26,9 @@ const rubFormatter = new Intl.NumberFormat('ru-RU', {
|
||||
});
|
||||
|
||||
export function CategoryChart({ data }: Props) {
|
||||
const isMobile = useMediaQuery('(max-width: 600px)');
|
||||
const chartHeight = isMobile ? 250 : 300;
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных за период</div>;
|
||||
}
|
||||
@@ -38,7 +42,7 @@ export function CategoryChart({ data }: Props) {
|
||||
|
||||
return (
|
||||
<div className="category-chart-wrapper">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { clearAllTransactions } from '../api/transactions';
|
||||
|
||||
const CONFIRM_WORD = 'УДАЛИТЬ';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
@@ -10,15 +8,11 @@ interface Props {
|
||||
|
||||
export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
const [check1, setCheck1] = useState(false);
|
||||
const [confirmInput, setConfirmInput] = useState('');
|
||||
const [check2, setCheck2] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const canConfirm =
|
||||
check1 &&
|
||||
confirmInput.trim().toUpperCase() === CONFIRM_WORD &&
|
||||
check2;
|
||||
const canConfirm = check1 && check2;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!canConfirm || loading) return;
|
||||
@@ -65,20 +59,6 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Введите <strong>{CONFIRM_WORD}</strong> для подтверждения
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmInput}
|
||||
onChange={(e) => setConfirmInput(e.target.value)}
|
||||
placeholder={CONFIRM_WORD}
|
||||
className={confirmInput && confirmInput.trim().toUpperCase() !== CONFIRM_WORD ? 'input-error' : ''}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group form-group-checkbox clear-history-check">
|
||||
<label>
|
||||
<input
|
||||
|
||||
@@ -1,13 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ClearHistoryModal } from './ClearHistoryModal';
|
||||
import { DeleteImportModal } from './DeleteImportModal';
|
||||
import { getImports } from '../api/import';
|
||||
import type { Import } from '@family-budget/shared';
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function DataSection() {
|
||||
const [showClearModal, setShowClearModal] = useState(false);
|
||||
const [imports, setImports] = useState<Import[]>([]);
|
||||
const [impToDelete, setImpToDelete] = useState<Import | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getImports().then(setImports).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleImportDeleted = () => {
|
||||
setImpToDelete(null);
|
||||
getImports().then(setImports).catch(() => {});
|
||||
navigate('/history');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="data-section">
|
||||
<div className="section-block">
|
||||
<h3>История импортов</h3>
|
||||
<p className="section-desc">
|
||||
Список импортов выписок. Можно удалить операции конкретного импорта.
|
||||
</p>
|
||||
{imports.length === 0 ? (
|
||||
<p className="muted">Импортов пока нет.</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Счёт</th>
|
||||
<th>Банк</th>
|
||||
<th>Импортировано</th>
|
||||
<th>Дубликаты</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{imports.map((imp) => (
|
||||
<tr key={imp.id}>
|
||||
<td>{formatDate(imp.importedAt)}</td>
|
||||
<td>
|
||||
{imp.accountAlias || imp.accountNumberMasked || '—'}
|
||||
</td>
|
||||
<td>{imp.bank}</td>
|
||||
<td>{imp.importedCount}</td>
|
||||
<td>{imp.duplicatesSkipped}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => setImpToDelete(imp)}
|
||||
disabled={imp.importedCount === 0}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-block">
|
||||
<h3>Очистка данных</h3>
|
||||
<p className="section-desc">
|
||||
@@ -32,6 +106,14 @@ export function DataSection() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{impToDelete && (
|
||||
<DeleteImportModal
|
||||
imp={impToDelete}
|
||||
onClose={() => setImpToDelete(null)}
|
||||
onDone={handleImportDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
70
frontend/src/components/DeleteImportModal.tsx
Normal file
70
frontend/src/components/DeleteImportModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react';
|
||||
import { deleteImport } from '../api/import';
|
||||
import type { Import } from '@family-budget/shared';
|
||||
|
||||
interface Props {
|
||||
imp: Import;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function DeleteImportModal({ imp, onClose, onDone }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const accountLabel =
|
||||
imp.accountAlias || imp.accountNumberMasked || `ID ${imp.accountId}`;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await deleteImport(imp.id);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : 'Ошибка при удалении импорта',
|
||||
);
|
||||
} finally {
|
||||
setLoading(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>
|
||||
|
||||
<div className="modal-body">
|
||||
<p className="clear-history-warn">
|
||||
Будут удалены все операции этого импорта ({imp.importedCount}{' '}
|
||||
шт.): {imp.bank} / {accountLabel}
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<p>Действие необратимо.</p>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Удаление…' : 'Удалить'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,23 +22,27 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const name = file.name.toLowerCase();
|
||||
const type = file.type;
|
||||
const isPdf = type === 'application/pdf' || name.endsWith('.pdf');
|
||||
const isJson = type === 'application/json' || name.endsWith('.json');
|
||||
|
||||
if (!isPdf && !isJson) {
|
||||
setError('Допустимы только файлы PDF или JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
const resp = await importStatement(json);
|
||||
const resp = await importStatement(file);
|
||||
setResult(resp);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof SyntaxError) {
|
||||
setError('Некорректный JSON-файл');
|
||||
} else {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : 'Ошибка импорта';
|
||||
setError(msg);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -69,11 +73,11 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
|
||||
{!result && (
|
||||
<div className="import-upload">
|
||||
<p>Выберите JSON-файл выписки (формат 1.0)</p>
|
||||
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
accept=".pdf,.json,application/pdf,application/json"
|
||||
onChange={handleFileChange}
|
||||
className="file-input"
|
||||
/>
|
||||
@@ -85,7 +89,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
|
||||
{result && (
|
||||
<div className="import-result">
|
||||
<div className="import-result-icon">✓</div>
|
||||
<div className="import-result-icon" aria-hidden="true">✓</div>
|
||||
<h3>Импорт завершён</h3>
|
||||
<table className="import-stats">
|
||||
<tbody>
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { getBackendVersion } from '../api/version';
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const { user, logout } = useAuth();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [beVersion, setBeVersion] = useState<string | null>(null);
|
||||
|
||||
const closeDrawer = () => setDrawerOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
getBackendVersion()
|
||||
.then((r) => setBeVersion(r.version))
|
||||
.catch(() => setBeVersion(null));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<aside className="sidebar">
|
||||
<button
|
||||
type="button"
|
||||
className="burger-btn"
|
||||
aria-label="Открыть меню"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{drawerOpen && (
|
||||
<div
|
||||
className="sidebar-overlay"
|
||||
aria-hidden="true"
|
||||
onClick={closeDrawer}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={`sidebar ${drawerOpen ? 'sidebar-open' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
<span className="sidebar-brand-icon">₽</span>
|
||||
<span className="sidebar-brand-text">Семейный бюджет</span>
|
||||
@@ -19,6 +51,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
className={({ isActive }) =>
|
||||
`nav-link${isActive ? ' active' : ''}`
|
||||
}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
<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" />
|
||||
@@ -35,6 +68,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
className={({ isActive }) =>
|
||||
`nav-link${isActive ? ' active' : ''}`
|
||||
}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
<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" />
|
||||
@@ -49,6 +83,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
className={({ isActive }) =>
|
||||
`nav-link${isActive ? ' active' : ''}`
|
||||
}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
<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" />
|
||||
@@ -59,11 +94,19 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-footer-top">
|
||||
<span className="sidebar-user">{user?.login}</span>
|
||||
<button className="btn-logout" onClick={() => logout()}>
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-footer-bottom">
|
||||
<span className="sidebar-version">
|
||||
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
|
||||
</span>
|
||||
<span className="sidebar-copyright">© 2025 Семейный бюджет</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main-content">{children}</main>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import type { TimeseriesItem } from '@family-budget/shared';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
|
||||
interface Props {
|
||||
data: TimeseriesItem[];
|
||||
@@ -21,6 +22,9 @@ const rubFormatter = new Intl.NumberFormat('ru-RU', {
|
||||
});
|
||||
|
||||
export function TimeseriesChart({ data }: Props) {
|
||||
const isMobile = useMediaQuery('(max-width: 600px)');
|
||||
const chartHeight = isMobile ? 250 : 300;
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных за период</div>;
|
||||
}
|
||||
@@ -32,7 +36,7 @@ export function TimeseriesChart({ data }: Props) {
|
||||
}));
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
|
||||
@@ -7,18 +7,75 @@ interface Props {
|
||||
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',
|
||||
};
|
||||
|
||||
function TransactionCard({
|
||||
tx,
|
||||
onEdit,
|
||||
}: {
|
||||
tx: Transaction;
|
||||
onEdit: (tx: Transaction) => void;
|
||||
}) {
|
||||
const directionClass = DIRECTION_CLASSES[tx.direction] ?? '';
|
||||
const isUnconfirmed =
|
||||
!tx.isCategoryConfirmed && tx.categoryId != null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`transaction-card ${isUnconfirmed ? 'row-unconfirmed' : ''}`}
|
||||
>
|
||||
<div className="transaction-card-header">
|
||||
<span className="transaction-card-date">
|
||||
{formatDateTime(tx.operationAt)}
|
||||
</span>
|
||||
<span className={`transaction-card-amount ${directionClass}`}>
|
||||
{formatAmount(tx.amountSigned)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="transaction-card-body">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="transaction-card-footer">
|
||||
<span className="transaction-card-meta">
|
||||
{tx.accountAlias || '—'} · {tx.categoryName || '—'}
|
||||
</span>
|
||||
<div className="transaction-card-actions">
|
||||
{tx.categoryId != null && !tx.isCategoryConfirmed && (
|
||||
<span
|
||||
className="badge badge-warning"
|
||||
title="Категория не подтверждена"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon btn-icon-touch"
|
||||
onClick={() => onEdit(tx)}
|
||||
title="Редактировать"
|
||||
>
|
||||
<svg width="20" height="20" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
if (loading) {
|
||||
return <div className="table-loading">Загрузка операций...</div>;
|
||||
@@ -29,7 +86,8 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-wrapper">
|
||||
<>
|
||||
<div className="table-wrapper table-desktop">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -103,5 +161,12 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="transaction-cards transaction-mobile">
|
||||
{transactions.map((tx) => (
|
||||
<TransactionCard key={tx.id} tx={tx} onEdit={onEdit} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
18
frontend/src/hooks/useMediaQuery.ts
Normal file
18
frontend/src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
setMatches(mql.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -138,11 +138,34 @@ body {
|
||||
.sidebar-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-footer-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar-footer-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--color-sidebar-text);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.sidebar-version {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.sidebar-copyright {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
font-size: 13px;
|
||||
color: var(--color-sidebar-text);
|
||||
@@ -249,6 +272,7 @@ body {
|
||||
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -598,6 +622,75 @@ textarea {
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Transaction cards (mobile view) */
|
||||
.transaction-cards {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.transaction-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-left: 4px solid var(--color-border);
|
||||
}
|
||||
|
||||
.transaction-card.row-unconfirmed {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.transaction-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.transaction-card-date {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.transaction-card-amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.transaction-card-body {
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.transaction-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.transaction-card-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.transaction-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-icon-touch {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
@@ -758,8 +851,9 @@ textarea {
|
||||
|
||||
.pagination-size {
|
||||
width: auto;
|
||||
padding: 4px 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-page {
|
||||
@@ -771,6 +865,11 @@ textarea {
|
||||
font-size: 14px;
|
||||
transition: all var(--transition);
|
||||
font-family: inherit;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-page:hover:not(:disabled) {
|
||||
@@ -837,6 +936,12 @@ textarea {
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: -8px -8px -8px 0;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
@@ -986,6 +1091,13 @@ textarea {
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
margin-bottom: 0;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@@ -1000,6 +1112,8 @@ textarea {
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
font-family: inherit;
|
||||
flex-shrink: 0;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
@@ -1311,6 +1425,61 @@ textarea {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Burger button (mobile)
|
||||
================================================================ */
|
||||
|
||||
.burger-btn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 101;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
background: var(--color-sidebar);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.burger-btn:hover {
|
||||
background: var(--color-sidebar-hover);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 99;
|
||||
animation: overlay-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes overlay-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drawer-slide-in {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
Responsive
|
||||
================================================================ */
|
||||
@@ -1322,13 +1491,29 @@ textarea {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.burger-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
width: min(280px, 85vw);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.sidebar.sidebar-open {
|
||||
transform: translateX(0);
|
||||
animation: drawer-slide-in 0.25s ease;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 200px;
|
||||
padding: 16px;
|
||||
margin-left: 0;
|
||||
padding: 16px 16px 16px 60px;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
@@ -1342,4 +1527,114 @@ textarea {
|
||||
.summary-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header .btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.filter-dates-wrap .btn-page {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.filter-presets {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
flex-wrap: nowrap;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.btn-preset {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-sort .btn-sort-order {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.table-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.transaction-mobile {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-overlay {
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modal {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
min-height: 100dvh;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area for devices with notches */
|
||||
@supports (padding: env(safe-area-inset-bottom)) {
|
||||
.burger-btn {
|
||||
top: calc(12px + env(safe-area-inset-top));
|
||||
left: calc(12px + env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding-bottom: calc(20px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __FE_VERSION__: string;
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, 'package.json'), 'utf-8'),
|
||||
) as { version: string };
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__FE_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
261
package-lock.json
generated
261
package-lock.json
generated
@@ -22,6 +22,9 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"openai": "^6.27.0",
|
||||
"pdf-parse": "1.1.1",
|
||||
"pg": "^8.19.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
@@ -29,6 +32,7 @@
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -36,6 +40,28 @@
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"backend/node_modules/pdf-parse": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz",
|
||||
"integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^3.1.0",
|
||||
"node-ensure": "^0.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.8.1"
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.1.0",
|
||||
@@ -85,6 +111,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1368,6 +1395,7 @@
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -1394,6 +1422,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
|
||||
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||
@@ -1436,6 +1474,7 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1512,6 +1551,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -1569,6 +1614,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -1583,6 +1629,23 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1651,6 +1714,21 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
@@ -2470,6 +2548,68 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -2498,6 +2638,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-ensure": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
|
||||
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -2547,6 +2693,27 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
|
||||
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -2571,6 +2738,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.11.0",
|
||||
"pg-pool": "^3.12.0",
|
||||
@@ -2668,6 +2836,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2817,6 +2986,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2826,6 +2996,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -2931,6 +3102,20 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
@@ -3034,6 +3219,26 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
@@ -3213,6 +3418,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -3278,7 +3500,6 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3296,7 +3517,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3314,7 +3534,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3332,7 +3551,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3350,7 +3568,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3368,7 +3585,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3386,7 +3602,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3404,7 +3619,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3422,7 +3636,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3440,7 +3653,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3458,7 +3670,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3476,7 +3687,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3494,7 +3704,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3512,7 +3721,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3530,7 +3738,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3548,7 +3755,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3566,7 +3772,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3584,7 +3789,6 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3602,7 +3806,6 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3620,7 +3823,6 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3638,7 +3840,6 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3656,7 +3857,6 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3674,7 +3874,6 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3692,7 +3891,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3710,7 +3908,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3728,7 +3925,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3789,6 +3985,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -3850,6 +4052,12 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
@@ -3900,6 +4108,7 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Промпт для конвертации PDF банковской выписки в JSON
|
||||
|
||||
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
|
||||
|
||||
## Структура выходного JSON
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/** Import record from imports table (for import history) */
|
||||
export interface Import {
|
||||
id: number;
|
||||
importedAt: string;
|
||||
accountId: number | null;
|
||||
accountAlias: string | null;
|
||||
bank: string;
|
||||
accountNumberMasked: string;
|
||||
importedCount: number;
|
||||
duplicatesSkipped: number;
|
||||
totalInFile: number;
|
||||
}
|
||||
|
||||
/** JSON 1.0 statement file — the shape accepted by POST /api/import/statement */
|
||||
export interface StatementFile {
|
||||
schemaVersion: '1.0';
|
||||
|
||||
@@ -37,6 +37,7 @@ export type {
|
||||
StatementHeader,
|
||||
StatementTransaction,
|
||||
ImportStatementResponse,
|
||||
Import,
|
||||
} from './import';
|
||||
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user