24 Commits

Author SHA1 Message Date
vakabunga
67fed57118 fix: disable gzip and pad SSE events to prevent proxy buffering
Add gzip off to Nginx import location — the global gzip on was
buffering text/event-stream responses. Pad each SSE event to 4 KB
with comment lines to push past any remaining proxy buffer threshold.
2026-03-14 19:45:33 +03:00
45a6f3d374 Merge pull request 'fix: eliminate SSE buffering through Nginx proxy' (#8) from fix/sse-proxy-buffering into main
Reviewed-on: #8
2026-03-14 14:31:16 +00:00
vakabunga
aaf8cacf75 fix: eliminate SSE buffering through Nginx proxy
Add 2 KB padding comment after headers to push past proxy buffer
threshold, enable TCP_NODELAY on the socket, and remove erroneous
chunked_transfer_encoding off from Nginx that caused full response
buffering.
2026-03-14 17:30:52 +03:00
vakabunga
e28d0f46d0 fix: reopen result modal from progress bar, faster progress, handle LLM context error
- Move import modal visibility into ImportContext so the Layout
  progress pill can reopen the result dialog after the modal was closed.
- Raise LLM progress cap from 85% to 98% and drop the intermediate
  -import 88%- SSE event to eliminate the visual stall after LLM finishes.
- Detect LLM context-length errors (n_keep >= n_ctx) and surface a
  clear message instead of generic -Временная ошибка конвертации-.
2026-03-14 17:05:55 +03:00
22be09c101 Merge pull request 'fix: adaptive LLM progress estimation and emit 85% on stream end' (#6) from fix/adaptive-llm-progress into main
Reviewed-on: #6
2026-03-14 13:43:27 +00:00
vakabunga
78c4730196 fix: adaptive LLM progress estimation and emit 85% on stream end
Hardcoded EXPECTED_CHARS (15k) caused progress to stall at ~20-25% for
short statements. Now expected size is derived from input text length.
Also emit an explicit 85% event when the LLM stream finishes, and
throttle SSE events to 300ms to reduce browser overhead.
2026-03-14 16:41:12 +03:00
f2d0c91488 Merge pull request 'feat: stream PDF import progress via SSE with global progress bar' (#5) from feature/pdf-import-sse-streaming into main
Reviewed-on: #5
2026-03-14 13:18:57 +00:00
vakabunga
1d7fbea9ef feat: stream PDF import progress via SSE with global progress bar
Replace the synchronous PDF import with Server-Sent Events streaming
between the backend (LLM) and the browser. The user can now close the
import modal and continue working while the conversion runs — a fixed
progress bar in the Layout shows real-time stage and percentage.
2026-03-14 16:18:31 +03:00
vakabunga
627706228b fix: remove response_format incompatible with LM Studio API 2026-03-14 15:23:33 +03:00
vakabunga
a5f2294440 fix: adds copy of node modules from backend folder 2026-03-14 15:14:04 +03:00
25ddd6b7ed Merge pull request 'fix: lazy-load pdf-parse to prevent startup crash if module is missing' (#4) from fix-lazyload-pdfparse-crash into main
Reviewed-on: #4
2026-03-14 11:18:55 +00:00
vakabunga
3e481d5a55 fix: lazy-load pdf-parse to prevent startup crash if module is missing 2026-03-14 14:18:25 +03:00
cf24d5dc26 Merge pull request 'fix: downgrade pdf-parse to 1.1.1 to eliminate native dependency' (#3) from fix-pdfparse-crash into main
Reviewed-on: #3
2026-03-14 11:03:48 +00:00
vakabunga
723df494ca fix: downgrade pdf-parse to 1.1.1 to eliminate native dependency
pdf-parse@2.4.5 depends on @napi-rs/canvas (native Skia binary compiled
with AVX instructions) which crashes with SIGILL on the Synology NAS CPU.
Version 1.1.1 is pure JavaScript and works on any architecture.
2026-03-14 14:03:14 +03:00
5fa6b921d8 Merge pull request 'fix-pdf-parser-crash' (#2) from fix-pdf-parser-crash into main
Reviewed-on: #2
2026-03-14 10:43:21 +00:00
vakabunga
feb756cfe2 fix(docker): prevent backend crash loop caused by pdf-parse native deps
pdf-parse@2.4.5 pulls in @napi-rs/canvas (native Skia binary) which
crashes on import in Alpine containers. Moved to lazy require() so the
app starts normally and pdf-parse loads only when PDF conversion is
actually requested.
- Lazy-load pdf-parse in pdfToStatement to avoid startup crash
- Add libc6-compat, fontconfig, freetype to Alpine runner stage
- Increase npm fetch timeouts in both Dockerfiles for slow networks
- Add connectionTimeoutMillis to pg Pool for faster failure detection
2026-03-14 13:37:34 +03:00
vakabunga
b598216d24 feat(backend): настраиваемая LLM для конвертации PDF в JSON
- Добавлена переменная LLM_MODEL в конфиг
- Увеличен max_tokens до 32768 для крупных выписок
- Включен response_format: json_object
- Добавлен скрипт test:llm для проверки подключения к LLM-серверу
2026-03-13 23:12:52 +03:00
0638885812 Merge pull request 'feat: adds PDF import with conversion to JSON 1.0' (#1) from pdf2json-feat into main
Reviewed-on: #1
2026-03-13 10:40:03 +00:00
Anton
975f2c4fd2 feat: adds PDF import with conversion to JSON 1.0
- Accept only PDF and JSON files in import modal and API
- Convert PDF statements to JSON 1.0 via LLM (OpenAI-compatible)
- Use multipart/form-data for file upload (multer, 15 MB limit)
- Add LLM_API_KEY and LLM_API_BASE_URL for configurable LLM endpoint
- Update ImportModal to validate type and send FormData
- Add postFormData to API client for file upload
2026-03-13 13:38:02 +03:00
vakabunga
50154f304c chore: adds copy of migration file to prod 2026-03-12 22:25:52 +03:00
vakabunga
8625f7f6cf chore: adds .gitattributes for consistent line endings across machines 2026-03-12 22:11:21 +03:00
Anton
d1536b8872 feat: расширенные правила категоризации (миграция 004) 2026-03-12 14:50:02 +03:00
Anton
20d2a2b497 chore: добавить dist-node в .gitignore, убрать из отслеживания
Made-with: Cursor
2026-03-10 11:54:31 +03:00
Anton
56b5c81ec5 feat(frontend): адаптация под мобильные устройства
- Мобильная навигация: hamburger-меню и drawer вместо фиксированного sidebar
- Модальные окна на весь экран при ширине < 480px
- Адаптивные заголовки страниц и фильтры (touch-friendly)
- Card view для таблицы операций при ширине < 600px
- Горизонтальный скролл вкладок настроек
- Увеличенные touch-targets (44px) для пагинации и кнопок
- Уменьшенная высота графиков на мобильных
- Поддержка safe-area-inset для устройств с вырезами
- theme-color в index.html

Made-with: Cursor
2026-03-10 11:50:36 +03:00
32 changed files with 1832 additions and 185 deletions

6
.gitattributes vendored Normal file
View File

@@ -0,0 +1,6 @@
# Единые правила переносов строк для всех машин
* text=auto eol=lf
# Windows-скрипты — исключение, им нужен CRLF
*.bat text eol=crlf
*.cmd text eol=crlf

6
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/
dist/
dist-node/
build/
.env
@@ -17,3 +18,8 @@ Thumbs.db
coverage/
jan_feb.json
jan-feb.json
history.xlsx
match_analysis.py
match_report.txt
statements/

View File

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

View File

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

View File

@@ -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=

View File

@@ -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) |
### Транзакции

View File

@@ -6,7 +6,9 @@
"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",

View File

@@ -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',
};

View File

@@ -133,6 +133,49 @@ const migrations: { name: string; sql: string }[] = [
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
`,
},
{
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);
`,
},
];
export async function runMigrations(): Promise<void> {

View File

@@ -7,4 +7,5 @@ export const pool = new Pool({
database: config.db.database,
user: config.db.user,
password: config.db.password,
connectionTimeoutMillis: 5000,
});

View File

@@ -1,13 +1,131 @@
import { Router } from 'express';
import multer from 'multer';
import { asyncHandler } from '../utils';
import { importStatement, isValidationError } from '../services/import';
import {
convertPdfToStatementStreaming,
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 SSE_PAD_TARGET = 4096;
function sseWrite(res: import('express').Response, data: Record<string, unknown>) {
const payload = `data: ${JSON.stringify(data)}\n\n`;
const pad = Math.max(0, SSE_PAD_TARGET - payload.length);
// SSE comment lines (": ...") are ignored by the browser but push
// data past proxy buffer thresholds so each event is delivered immediately.
res.write(pad > 0 ? `: ${' '.repeat(pad)}\n${payload}` : payload);
}
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;
}
if (isPdfFile(file)) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.socket?.setNoDelay(true);
res.flushHeaders();
try {
const converted = await convertPdfToStatementStreaming(
file.buffer,
(stage, progress, message) => {
sseWrite(res, { stage, progress, message });
},
);
if (isPdfConversionError(converted)) {
sseWrite(res, {
stage: 'error',
message: converted.message,
});
res.end();
return;
}
const result = await importStatement(converted);
if (isValidationError(result)) {
sseWrite(res, {
stage: 'error',
message: (result as { message: string }).message,
});
res.end();
return;
}
sseWrite(res, {
stage: 'done',
progress: 100,
result,
});
} catch (err) {
console.error('SSE import error:', err);
sseWrite(res, {
stage: 'error',
message: 'Внутренняя ошибка сервера',
});
}
res.end();
return;
}
// JSON files — synchronous response as before
let body: unknown;
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,

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

View File

@@ -0,0 +1,294 @@
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: 'Результат конвертации пуст',
};
}
return parseConversionResult(content);
} catch (err) {
console.error('LLM conversion error:', err);
return {
status: 502,
error: 'BAD_GATEWAY',
message: extractLlmErrorMessage(err),
};
}
}
export type ProgressStage = 'pdf' | 'llm' | 'import';
export type OnProgress = (stage: ProgressStage, progress: number, message: string) => void;
const LLM_PROGRESS_MIN = 10;
const LLM_PROGRESS_MAX = 98;
const LLM_PROGRESS_RANGE = LLM_PROGRESS_MAX - LLM_PROGRESS_MIN;
const THROTTLE_MS = 300;
export async function convertPdfToStatementStreaming(
buffer: Buffer,
onProgress: OnProgress,
): Promise<StatementFile | PdfConversionError> {
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
return {
status: 503,
error: 'SERVICE_UNAVAILABLE',
message: 'Конвертация PDF недоступна: не задан LLM_API_KEY',
};
}
onProgress('pdf', 2, 'Извлечение текста из PDF...');
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',
};
}
onProgress('pdf', 8, 'Текст извлечён, отправка в LLM...');
const openai = new OpenAI({
apiKey: config.llmApiKey,
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
timeout: 5 * 60 * 1000,
});
try {
const stream = 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,
stream: true,
});
// Estimate expected output size as ~2x the input PDF text length, clamped
const expectedChars = Math.max(2_000, Math.min(text.length * 2, 30_000));
let accumulated = '';
let charsReceived = 0;
let lastEmitTime = 0;
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) {
accumulated += delta;
charsReceived += delta.length;
const now = Date.now();
if (now - lastEmitTime >= THROTTLE_MS) {
const ratio = Math.min(1, charsReceived / expectedChars);
const llmProgress = Math.min(
LLM_PROGRESS_MAX,
Math.round(ratio * LLM_PROGRESS_RANGE + LLM_PROGRESS_MIN),
);
onProgress('llm', llmProgress, 'Конвертация через LLM...');
lastEmitTime = now;
}
}
}
onProgress('llm', LLM_PROGRESS_MAX, 'LLM завершил, обработка результата...');
const content = accumulated.trim();
if (!content) {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации пуст',
};
}
return parseConversionResult(content);
} catch (err) {
console.error('LLM streaming 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 'Временная ошибка конвертации';
}
function parseConversionResult(content: string): StatementFile | PdfConversionError {
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;
}

View File

@@ -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) |

