Compare commits
29 Commits
pdf2json-f
...
0589da5005
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
|
|
50154f304c | ||
|
|
8625f7f6cf |
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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ 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
|
||||
|
||||
|
||||
@@ -18,3 +18,6 @@ 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=
|
||||
|
||||
@@ -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": "*",
|
||||
@@ -16,7 +18,7 @@
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"openai": "^6.27.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdf-parse": "1.1.1",
|
||||
"pg": "^8.19.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { requireAuth } from './middleware/auth';
|
||||
|
||||
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';
|
||||
@@ -26,6 +27,7 @@ app.use('/api/auth', authRouter);
|
||||
// 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);
|
||||
|
||||
@@ -24,4 +24,7 @@ export const config = {
|
||||
|
||||
/** Базовый 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,24 @@ 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: `
|
||||
@@ -176,6 +194,24 @@ const migrations: { name: string; sql: string }[] = [
|
||||
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,
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
import OpenAI from 'openai';
|
||||
import { config } from '../config';
|
||||
import type { StatementFile } from '@family-budget/shared';
|
||||
@@ -64,6 +63,16 @@ export function isPdfConversionError(r: unknown): r is PdfConversionError {
|
||||
);
|
||||
}
|
||||
|
||||
// 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> {
|
||||
@@ -77,10 +86,8 @@ export async function convertPdfToStatement(
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
const parser = new PDFParse({ data: buffer });
|
||||
const result = await parser.getText();
|
||||
const result = await getPdfParse()(buffer);
|
||||
text = result.text || '';
|
||||
await parser.destroy();
|
||||
} catch (err) {
|
||||
console.error('PDF extraction error:', err);
|
||||
return {
|
||||
@@ -101,16 +108,18 @@ export async function convertPdfToStatement(
|
||||
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: 'gpt-4o-mini',
|
||||
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();
|
||||
@@ -150,7 +159,21 @@ export async function convertPdfToStatement(
|
||||
return {
|
||||
status: 502,
|
||||
error: 'BAD_GATEWAY',
|
||||
message: 'Временная ошибка конвертации',
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<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;
|
||||
|
||||
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 |
@@ -1,4 +1,7 @@
|
||||
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||
import type {
|
||||
ImportStatementResponse,
|
||||
Import,
|
||||
} from '@family-budget/shared';
|
||||
import { api } from './client';
|
||||
|
||||
export async function importStatement(
|
||||
@@ -11,3 +14,13 @@ export async function importStatement(
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -89,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>
|
||||
|
||||
246
package-lock.json
generated
246
package-lock.json
generated
@@ -24,7 +24,7 @@
|
||||
"express": "^5.2.1",
|
||||
"multer": "^2.1.1",
|
||||
"openai": "^6.27.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdf-parse": "1.1.1",
|
||||
"pg": "^8.19.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
@@ -40,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",
|
||||
@@ -854,190 +876,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2800,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",
|
||||
@@ -2889,38 +2733,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"pdfjs-dist": "5.4.296"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-parse": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||
|
||||
@@ -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