Compare commits
7 Commits
feat/add-f
...
495c1e89bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 495c1e89bb | |||
|
|
032a9f4e3b | ||
| d5f49fd86f | |||
|
|
ab88a0553d | ||
| 0589da5005 | |||
| c50e48d564 | |||
|
|
01b1f26553 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ history.xlsx
|
|||||||
match_analysis.py
|
match_analysis.py
|
||||||
match_report.txt
|
match_report.txt
|
||||||
statements/
|
statements/
|
||||||
|
.cursor/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { requireAuth } from './middleware/auth';
|
|||||||
|
|
||||||
import authRouter from './routes/auth';
|
import authRouter from './routes/auth';
|
||||||
import importRouter from './routes/import';
|
import importRouter from './routes/import';
|
||||||
|
import importsRouter from './routes/imports';
|
||||||
import transactionsRouter from './routes/transactions';
|
import transactionsRouter from './routes/transactions';
|
||||||
import accountsRouter from './routes/accounts';
|
import accountsRouter from './routes/accounts';
|
||||||
import categoriesRouter from './routes/categories';
|
import categoriesRouter from './routes/categories';
|
||||||
@@ -26,6 +27,7 @@ app.use('/api/auth', authRouter);
|
|||||||
// All remaining /api routes require authentication
|
// All remaining /api routes require authentication
|
||||||
app.use('/api', requireAuth);
|
app.use('/api', requireAuth);
|
||||||
app.use('/api/import', importRouter);
|
app.use('/api/import', importRouter);
|
||||||
|
app.use('/api/imports', importsRouter);
|
||||||
app.use('/api/transactions', transactionsRouter);
|
app.use('/api/transactions', transactionsRouter);
|
||||||
app.use('/api/accounts', accountsRouter);
|
app.use('/api/accounts', accountsRouter);
|
||||||
app.use('/api/categories', categoriesRouter);
|
app.use('/api/categories', categoriesRouter);
|
||||||
|
|||||||
@@ -133,6 +133,24 @@ const migrations: { name: string; sql: string }[] = [
|
|||||||
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
|
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',
|
name: '004_seed_category_rules_extended',
|
||||||
sql: `
|
sql: `
|
||||||
@@ -176,6 +194,24 @@ const migrations: { name: string; sql: string }[] = [
|
|||||||
AND EXISTS (SELECT 1 FROM categories LIMIT 1);
|
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> {
|
export async function runMigrations(): Promise<void> {
|
||||||
|
|||||||
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;
|
||||||
@@ -155,6 +155,16 @@ export async function importStatement(
|
|||||||
isNewAccount = true;
|
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
|
// Insert transactions
|
||||||
const insertedIds: number[] = [];
|
const insertedIds: number[] = [];
|
||||||
|
|
||||||
@@ -164,11 +174,11 @@ export async function importStatement(
|
|||||||
|
|
||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`INSERT INTO transactions
|
`INSERT INTO transactions
|
||||||
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed)
|
(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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE, $8)
|
||||||
ON CONFLICT (account_id, fingerprint) DO NOTHING
|
ON CONFLICT (account_id, fingerprint) DO NOTHING
|
||||||
RETURNING id`,
|
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) {
|
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
|
// Auto-categorize newly inserted transactions
|
||||||
if (insertedIds.length > 0) {
|
if (insertedIds.length > 0) {
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -208,7 +225,7 @@ export async function importStatement(
|
|||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
isNewAccount,
|
isNewAccount,
|
||||||
accountNumberMasked: maskAccountNumber(data.statement.accountNumber),
|
accountNumberMasked,
|
||||||
imported: insertedIds.length,
|
imported: insertedIds.length,
|
||||||
duplicatesSkipped: data.transactions.length - insertedIds.length,
|
duplicatesSkipped: data.transactions.length - insertedIds.length,
|
||||||
totalInFile: data.transactions.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 };
|
||||||
|
}
|
||||||
@@ -171,5 +171,6 @@ export async function updateTransaction(
|
|||||||
|
|
||||||
export async function clearAllTransactions(): Promise<{ deleted: number }> {
|
export async function clearAllTransactions(): Promise<{ deleted: number }> {
|
||||||
const result = await pool.query('DELETE FROM transactions RETURNING id');
|
const result = await pool.query('DELETE FROM transactions RETURNING id');
|
||||||
|
await pool.query('DELETE FROM imports');
|
||||||
return { deleted: result.rowCount ?? 0 };
|
return { deleted: result.rowCount ?? 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { ImportStatementResponse } from '@family-budget/shared';
|
import type {
|
||||||
|
ImportStatementResponse,
|
||||||
|
Import,
|
||||||
|
} from '@family-budget/shared';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
|
|
||||||
export async function importStatement(
|
export async function importStatement(
|
||||||
@@ -11,3 +14,13 @@ export async function importStatement(
|
|||||||
formData,
|
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,8 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { clearAllTransactions } from '../api/transactions';
|
import { clearAllTransactions } from '../api/transactions';
|
||||||
|
|
||||||
const CONFIRM_WORD = 'УДАЛИТЬ';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
@@ -10,15 +8,11 @@ interface Props {
|
|||||||
|
|
||||||
export function ClearHistoryModal({ onClose, onDone }: Props) {
|
export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||||
const [check1, setCheck1] = useState(false);
|
const [check1, setCheck1] = useState(false);
|
||||||
const [confirmInput, setConfirmInput] = useState('');
|
|
||||||
const [check2, setCheck2] = useState(false);
|
const [check2, setCheck2] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const canConfirm =
|
const canConfirm = check1 && check2;
|
||||||
check1 &&
|
|
||||||
confirmInput.trim().toUpperCase() === CONFIRM_WORD &&
|
|
||||||
check2;
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (!canConfirm || loading) return;
|
if (!canConfirm || loading) return;
|
||||||
@@ -65,20 +59,6 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>
|
|
||||||
Введите <strong>{CONFIRM_WORD}</strong> для подтверждения
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={confirmInput}
|
|
||||||
onChange={(e) => setConfirmInput(e.target.value)}
|
|
||||||
placeholder={CONFIRM_WORD}
|
|
||||||
className={confirmInput && confirmInput.trim().toUpperCase() !== CONFIRM_WORD ? 'input-error' : ''}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group form-group-checkbox clear-history-check">
|
<div className="form-group form-group-checkbox clear-history-check">
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,13 +1,87 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ClearHistoryModal } from './ClearHistoryModal';
|
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() {
|
export function DataSection() {
|
||||||
const [showClearModal, setShowClearModal] = useState(false);
|
const [showClearModal, setShowClearModal] = useState(false);
|
||||||
|
const [imports, setImports] = useState<Import[]>([]);
|
||||||
|
const [impToDelete, setImpToDelete] = useState<Import | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getImports().then(setImports).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImportDeleted = () => {
|
||||||
|
setImpToDelete(null);
|
||||||
|
getImports().then(setImports).catch(() => {});
|
||||||
|
navigate('/history');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="data-section">
|
<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">
|
<div className="section-block">
|
||||||
<h3>Очистка данных</h3>
|
<h3>Очистка данных</h3>
|
||||||
<p className="section-desc">
|
<p className="section-desc">
|
||||||
@@ -32,6 +106,14 @@ export function DataSection() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{impToDelete && (
|
||||||
|
<DeleteImportModal
|
||||||
|
imp={impToDelete}
|
||||||
|
onClose={() => setImpToDelete(null)}
|
||||||
|
onDone={handleImportDeleted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -249,6 +249,7 @@ body {
|
|||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
9
package-lock.json
generated
9
package-lock.json
generated
@@ -111,6 +111,7 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1394,6 +1395,7 @@
|
|||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@@ -1472,6 +1474,7 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1611,6 +1614,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2734,6 +2738,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.11.0",
|
"pg-connection-string": "^2.11.0",
|
||||||
"pg-pool": "^3.12.0",
|
"pg-pool": "^3.12.0",
|
||||||
@@ -2831,6 +2836,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -2980,6 +2986,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -2989,6 +2996,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -4100,6 +4108,7 @@
|
|||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
@@ -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 */
|
/** JSON 1.0 statement file — the shape accepted by POST /api/import/statement */
|
||||||
export interface StatementFile {
|
export interface StatementFile {
|
||||||
schemaVersion: '1.0';
|
schemaVersion: '1.0';
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type {
|
|||||||
StatementHeader,
|
StatementHeader,
|
||||||
StatementTransaction,
|
StatementTransaction,
|
||||||
ImportStatementResponse,
|
ImportStatementResponse,
|
||||||
|
Import,
|
||||||
} from './import';
|
} from './import';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
Reference in New Issue
Block a user