View File

@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

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

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<title>Семейный бюджет</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@@ -3,6 +3,22 @@ server {
root /usr/share/nginx/html;
index index.html;
# Import endpoint — SSE streaming, long timeout, no buffering
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;
proxy_buffering off;
gzip off;
client_max_body_size 15m;
}
# API — проксируем на backend (сервис из docker-compose)
location /api {
proxy_pass http://family-budget-backend:3000;

View File

@@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext';
import { ImportProvider } from './context/ImportContext';
import { Layout } from './components/Layout';
import { LoginPage } from './pages/LoginPage';
import { HistoryPage } from './pages/HistoryPage';
@@ -18,6 +19,7 @@ export function App() {
}
return (
<ImportProvider>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/history" replace />} />
@@ -27,5 +29,6 @@ export function App() {
<Route path="*" element={<Navigate to="/history" replace />} />
</Routes>
</Layout>
</ImportProvider>
);
}

View File

@@ -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) }),

View File

@@ -2,7 +2,83 @@ import type { ImportStatementResponse } 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 interface SseProgressEvent {
stage: 'pdf' | 'llm' | 'import';
progress: number;
message: string;
}
export interface SseDoneEvent {
stage: 'done';
progress: 100;
result: ImportStatementResponse;
}
export interface SseErrorEvent {
stage: 'error';
message: string;
}
export type SseEvent = SseProgressEvent | SseDoneEvent | SseErrorEvent;
export async function importStatementStream(
file: File,
onEvent: (event: SseEvent) => void,
): Promise<void> {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/import/statement', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!res.ok) {
let msg = 'Ошибка импорта';
try {
const body = await res.json();
if (body.message) msg = body.message;
} catch { /* use default */ }
onEvent({ stage: 'error', message: msg });
return;
}
const reader = res.body?.getReader();
if (!reader) {
onEvent({ stage: 'error', message: 'Streaming не поддерживается' });
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
try {
const parsed = JSON.parse(trimmed.slice(6)) as SseEvent;
onEvent(parsed);
} catch { /* skip malformed lines */ }
}
}
}

