7 Commits

Author SHA1 Message Date
0589da5005 Merge pull request 'feat(imports): import history and delete by import' (#13) from feat/import-history into main
Reviewed-on: #13
2026-03-16 14:48:47 +00:00
c50e48d564 Merge pull request 'chore(frontend): adds favicon' (#12) from feat/add-favicon into main
Reviewed-on: #12
2026-03-16 14:48:37 +00:00
Anton
01b1f26553 feat(imports): import history and delete by import
Track imports in DB, show history in Data section, allow deleting
transactions of a specific import instead of clearing all.
2026-03-16 17:46:15 +03:00
Anton
ba3105bbe5 chore(frontend): adds favicon 2026-03-16 17:44:04 +03:00
f32a21f87a Merge pull request 'Revert SSE streaming for PDF import, use synchronous flow' (#11) from revert/remove-sse-streaming into main
Reviewed-on: #11
2026-03-14 17:13:00 +00:00
vakabunga
8b57dd987e Revert SSE streaming for PDF import, use synchronous flow
SSE streaming added unnecessary complexity and latency due to
buffering issues across Node.js event loop, Nginx proxy, and
Docker layers. Reverted to a simple synchronous request/response
for PDF conversion. Kept extractLlmErrorMessage for user-friendly
LLM errors, lazy-loaded pdf-parse, and extended Nginx timeout.
2026-03-14 20:12:27 +03:00
ea234ea007 Merge pull request 'fix: yield to event loop after each SSE write to flush socket' (#10) from fix/sse-event-loop-flush into main
Reviewed-on: #10
2026-03-14 17:00:51 +00:00
24 changed files with 405 additions and 697 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ history.xlsx
match_analysis.py match_analysis.py
match_report.txt match_report.txt
statements/ statements/
.cursor/

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import multer from 'multer';
import { asyncHandler } from '../utils'; import { asyncHandler } from '../utils';
import { importStatement, isValidationError } from '../services/import'; import { importStatement, isValidationError } from '../services/import';
import { import {
convertPdfToStatementStreaming, convertPdfToStatement,
isPdfConversionError, isPdfConversionError,
} from '../services/pdfToStatement'; } from '../services/pdfToStatement';
@@ -28,10 +28,6 @@ function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
); );
} }
function sseWrite(res: import('express').Response, data: Record<string, unknown>) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
const router = Router(); const router = Router();
router.post( router.post(
@@ -55,68 +51,28 @@ router.post(
return; 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; let body: unknown;
try {
body = JSON.parse(file.buffer.toString('utf-8')); if (isPdfFile(file)) {
} catch { const converted = await convertPdfToStatement(file.buffer);
res.status(400).json({ if (isPdfConversionError(converted)) {
error: 'BAD_REQUEST', res.status(converted.status).json({
message: 'Некорректный JSON-файл', error: converted.error,
}); message: converted.message,
return; });
return;
}
body = converted;
} else {
try {
body = JSON.parse(file.buffer.toString('utf-8'));
} catch {
res.status(400).json({
error: 'BAD_REQUEST',
message: 'Некорректный JSON-файл',
});
return;
}
} }
const result = await importStatement(body); const result = await importStatement(body);

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

View File

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

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

View File

@@ -131,128 +131,31 @@ export async function convertPdfToStatement(
}; };
} }
return parseConversionResult(content); const jsonMatch = content.match(/\{[\s\S]*\}/);
} catch (err) { const jsonStr = jsonMatch ? jsonMatch[0] : content;
console.error('LLM conversion error:', err); let parsed: unknown;
return { try {
status: 502, parsed = JSON.parse(jsonStr);
error: 'BAD_GATEWAY', } catch {
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;
function yieldToEventLoop(): Promise<void> {
return new Promise(resolve => setImmediate(resolve));
}
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...');
await yieldToEventLoop();
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...');
await yieldToEventLoop();
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,
});
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;
// Let the event loop flush socket writes to the network
await yieldToEventLoop();
}
}
}
onProgress('llm', LLM_PROGRESS_MAX, 'LLM завершил, обработка результата...');
await yieldToEventLoop();
const content = accumulated.trim();
if (!content) {
return { return {
status: 422, status: 422,
error: 'VALIDATION_ERROR', error: 'VALIDATION_ERROR',
message: 'Результат конвертации пуст', message: 'Результат конвертации не является валидным JSON',
}; };
} }
return parseConversionResult(content); const data = parsed as Record<string, unknown>;
if (data.schemaVersion !== '1.0') {
return {
status: 422,
error: 'VALIDATION_ERROR',
message: 'Результат конвертации не соответствует схеме 1.0',
};
}
return parsed as StatementFile;
} catch (err) { } catch (err) {
console.error('LLM streaming error:', err); console.error('LLM conversion error:', err);
return { return {
status: 502, status: 502,
error: 'BAD_GATEWAY', error: 'BAD_GATEWAY',
@@ -274,29 +177,3 @@ function extractLlmErrorMessage(err: unknown): string {
} }
return 'Временная ошибка конвертации'; 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

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

View File

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

View File

@@ -3,7 +3,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Import endpoint — SSE streaming, long timeout, no buffering # Import endpoint — long timeout for LLM processing
location /api/import { location /api/import {
proxy_pass http://family-budget-backend:3000; proxy_pass http://family-budget-backend:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -14,8 +14,6 @@ server {
proxy_cookie_path / /; proxy_cookie_path / /;
proxy_connect_timeout 5s; proxy_connect_timeout 5s;
proxy_read_timeout 600s; proxy_read_timeout 600s;
proxy_buffering off;
gzip off;
client_max_body_size 15m; client_max_body_size 15m;
} }

View 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">&#8381;</text>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

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

View File

@@ -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(
@@ -12,73 +15,12 @@ export async function importStatement(
); );
} }
export interface SseProgressEvent { export async function getImports(): Promise<Import[]> {
stage: 'pdf' | 'llm' | 'import'; return api.get<Import[]>('/api/imports');
progress: number;
message: string;
} }
export interface SseDoneEvent { export async function deleteImport(
stage: 'done'; id: number,
progress: 100; ): Promise<{ deleted: number }> {
result: ImportStatementResponse; return api.delete<{ deleted: number }>(`/api/imports/${id}`);
}
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

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

View 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}>
&times;
</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>
);
}

