feat: stream PDF import progress via SSE with global progress bar

Replace the synchronous PDF import with Server-Sent Events streaming
between the backend (LLM) and the browser. The user can now close the
import modal and continue working while the conversion runs — a fixed
progress bar in the Layout shows real-time stage and percentage.
This commit is contained in:
vakabunga
2026-03-14 16:18:31 +03:00
parent 627706228b
commit 1d7fbea9ef
9 changed files with 680 additions and 70 deletions

View File

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