diff --git a/.gitignore b/.gitignore index 062a7a7..585724f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ history.xlsx match_analysis.py match_report.txt statements/ +.cursor/ diff --git a/backend/src/app.ts b/backend/src/app.ts index e134947..80d7727 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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); diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 36a7eaf..092e5a2 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -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 { diff --git a/backend/src/routes/imports.ts b/backend/src/routes/imports.ts new file mode 100644 index 0000000..b0ee5d4 --- /dev/null +++ b/backend/src/routes/imports.ts @@ -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; diff --git a/backend/src/services/import.ts b/backend/src/services/import.ts index 1b25fe6..34a1619 100644 --- a/backend/src/services/import.ts +++ b/backend/src/services/import.ts @@ -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, diff --git a/backend/src/services/imports.ts b/backend/src/services/imports.ts new file mode 100644 index 0000000..3fd0ac1 --- /dev/null +++ b/backend/src/services/imports.ts @@ -0,0 +1,44 @@ +import { pool } from '../db/pool'; +import type { Import } from '@family-budget/shared'; + +export async function getImports(): Promise { + 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 }; +} diff --git a/backend/src/services/transactions.ts b/backend/src/services/transactions.ts index b758930..a142199 100644 --- a/backend/src/services/transactions.ts +++ b/backend/src/services/transactions.ts @@ -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 }; } diff --git a/frontend/src/api/import.ts b/frontend/src/api/import.ts index af936b5..4e4c708 100644 --- a/frontend/src/api/import.ts +++ b/frontend/src/api/import.ts @@ -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 { + return api.get('/api/imports'); +} + +export async function deleteImport( + id: number, +): Promise<{ deleted: number }> { + return api.delete<{ deleted: number }>(`/api/imports/${id}`); +} diff --git a/frontend/src/components/DataSection.tsx b/frontend/src/components/DataSection.tsx index c3738cc..485999d 100644 --- a/frontend/src/components/DataSection.tsx +++ b/frontend/src/components/DataSection.tsx @@ -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([]); + const [impToDelete, setImpToDelete] = useState(null); const navigate = useNavigate(); + useEffect(() => { + getImports().then(setImports).catch(() => {}); + }, []); + + const handleImportDeleted = () => { + setImpToDelete(null); + getImports().then(setImports).catch(() => {}); + navigate('/history'); + }; + return (
+
+

История импортов

+

+ Список импортов выписок. Можно удалить операции конкретного импорта. +

+ {imports.length === 0 ? ( +

Импортов пока нет.

+ ) : ( +
+ + + + + + + + + + + + + {imports.map((imp) => ( + + + + + + + + + ))} + +
ДатаСчётБанкИмпортированоДубликаты
{formatDate(imp.importedAt)} + {imp.accountAlias || imp.accountNumberMasked || '—'} + {imp.bank}{imp.importedCount}{imp.duplicatesSkipped} + +
+
+ )} +
+

Очистка данных

@@ -32,6 +106,14 @@ export function DataSection() { }} /> )} + + {impToDelete && ( + setImpToDelete(null)} + onDone={handleImportDeleted} + /> + )}

); } diff --git a/frontend/src/components/DeleteImportModal.tsx b/frontend/src/components/DeleteImportModal.tsx new file mode 100644 index 0000000..d03943f --- /dev/null +++ b/frontend/src/components/DeleteImportModal.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+

Удалить импорт

+ +
+ +
+

+ Будут удалены все операции этого импорта ({imp.importedCount}{' '} + шт.): {imp.bank} / {accountLabel} +

+ + {error &&
{error}
} + +

Действие необратимо.

+
+ +
+ + +
+
+
+ ); +} diff --git a/package-lock.json b/package-lock.json index 466cca4..9929661 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1394,6 +1395,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -1472,6 +1474,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1611,6 +1614,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2734,6 +2738,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", @@ -2831,6 +2836,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2980,6 +2986,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2989,6 +2996,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4100,6 +4108,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/shared/src/types/import.ts b/shared/src/types/import.ts index 23e9cbf..f048213 100644 --- a/shared/src/types/import.ts +++ b/shared/src/types/import.ts @@ -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'; diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index 3794c73..9d41913 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -37,6 +37,7 @@ export type { StatementHeader, StatementTransaction, ImportStatementResponse, + Import, } from './import'; export type {