View File

@@ -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}

View File

@@ -2,6 +2,7 @@ import { useState, useRef } from 'react';
import type { ImportStatementResponse } from '@family-budget/shared';
import { importStatement } from '../api/import';
import { updateAccount } from '../api/accounts';
import { useImport } from '../context/ImportContext';
interface Props {
onClose: () => void;
@@ -9,41 +10,70 @@ interface Props {
}
export function ImportModal({ onClose, onDone }: Props) {
const [result, setResult] = useState<ImportStatementResponse | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { importState, startImport, clearImport } = useImport();
const [jsonResult, setJsonResult] = useState<ImportStatementResponse | null>(null);
const [jsonError, setJsonError] = useState('');
const [jsonLoading, setJsonLoading] = useState(false);
const [alias, setAlias] = useState('');
const [aliasSaved, setAliasSaved] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const result = importState.result ?? jsonResult;
const error = importState.error || jsonError;
const loading = importState.active || jsonLoading;
const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0];
if (!file) return;
setLoading(true);
setError('');
setResult(null);
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) {
setJsonError('Допустимы только файлы PDF или JSON');
return;
}
setJsonError('');
setJsonResult(null);
if (isPdf) {
startImport(file);
return;
}
setJsonLoading(true);
try {
const text = await file.text();
const json = JSON.parse(text);
const resp = await importStatement(json);
setResult(resp);
const resp = await importStatement(file);
setJsonResult(resp);
} catch (err: unknown) {
if (err instanceof SyntaxError) {
setError('Некорректный JSON-файл');
} else {
const msg =
err instanceof Error ? err.message : 'Ошибка импорта';
setError(msg);
}
setJsonError(msg);
} finally {
setLoading(false);
setJsonLoading(false);
}
};
const handleClose = () => {
if (importState.active) {
if (window.confirm('Импорт продолжится в фоне. Закрыть окно?')) {
onClose();
}
} else {
onClose();
}
};
const handleDone = () => {
onDone();
};
const handleSaveAlias = async () => {
if (!result || !alias.trim()) return;
try {
@@ -54,12 +84,21 @@ export function ImportModal({ onClose, onDone }: Props) {
}
};
const stageLabel = (stage: string) => {
switch (stage) {
case 'pdf': return 'Извлечение текста...';
case 'llm': return 'Конвертация через LLM...';
case 'import': return 'Сохранение в базу...';
default: return 'Обработка...';
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-overlay" onClick={handleClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Импорт выписки</h2>
<button className="btn-close" onClick={onClose}>
<button className="btn-close" onClick={handleClose}>
&times;
</button>
</div>
@@ -67,25 +106,42 @@ export function ImportModal({ onClose, onDone }: Props) {
<div className="modal-body">
{error && <div className="alert alert-error">{error}</div>}
{!result && (
{!result && !importState.active && (
<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"
disabled={loading}
/>
{loading && (
{jsonLoading && (
<div className="import-loading">Импорт...</div>
)}
</div>
)}
{importState.active && (
<div className="import-upload">
<div className="import-progress-modal">
<div className="import-progress-modal-bar">
<div
className="import-progress-modal-fill"
style={{ width: `${importState.progress}%` }}
/>
</div>
<p className="import-progress-modal-label">
{stageLabel(importState.stage)} {importState.progress}%
</p>
</div>
</div>
)}
{result && (
<div className="import-result">
<div className="import-result-icon">&check;</div>
<div className="import-result-icon" aria-hidden="true"></div>
<h3>Импорт завершён</h3>
<table className="import-stats">
<tbody>
@@ -145,16 +201,15 @@ export function ImportModal({ onClose, onDone }: Props) {
<div className="modal-footer">
{result ? (
<button className="btn btn-primary" onClick={onDone}>
<button className="btn btn-primary" onClick={handleDone}>
Готово
</button>
) : (
<button
className="btn btn-secondary"
onClick={onClose}
disabled={loading}
onClick={handleClose}
>
Отмена
{importState.active ? 'Свернуть' : 'Отмена'}
</button>
)}
</div>

View File

@@ -1,13 +1,103 @@
import type { ReactNode } from 'react';
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useImport } from '../context/ImportContext';
function ImportProgressBar() {
const { importState, clearImport, openModal } = useImport();
const [visible, setVisible] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const isActive = importState.active;
const isDone = importState.stage === 'done';
const isError = importState.stage === 'error';
const showBar = isActive || isDone || isError;
useEffect(() => {
if (showBar) {
setVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}
if (isDone || isError) {
hideTimerRef.current = setTimeout(() => {
setVisible(false);
clearImport();
}, 10_000);
}
return () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
};
}, [showBar, isDone, isError, clearImport]);
const handleClick = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
openModal();
setVisible(false);
}, [openModal]);
if (!visible) return null;
const barClass = isError
? 'import-progress-bar import-progress-bar--error'
: isDone
? 'import-progress-bar import-progress-bar--done'
: 'import-progress-bar';
const labelText = isError
? `Ошибка импорта: ${importState.message}`
: isDone && importState.result
? `Импорт завершён — ${importState.result.imported} операций`
: `${importState.message} ${importState.progress}%`;
return (
<div className={barClass}>
<div
className="import-progress-bar__fill"
style={{ width: `${isDone ? 100 : importState.progress}%` }}
/>
<button
type="button"
className={`import-progress-label ${isDone || isError ? 'import-progress-label--clickable' : ''}`}
onClick={isDone || isError ? handleClick : undefined}
>
{labelText}
</button>
</div>
);
}
export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth();
const [drawerOpen, setDrawerOpen] = useState(false);
const closeDrawer = () => setDrawerOpen(false);
return (
<div className="layout">
<aside className="sidebar">
<ImportProgressBar />
<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 +109,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 +126,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 +141,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" />

View File

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

View File

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

View File

@@ -0,0 +1,114 @@
import {
createContext,
useContext,
useState,
useCallback,
useRef,
type ReactNode,
} from 'react';
import type { ImportStatementResponse } from '@family-budget/shared';
import { importStatementStream, type SseEvent } from '../api/import';
export interface ImportProgress {
active: boolean;
stage: string;
progress: number;
message: string;
result?: ImportStatementResponse;
error?: string;
}
interface ImportContextValue {
importState: ImportProgress;
showModal: boolean;
openModal: () => void;
closeModal: () => void;
startImport: (file: File) => void;
clearImport: () => void;
}
const INITIAL: ImportProgress = {
active: false,
stage: '',
progress: 0,
message: '',
};
const ImportContext = createContext<ImportContextValue | null>(null);
export function ImportProvider({ children }: { children: ReactNode }) {
const [importState, setImportState] = useState<ImportProgress>(INITIAL);
const [showModal, setShowModal] = useState(false);
const runningRef = useRef(false);
const openModal = useCallback(() => setShowModal(true), []);
const closeModal = useCallback(() => setShowModal(false), []);
const startImport = useCallback((file: File) => {
if (runningRef.current) return;
runningRef.current = true;
setImportState({
active: true,
stage: 'pdf',
progress: 0,
message: 'Загрузка файла...',
});
importStatementStream(file, (event: SseEvent) => {
if (event.stage === 'done') {
setImportState({
active: false,
stage: 'done',
progress: 100,
message: 'Импорт завершён',
result: event.result,
});
runningRef.current = false;
} else if (event.stage === 'error') {
setImportState({
active: false,
stage: 'error',
progress: 0,
message: event.message,
error: event.message,
});
runningRef.current = false;
} else {
setImportState({
active: true,
stage: event.stage,
progress: event.progress,
message: event.message,
});
}
}).catch((err) => {
setImportState({
active: false,
stage: 'error',
progress: 0,
message: err instanceof Error ? err.message : 'Ошибка импорта',
error: err instanceof Error ? err.message : 'Ошибка импорта',
});
runningRef.current = false;
});
}, []);
const clearImport = useCallback(() => {
setImportState(INITIAL);
}, []);
return (
<ImportContext.Provider value={{
importState, showModal, openModal, closeModal, startImport, clearImport,
}}>
{children}
</ImportContext.Provider>
);
}
export function useImport(): ImportContextValue {
const ctx = useContext(ImportContext);
if (!ctx) throw new Error('useImport must be used within ImportProvider');
return ctx;
}

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

View File

@@ -18,6 +18,7 @@ import { TransactionTable } from '../components/TransactionTable';
import { Pagination } from '../components/Pagination';
import { EditTransactionModal } from '../components/EditTransactionModal';
import { ImportModal } from '../components/ImportModal';
import { useImport } from '../context/ImportContext';
import { toISODate } from '../utils/format';
const PARAM_KEYS = [
@@ -125,7 +126,7 @@ export function HistoryPage() {
const [accounts, setAccounts] = useState<Account[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [editingTx, setEditingTx] = useState<Transaction | null>(null);
const [showImport, setShowImport] = useState(false);
const { showModal: showImport, openModal: openImport, closeModal: closeImport, clearImport } = useImport();
useEffect(() => {
getAccounts().then(setAccounts).catch(() => {});
@@ -197,7 +198,8 @@ export function HistoryPage() {
};
const handleImportDone = () => {
setShowImport(false);
closeImport();
clearImport();
fetchData();
getAccounts().then(setAccounts).catch(() => {});
};
@@ -208,7 +210,7 @@ export function HistoryPage() {
<h1>История операций</h1>
<button
className="btn btn-primary"
onClick={() => setShowImport(true)}
onClick={openImport}
>
Импорт выписки
</button>
@@ -262,7 +264,7 @@ export function HistoryPage() {
{showImport && (
<ImportModal
onClose={() => setShowImport(false)}
onClose={closeImport}
onDone={handleImportDone}
/>
)}

View File

@@ -598,6 +598,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 +827,9 @@ textarea {
.pagination-size {
width: auto;
padding: 4px 8px;
padding: 8px 12px;
font-size: 13px;
min-height: 44px;
}
.btn-page {
@@ -771,6 +841,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 +912,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 {
@@ -977,6 +1058,152 @@ textarea {
font-weight: 500;
}
/* Import progress bar in modal */
.import-progress-modal {
padding: 20px 0;
}
.import-progress-modal-bar {
height: 8px;
background: var(--color-border);
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
}
.import-progress-modal-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-primary), #6366f1);
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
.import-progress-modal-fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
.import-progress-modal-label {
text-align: center;
color: var(--color-text-secondary);
font-size: 14px;
}
/* ================================================================
Import progress bar (fixed top, Layout)
================================================================ */
.import-progress-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 300;
height: 4px;
background: var(--color-border);
}
.import-progress-bar--done {
background: var(--color-success-light);
}
.import-progress-bar--error {
background: var(--color-danger-light);
}
.import-progress-bar__fill {
height: 100%;
border-radius: 0 2px 2px 0;
transition: width 0.3s ease;
position: relative;
background: linear-gradient(90deg, var(--color-primary), #6366f1);
}
.import-progress-bar__fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.4) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
.import-progress-bar--done .import-progress-bar__fill {
background: var(--color-success);
}
.import-progress-bar--done .import-progress-bar__fill::after {
animation: none;
}
.import-progress-bar--error .import-progress-bar__fill {
background: var(--color-danger);
width: 100% !important;
}
.import-progress-bar--error .import-progress-bar__fill::after {
animation: none;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.import-progress-label {
position: fixed;
top: 6px;
right: 16px;
z-index: 301;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 16px;
padding: 4px 14px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary);
box-shadow: var(--shadow-sm);
white-space: nowrap;
cursor: default;
font-family: inherit;
}
.import-progress-label--clickable {
cursor: pointer;
}
.import-progress-label--clickable:hover {
background: var(--color-bg);
border-color: var(--color-border-hover);
}
.import-progress-bar--done .import-progress-label {
color: var(--color-success);
border-color: var(--color-success);
}
.import-progress-bar--error .import-progress-label {
color: var(--color-danger);
border-color: var(--color-danger);
}
/* ================================================================
Tabs
================================================================ */
@@ -986,6 +1213,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 +1234,8 @@ textarea {
cursor: pointer;
transition: all var(--transition);
font-family: inherit;
flex-shrink: 0;
min-height: 44px;
}
.tab:hover {
@@ -1311,6 +1547,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 +1613,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 +1649,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));
}
}

252
package-lock.json generated
View File

@@ -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",
@@ -1394,6 +1420,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",
@@ -1512,6 +1548,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",
@@ -1583,6 +1625,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 +1710,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 +2544,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 +2634,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 +2689,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",
@@ -2931,6 +3094,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 +3211,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 +3410,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 +3492,6 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3296,7 +3509,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3314,7 +3526,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3332,7 +3543,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3350,7 +3560,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3368,7 +3577,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3386,7 +3594,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3404,7 +3611,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3422,7 +3628,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3440,7 +3645,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3458,7 +3662,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3476,7 +3679,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3494,7 +3696,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3512,7 +3713,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3530,7 +3730,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3548,7 +3747,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3566,7 +3764,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3584,7 +3781,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3602,7 +3798,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3620,7 +3815,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3638,7 +3832,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3656,7 +3849,6 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3674,7 +3866,6 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3692,7 +3883,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3710,7 +3900,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3728,7 +3917,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3789,6 +3977,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 +4044,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",

View File

@@ -1,5 +1,3 @@
# Промпт для конвертации PDF банковской выписки в JSON
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
## Структура выходного JSON