View File

@@ -2,7 +2,6 @@ import { useState, useRef } from 'react';
import type { ImportStatementResponse } from '@family-budget/shared'; import type { ImportStatementResponse } from '@family-budget/shared';
import { importStatement } from '../api/import'; import { importStatement } from '../api/import';
import { updateAccount } from '../api/accounts'; import { updateAccount } from '../api/accounts';
import { useImport } from '../context/ImportContext';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
@@ -10,19 +9,13 @@ interface Props {
} }
export function ImportModal({ onClose, onDone }: Props) { export function ImportModal({ onClose, onDone }: Props) {
const { importState, startImport, clearImport } = useImport(); const [result, setResult] = useState<ImportStatementResponse | null>(null);
const [error, setError] = useState('');
const [jsonResult, setJsonResult] = useState<ImportStatementResponse | null>(null); const [loading, setLoading] = useState(false);
const [jsonError, setJsonError] = useState('');
const [jsonLoading, setJsonLoading] = useState(false);
const [alias, setAlias] = useState(''); const [alias, setAlias] = useState('');
const [aliasSaved, setAliasSaved] = useState(false); const [aliasSaved, setAliasSaved] = useState(false);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const result = importState.result ?? jsonResult;
const error = importState.error || jsonError;
const loading = importState.active || jsonLoading;
const handleFileChange = async ( const handleFileChange = async (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
) => { ) => {
@@ -35,45 +28,26 @@ export function ImportModal({ onClose, onDone }: Props) {
const isJson = type === 'application/json' || name.endsWith('.json'); const isJson = type === 'application/json' || name.endsWith('.json');
if (!isPdf && !isJson) { if (!isPdf && !isJson) {
setJsonError('Допустимы только файлы PDF или JSON'); setError('Допустимы только файлы PDF или JSON');
return; return;
} }
setJsonError(''); setLoading(true);
setJsonResult(null); setError('');
setResult(null);
if (isPdf) {
startImport(file);
return;
}
setJsonLoading(true);
try { try {
const resp = await importStatement(file); const resp = await importStatement(file);
setJsonResult(resp); setResult(resp);
} catch (err: unknown) { } catch (err: unknown) {
const msg = const msg =
err instanceof Error ? err.message : 'Ошибка импорта'; err instanceof Error ? err.message : 'Ошибка импорта';
setJsonError(msg); setError(msg);
} finally { } finally {
setJsonLoading(false); setLoading(false);
} }
}; };
const handleClose = () => {
if (importState.active) {
if (window.confirm('Импорт продолжится в фоне. Закрыть окно?')) {
onClose();
}
} else {
onClose();
}
};
const handleDone = () => {
onDone();
};
const handleSaveAlias = async () => { const handleSaveAlias = async () => {
if (!result || !alias.trim()) return; if (!result || !alias.trim()) return;
try { try {
@@ -84,21 +58,12 @@ 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 ( return (
<div className="modal-overlay" onClick={handleClose}> <div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}> <div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2>Импорт выписки</h2> <h2>Импорт выписки</h2>
<button className="btn-close" onClick={handleClose}> <button className="btn-close" onClick={onClose}>
&times; &times;
</button> </button>
</div> </div>
@@ -106,7 +71,7 @@ export function ImportModal({ onClose, onDone }: Props) {
<div className="modal-body"> <div className="modal-body">
{error && <div className="alert alert-error">{error}</div>} {error && <div className="alert alert-error">{error}</div>}
{!result && !importState.active && ( {!result && (
<div className="import-upload"> <div className="import-upload">
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p> <p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
<input <input
@@ -115,30 +80,13 @@ export function ImportModal({ onClose, onDone }: Props) {
accept=".pdf,.json,application/pdf,application/json" accept=".pdf,.json,application/pdf,application/json"
onChange={handleFileChange} onChange={handleFileChange}
className="file-input" className="file-input"
disabled={loading}
/> />
{jsonLoading && ( {loading && (
<div className="import-loading">Импорт...</div> <div className="import-loading">Импорт...</div>
)} )}
</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 && ( {result && (
<div className="import-result"> <div className="import-result">
<div className="import-result-icon" aria-hidden="true"></div> <div className="import-result-icon" aria-hidden="true"></div>
@@ -201,15 +149,16 @@ export function ImportModal({ onClose, onDone }: Props) {
<div className="modal-footer"> <div className="modal-footer">
{result ? ( {result ? (
<button className="btn btn-primary" onClick={handleDone}> <button className="btn btn-primary" onClick={onDone}>
Готово Готово
</button> </button>
) : ( ) : (
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={handleClose} onClick={onClose}
disabled={loading}
> >
{importState.active ? 'Свернуть' : 'Отмена'} Отмена
</button> </button>
)} )}
</div> </div>

View File

@@ -1,70 +1,6 @@
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; 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 }) { export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@@ -74,8 +10,6 @@ export function Layout({ children }: { children: ReactNode }) {
return ( return (
<div className="layout"> <div className="layout">
<ImportProgressBar />
<button <button
type="button" type="button"
className="burger-btn" className="burger-btn"

View File

@@ -1,114 +0,0 @@
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

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

View File

@@ -1058,152 +1058,6 @@ textarea {
font-weight: 500; 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 Tabs
================================================================ */ ================================================================ */

9
package-lock.json generated
View File

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

View File

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

View File

@@ -37,6 +37,7 @@ export type {
StatementHeader, StatementHeader,
StatementTransaction, StatementTransaction,
ImportStatementResponse, ImportStatementResponse,
Import,
} from './import'; } from './import';
export type { export type {