feat: adds PDF import with conversion to JSON 1.0

- Accept only PDF and JSON files in import modal and API
- Convert PDF statements to JSON 1.0 via LLM (OpenAI-compatible)
- Use multipart/form-data for file upload (multer, 15 MB limit)
- Add LLM_API_KEY and LLM_API_BASE_URL for configurable LLM endpoint
- Update ImportModal to validate type and send FormData
- Add postFormData to API client for file upload
This commit is contained in:
Anton
2026-03-13 13:38:02 +03:00
parent d1536b8872
commit 975f2c4fd2
13 changed files with 745 additions and 48 deletions

View File

@@ -53,6 +53,40 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
return res.json();
}
async function requestFormData<T>(url: string, formData: FormData): Promise<T> {
const res = await fetch(url, {
method: 'POST',
body: formData,
credentials: 'include',
// Do not set Content-Type — browser sets multipart/form-data with boundary
});
if (res.status === 401) {
let body: ApiError;
try {
body = await res.json();
} catch {
body = { error: 'UNAUTHORIZED', message: 'Сессия истекла' };
}
if (!url.includes('/api/auth/login')) {
onUnauthorized?.();
}
throw new ApiException(401, body);
}
if (!res.ok) {
let body: ApiError;
try {
body = await res.json();
} catch {
body = { error: 'UNKNOWN', message: res.statusText };
}
throw new ApiException(res.status, body);
}
return res.json();
}
export const api = {
get: <T>(url: string) => request<T>(url),
@@ -62,6 +96,9 @@ export const api = {
body: body != null ? JSON.stringify(body) : undefined,
}),
postFormData: <T>(url: string, formData: FormData) =>
requestFormData<T>(url, formData),
put: <T>(url: string, body: unknown) =>
request<T>(url, { method: 'PUT', body: JSON.stringify(body) }),

View File

@@ -2,7 +2,12 @@ import type { ImportStatementResponse } from '@family-budget/shared';
import { api } from './client';
export async function importStatement(
file: unknown,
file: File,
): Promise<ImportStatementResponse> {
return api.post<ImportStatementResponse>('/api/import/statement', file);
const formData = new FormData();
formData.append('file', file);
return api.postFormData<ImportStatementResponse>(
'/api/import/statement',
formData,
);
}

View File

@@ -22,23 +22,27 @@ export function ImportModal({ onClose, onDone }: Props) {
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 text = await file.text();
const json = JSON.parse(text);
const resp = await importStatement(json);
const resp = await importStatement(file);
setResult(resp);
} catch (err: unknown) {
if (err instanceof SyntaxError) {
setError('Некорректный JSON-файл');
} else {
const msg =
err instanceof Error ? err.message : 'Ошибка импорта';
setError(msg);
}
const msg =
err instanceof Error ? err.message : 'Ошибка импорта';
setError(msg);
} finally {
setLoading(false);
}
@@ -69,11 +73,11 @@ export function ImportModal({ onClose, onDone }: Props) {
{!result && (
<div className="import-upload">
<p>Выберите JSON-файл выписки (формат 1.0)</p>
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
<input
ref={fileRef}
type="file"
accept=".json"
accept=".pdf,.json,application/pdf,application/json"
onChange={handleFileChange}
className="file-input"
/>