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.
169 lines
5.2 KiB
TypeScript
169 lines
5.2 KiB
TypeScript
import { useState, useRef } from 'react';
|
||
import type { ImportStatementResponse } from '@family-budget/shared';
|
||
import { importStatement } from '../api/import';
|
||
import { updateAccount } from '../api/accounts';
|
||
|
||
interface Props {
|
||
onClose: () => void;
|
||
onDone: () => void;
|
||
}
|
||
|
||
export function ImportModal({ onClose, onDone }: Props) {
|
||
const [result, setResult] = useState<ImportStatementResponse | null>(null);
|
||
const [error, setError] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [alias, setAlias] = useState('');
|
||
const [aliasSaved, setAliasSaved] = useState(false);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const handleFileChange = async (
|
||
e: React.ChangeEvent<HTMLInputElement>,
|
||
) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
const name = file.name.toLowerCase();
|
||
const type = file.type;
|
||
const isPdf = type === 'application/pdf' || name.endsWith('.pdf');
|
||
const isJson = type === 'application/json' || name.endsWith('.json');
|
||
|
||
if (!isPdf && !isJson) {
|
||
setError('Допустимы только файлы PDF или JSON');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setError('');
|
||
setResult(null);
|
||
|
||
try {
|
||
const resp = await importStatement(file);
|
||
setResult(resp);
|
||
} catch (err: unknown) {
|
||
const msg =
|
||
err instanceof Error ? err.message : 'Ошибка импорта';
|
||
setError(msg);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveAlias = async () => {
|
||
if (!result || !alias.trim()) return;
|
||
try {
|
||
await updateAccount(result.accountId, { alias: alias.trim() });
|
||
setAliasSaved(true);
|
||
} catch {
|
||
// handled globally
|
||
}
|
||
};
|
||
|
||
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">
|
||
{error && <div className="alert alert-error">{error}</div>}
|
||
|
||
{!result && (
|
||
<div className="import-upload">
|
||
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept=".pdf,.json,application/pdf,application/json"
|
||
onChange={handleFileChange}
|
||
className="file-input"
|
||
/>
|
||
{loading && (
|
||
<div className="import-loading">Импорт...</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{result && (
|
||
<div className="import-result">
|
||
<div className="import-result-icon" aria-hidden="true">✓</div>
|
||
<h3>Импорт завершён</h3>
|
||
<table className="import-stats">
|
||
<tbody>
|
||
<tr>
|
||
<td>Счёт</td>
|
||
<td>{result.accountNumberMasked}</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Новый счёт</td>
|
||
<td>{result.isNewAccount ? 'Да' : 'Нет'}</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Импортировано</td>
|
||
<td>{result.imported}</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Дубликатов пропущено</td>
|
||
<td>{result.duplicatesSkipped}</td>
|
||
</tr>
|
||
<tr>
|
||
<td>Всего в файле</td>
|
||
<td>{result.totalInFile}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
{result.isNewAccount && !aliasSaved && (
|
||
<div className="import-alias">
|
||
<label>Алиас для нового счёта</label>
|
||
<div className="import-alias-row">
|
||
<input
|
||
type="text"
|
||
placeholder="Напр.: Текущий, Накопительный"
|
||
value={alias}
|
||
onChange={(e) => setAlias(e.target.value)}
|
||
maxLength={50}
|
||
/>
|
||
<button
|
||
className="btn btn-sm btn-primary"
|
||
onClick={handleSaveAlias}
|
||
disabled={!alias.trim()}
|
||
>
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{aliasSaved && (
|
||
<div className="import-alias-saved">
|
||
Алиас сохранён
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="modal-footer">
|
||
{result ? (
|
||
<button className="btn btn-primary" onClick={onDone}>
|
||
Готово
|
||
</button>
|
||
) : (
|
||
<button
|
||
className="btn btn-secondary"
|
||
onClick={onClose}
|
||
disabled={loading}
|
||
>
|
||
Отмена
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|