feat: import DOCX/PDF/scanned templates via DeepSeek recognition

Backend pipeline:
- POST /api/templates/import (multipart, max 25 MB)
- extract.ts: DOCX→mammoth, PDF→pdf-parse, fallback to OCR via tesseract+poppler-utils
  (pdftoppm renders pages to PNG, tesseract reads with rus+eng)
- deepseek.ts: chat completions client with strict JSON response_format
- recognize.ts: structured prompt that produces simplified DocBody (string text),
  postprocessor wraps text in TipTap-compatible JSON, validates with zod schema
- prompt enforces placeholder substitution: {{customer.*}}, {{executor.*}},
  {{contract.number}}, {{contract.date}}, {{today}}
- error codes: NO_OCR / NO_DEEPSEEK_KEY / UNSUPPORTED_MIME / INVALID_DOC_BODY

Dockerfile: apk add tesseract-ocr (+rus +eng data), poppler-utils, imagemagick

Frontend:
- Templates page: ⤴ Загрузить документ → file picker (.docx,.pdf,.png,.jpg)
- doc type selector (contract/invoice/act/upd)
- import-banner with spinner shows uploading→analyzing stages
- on success navigates to /templates/:id (TemplateEdit) for review

Reuses DEEPSEEK_API_KEY pattern from Hall-planer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 11:40:28 +03:00
parent 624d378bb5
commit e768d30fb6
13 changed files with 1114 additions and 7 deletions
+6
View File
@@ -35,6 +35,12 @@ DEFAULT_ORGANIZATION_ID=00000000-0000-0000-0000-000000000001
# Бесплатно до 10000 запросов/сутки.
DADATA_API_KEY=
# DeepSeek — разбор загруженных шаблонов (DOCX/PDF/скан) в DocBody-структуру.
# Получить ключ: https://platform.deepseek.com/api_keys
DEEPSEEK_API_KEY=
# DEEPSEEK_BASE_URL=https://api.deepseek.com
# DEEPSEEK_MODEL=deepseek-chat
# --- Dev-only ---
# Если 1 — пропускает проверку JWT и подсовывает фейкового admin'а.
# В production отказывается стартовать с этой переменной.
+5
View File
@@ -20,15 +20,20 @@
"@fastify/cookie": "^9.4.0",
"@fastify/cors": "^9.0.1",
"@fastify/helmet": "^11.1.1",
"@fastify/multipart": "^10.0.0",
"@prisma/client": "^5.22.0",
"fastify": "^4.28.1",
"fastify-plugin": "^4.5.1",
"jose": "^5.9.6",
"mammoth": "^1.12.0",
"node-tesseract-ocr": "^2.2.1",
"pdf-parse": "^2.4.5",
"puppeteer-core": "^24.42.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.16.0",
"@types/pdf-parse": "^1.1.5",
"embedded-postgres": "^18.3.0-beta.17",
"pino-pretty": "^11.3.0",
"prisma": "^5.22.0",
+5
View File
@@ -25,6 +25,11 @@ const EnvSchema = z.object({
// Получить: https://dadata.ru/api/find-party/ (нужен «API-ключ для приложения», не secret)
DADATA_API_KEY: z.string().optional(),
// DeepSeek (https://api.deepseek.com) — для разбора документов в DocBody.
DEEPSEEK_API_KEY: z.string().optional(),
DEEPSEEK_BASE_URL: z.string().url().default('https://api.deepseek.com'),
DEEPSEEK_MODEL: z.string().default('deepseek-chat'),
DEFAULT_ORGANIZATION_ID: z.string().uuid().default('00000000-0000-0000-0000-000000000001'),
// Только для локальной разработки. В проде — НИКОГДА. Hard-check ниже.
@@ -0,0 +1,48 @@
import { env } from '../../env.js';
type ChatMessage = { role: 'system' | 'user' | 'assistant'; content: string };
type DeepSeekResponse = {
choices: { message: { content: string } }[];
};
export async function deepseekJsonChat<T>(messages: ChatMessage[], opts: { temperature?: number; maxTokens?: number } = {}): Promise<T> {
if (!env.DEEPSEEK_API_KEY) {
throw Object.assign(new Error('DeepSeek API ключ не настроен (DEEPSEEK_API_KEY).'), {
code: 'NO_DEEPSEEK_KEY',
});
}
const res = await fetch(`${env.DEEPSEEK_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: env.DEEPSEEK_MODEL,
messages,
response_format: { type: 'json_object' },
temperature: opts.temperature ?? 0.2,
max_tokens: opts.maxTokens ?? 8000,
}),
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw Object.assign(new Error(`DeepSeek ${res.status}: ${body.slice(0, 300)}`), {
code: 'DEEPSEEK_HTTP_ERROR',
status: res.status,
});
}
const data = (await res.json()) as DeepSeekResponse;
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error('DeepSeek: пустой ответ');
try {
return JSON.parse(content) as T;
} catch {
throw Object.assign(new Error('DeepSeek вернул не валидный JSON'), {
code: 'DEEPSEEK_BAD_JSON',
raw: content,
});
}
}
+114
View File
@@ -0,0 +1,114 @@
import { execFile } from 'node:child_process';
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import mammoth from 'mammoth';
import * as pdfParseNs from 'pdf-parse';
// pdf-parse в ESM экспортирует default или сам namespace — нормализуем.
const pdfParse: (buf: Buffer) => Promise<{ text: string; numpages: number }> =
// @ts-expect-error namespace shape varies between cjs/esm
(pdfParseNs.default ?? pdfParseNs) as never;
const execFileP = promisify(execFile);
export type ExtractedSource = 'docx' | 'pdf-text' | 'pdf-ocr' | 'image-ocr';
export type Extracted = { text: string; source: ExtractedSource; pageCount?: number };
const OCR_LANGS = 'rus+eng';
/**
* Извлекает читаемый текст из произвольного загруженного документа.
* - DOCX → mammoth (без OCR);
* - PDF с текстовым слоем → pdf-parse;
* - PDF-скан → pdftoppm в PNG → tesseract;
* - PNG/JPG → tesseract напрямую.
*
* Если в системе нет tesseract/poppler — для не-DOCX вернётся ошибка с code=NO_OCR.
*/
export async function extractText(buf: Buffer, mime: string, filename: string): Promise<Extracted> {
const lower = (mime + ' ' + filename).toLowerCase();
if (lower.includes('wordprocessingml') || lower.endsWith('.docx')) {
const r = await mammoth.extractRawText({ buffer: buf });
return { text: cleanup(r.value), source: 'docx' };
}
if (mime.includes('pdf') || lower.endsWith('.pdf')) {
return extractFromPdf(buf);
}
if (mime.startsWith('image/') || /\.(png|jpe?g|tiff?|bmp)$/i.test(filename)) {
const text = await ocrImageBuffer(buf, '.png');
return { text: cleanup(text), source: 'image-ocr' };
}
throw Object.assign(new Error(`Неподдерживаемый формат: ${mime || filename}`), {
code: 'UNSUPPORTED_MIME',
});
}
async function extractFromPdf(buf: Buffer): Promise<Extracted> {
const parsed = await pdfParse(buf);
const text = cleanup(parsed.text);
// Эвристика: если текст-слой даёт меньше 60 символов на страницу — это сканированный PDF.
const charsPerPage = parsed.numpages > 0 ? text.length / parsed.numpages : text.length;
if (text.length > 200 && charsPerPage > 60) {
return { text, source: 'pdf-text', pageCount: parsed.numpages };
}
// OCR pipeline
const ocr = await ocrPdfBuffer(buf);
return { text: cleanup(ocr), source: 'pdf-ocr', pageCount: parsed.numpages };
}
async function ocrImageBuffer(buf: Buffer, ext: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'docmgr-ocr-'));
try {
const inFile = join(dir, `in${ext}`);
await writeFile(inFile, buf);
const { stdout } = await execFileP('tesseract', [inFile, 'stdout', '-l', OCR_LANGS], {
maxBuffer: 32 * 1024 * 1024,
});
return stdout;
} catch (e) {
if ((e as { code?: string }).code === 'ENOENT') {
throw Object.assign(new Error('Tesseract не установлен в окружении. PDF-сканы обработать не получится.'), {
code: 'NO_OCR',
});
}
throw e;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async function ocrPdfBuffer(buf: Buffer): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'docmgr-pdf-'));
try {
const inFile = join(dir, 'in.pdf');
await writeFile(inFile, buf);
// pdftoppm input.pdf out -png -r 200 → out-1.png, out-2.png ...
try {
await execFileP('pdftoppm', [inFile, join(dir, 'out'), '-png', '-r', '200']);
} catch (e) {
if ((e as { code?: string }).code === 'ENOENT') {
throw Object.assign(new Error('pdftoppm (poppler-utils) не установлен.'), { code: 'NO_OCR' });
}
throw e;
}
const files = (await readdir(dir)).filter((f) => f.startsWith('out-') && f.endsWith('.png')).sort();
const parts: string[] = [];
for (const f of files) {
const pageBuf = await readFile(join(dir, f));
parts.push(await ocrImageBuffer(pageBuf, '.png'));
}
return parts.join('\n\n');
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function cleanup(s: string): string {
return s
.replace(/­/g, '') // soft hyphens
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
@@ -0,0 +1,95 @@
import type { FastifyInstance } from 'fastify';
import { Prisma } from '@prisma/client';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { extractText } from './extract.js';
import { recognizeTemplate } from './recognize.js';
const MAX_BYTES = 20 * 1024 * 1024; // 20 MB
export async function templatesImportRoutes(app: FastifyInstance) {
app.post(
'/api/templates/import',
{ preHandler: app.requireDocPermission('user') },
async (req, reply) => {
const orgId = getOrganizationId(req);
const file = await req.file({ limits: { fileSize: MAX_BYTES } });
if (!file) {
reply.code(400).send({ error: 'no_file' });
return;
}
const buf = await file.toBuffer();
const filename = file.filename || 'template';
const mime = file.mimetype || '';
// Опц. имя/тип передаются полями формы; если нет — определит LLM
const fields = (file.fields ?? {}) as Record<string, { value?: string }>;
const userName = typeof fields.name?.value === 'string' ? fields.name.value : null;
const userDocType = typeof fields.docType?.value === 'string' ? fields.docType.value : null;
try {
// 1) Извлекаем текст
const extracted = await extractText(buf, mime, filename);
if (extracted.text.trim().length < 30) {
reply.code(422).send({
error: 'empty_extracted_text',
message: 'Не удалось извлечь читаемый текст. Возможно, скан плохого качества.',
source: extracted.source,
});
return;
}
// 2) Распознаём через DeepSeek
const recognized = await recognizeTemplate(extracted.text);
const docType = (userDocType as 'contract' | 'invoice' | 'act' | 'upd') ?? recognized.docType;
const name = userName?.trim() || recognized.title || `Импорт ${new Date().toLocaleDateString('ru-RU')}`;
const created = await prisma.documentTemplate.create({
data: {
organizationId: orgId,
docType,
name,
body: recognized.docBody as Prisma.InputJsonValue,
},
});
reply.code(201).send({
template: created,
extractedFrom: extracted.source,
textLength: extracted.text.length,
});
} catch (e) {
const err = e as { code?: string; message?: string; status?: number };
if (err.code === 'NO_DEEPSEEK_KEY') {
reply.code(503).send({ error: 'no_deepseek_key', message: err.message });
return;
}
if (err.code === 'NO_OCR') {
reply.code(503).send({ error: 'no_ocr', message: err.message });
return;
}
if (err.code === 'UNSUPPORTED_MIME') {
reply.code(415).send({ error: 'unsupported_mime', message: err.message });
return;
}
if (err.code === 'INVALID_DOC_BODY') {
reply.code(502).send({
error: 'invalid_doc_body',
message: 'LLM вернула структуру, не прошедшую валидацию. Попробуйте загрузить ещё раз или отредактировать вручную.',
});
return;
}
if (err.code === 'DEEPSEEK_HTTP_ERROR') {
reply.code(502).send({
error: 'deepseek_error',
message: err.message,
status: err.status ?? 502,
});
return;
}
app.log.error({ err: e }, 'template import failed');
reply.code(500).send({ error: 'import_failed', message: err.message ?? 'unknown' });
}
},
);
}
+156
View File
@@ -0,0 +1,156 @@
import type { DocType } from '@prisma/client';
import { DocBody as DocBodySchema } from '@doc-manager/shared';
import { deepseekJsonChat } from './deepseek.js';
// LLM возвращает упрощённую структуру (string text вместо TipTap-JSON).
// Конвертим её в полный DocBody с RichText на нашей стороне.
type LlmBlock =
| { type: 'heading'; level: 1 | 2 | 3; text: string }
| { type: 'paragraph'; text: string }
| { type: 'party'; role: 'executor' | 'customer'; bind: { kind: 'self' | 'client' } }
| { type: 'services_table'; columns?: string[]; lines?: [] }
| { type: 'totals'; showVat?: boolean; showInWords?: boolean }
| { type: 'terms'; text: string }
| { type: 'signatures'; sides: ('executor' | 'customer')[] }
| { type: 'custom_text'; text: string }
| { type: 'page_break' };
type LlmResult = {
docType?: 'contract' | 'invoice' | 'act' | 'upd';
title?: string;
blocks: LlmBlock[];
};
const SYSTEM_PROMPT = `Ты — парсер юридических документов на русском языке. Получаешь plain-text договора, счёта, акта или УПД и возвращаешь его структуру строго в JSON формате DocBody.
Цель: создать ШАБЛОН, в котором конкретные данные сторон, номера, даты — заменены на плейсхолдеры. Шаблон потом будет инстансироваться для конкретного клиента и документа.
Правила замены на плейсхолдеры (заменяй ТОЛЬКО эти сущности, остальной текст оставь как есть):
- Конкретный номер договора/счёта → {{contract.number}}
- Конкретная дата документа → {{contract.date}}
- Текущая дата (на момент рендера) → {{today}}
- Реквизиты исполнителя (наша компания):
{{executor.name}}, {{executor.inn}}, {{executor.kpp}}, {{executor.ogrn}}, {{executor.legalAddress}},
{{executor.signatoryName}}, {{executor.signatoryPosition}},
{{executor.bankName}}, {{executor.bankBik}}, {{executor.bankAccount}}
- Реквизиты заказчика (клиента):
{{customer.name}}, {{customer.inn}}, {{customer.kpp}}, {{customer.address}},
{{customer.email}}, {{customer.phone}}, {{customer.contactPerson}}
Структура ответа (JSON-объект):
{
"docType": "contract" | "invoice" | "act" | "upd",
"title": "Договор оказания услуг",
"blocks": [
{ "type": "heading", "level": 1, "text": "Договор оказания услуг № {{contract.number}} от {{contract.date}}" },
{ "type": "paragraph", "text": "..." },
{ "type": "party", "role": "executor", "bind": { "kind": "self" } },
{ "type": "party", "role": "customer", "bind": { "kind": "client" } },
{ "type": "services_table", "columns": ["name","qty","unit","price","vat","sum"], "lines": [] },
{ "type": "totals", "showVat": true, "showInWords": true },
{ "type": "terms", "text": "..." },
{ "type": "signatures", "sides": ["executor","customer"] },
{ "type": "custom_text", "text": "..." },
{ "type": "page_break" }
]
}
Важные правила:
1. Если в документе встречается реквизитный блок одной из сторон (ИНН/КПП/адрес/банк) — выдай ОДИН блок "party" с правильным role, не добавляй параграф с текстовым перечислением реквизитов.
2. Услуги/работы в табличной форме — НЕ переписывай построчно, ставь блок "services_table" (lines пустой массив, строки пользователь добавит вручную).
3. Если есть итоговые суммы (Итого, в т.ч. НДС, сумма прописью) — ставь блок "totals" со включенными нужными опциями.
4. Подписи в конце — блок "signatures" с указанием каких сторон видно.
5. Нумерованные пункты «1. Предмет договора», «2. Цена и порядок расчётов» и т.п. — каждый раздел как блок "terms" (заголовок раздела можно положить отдельным "heading" level 2).
6. Не выдумывай блоки, которых нет в документе.
7. text внутри блоков — обычная строка (не TipTap JSON), может содержать \\n для новых параграфов внутри блока.
8. Заполняй "title" коротким названием документа.
9. Если не уверен в типе документа — ставь "contract".`;
function uid(): string {
return Math.random().toString(36).slice(2, 11);
}
function plainToRich(text: string): unknown {
const lines = text.split(/\r?\n/);
return {
type: 'doc',
content: lines.map((line) => ({
type: 'paragraph',
content: line ? [{ type: 'text', text: line }] : [],
})),
};
}
function llmToDocBody(result: LlmResult): { docBody: unknown; docType: DocType; title: string } {
const docType: DocType = (result.docType as DocType) ?? 'contract';
const title = result.title ?? 'Документ';
const blocks = (result.blocks ?? []).map((b) => {
const id = uid();
switch (b.type) {
case 'heading':
return { id, type: 'heading', level: clampLevel(b.level), text: plainToRich(b.text) };
case 'paragraph':
return { id, type: 'paragraph', text: plainToRich(b.text) };
case 'party':
return {
id,
type: 'party',
role: b.role,
bind: b.bind?.kind === 'self' ? { kind: 'self' as const } : { kind: 'client' as const },
};
case 'services_table':
return {
id,
type: 'services_table',
columns: ['name', 'qty', 'unit', 'price', 'vat', 'sum'] as const,
lines: [],
};
case 'totals':
return { id, type: 'totals', showVat: b.showVat ?? true, showInWords: b.showInWords ?? true };
case 'terms':
return { id, type: 'terms', text: plainToRich(b.text) };
case 'signatures':
return { id, type: 'signatures', sides: b.sides?.length ? b.sides : (['executor', 'customer'] as const) };
case 'custom_text':
return { id, type: 'custom_text', text: plainToRich(b.text) };
case 'page_break':
return { id, type: 'page_break' };
default:
return { id, type: 'custom_text', text: plainToRich('') };
}
});
const docBody = { version: 1, blocks, vars: {} };
return { docBody, docType, title };
}
function clampLevel(l: number): 1 | 2 | 3 {
if (l === 1 || l === 2 || l === 3) return l;
return 1;
}
export async function recognizeTemplate(text: string): Promise<{ docBody: unknown; docType: DocType; title: string; raw?: unknown }> {
// Документ может быть длинным — DeepSeek-chat умеет до 64k context, сожмём только если совсем огромный
const trimmed = text.length > 60_000 ? text.slice(0, 60_000) + '\n\n…[документ обрезан]' : text;
const llm = await deepseekJsonChat<LlmResult>(
[
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: trimmed },
],
{ temperature: 0.1, maxTokens: 8000 },
);
const built = llmToDocBody(llm);
// Финальная валидация на нашей стороне
const parsed = DocBodySchema.safeParse(built.docBody);
if (!parsed.success) {
throw Object.assign(new Error('DocBody после LLM не прошёл валидацию'), {
code: 'INVALID_DOC_BODY',
issues: parsed.error.flatten(),
raw: built.docBody,
});
}
return { ...built, docBody: parsed.data, raw: llm };
}
+4
View File
@@ -3,6 +3,7 @@ import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import multipart from '@fastify/multipart';
import { env } from './env.js';
import authPlugin from './plugins/auth.js';
import { healthRoutes } from './routes/health.js';
@@ -14,6 +15,7 @@ import { servicesRoutes } from './modules/services/routes.js';
import { documentsRoutes } from './modules/documents/routes.js';
import { documentsPdfRoutes } from './modules/documents/pdf.routes.js';
import { templatesRoutes } from './modules/templates/routes.js';
import { templatesImportRoutes } from './modules/templates/import.routes.js';
import { dadataRoutes } from './modules/dadata/routes.js';
import { shutdownBrowser } from './modules/documents/pdf.js';
import activeOrgPlugin from './plugins/activeOrg.js';
@@ -38,6 +40,7 @@ async function main() {
credentials: true,
});
await app.register(cookie);
await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } });
await app.register(authPlugin);
await app.register(activeOrgPlugin);
@@ -50,6 +53,7 @@ async function main() {
await app.register(documentsRoutes);
await app.register(documentsPdfRoutes);
await app.register(templatesRoutes);
await app.register(templatesImportRoutes);
await app.register(dadataRoutes);
app.addHook('onClose', async () => {
+98 -5
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api, type DocBody, type DocType, type DocumentTemplate } from '../api.js';
import { api, ApiError, type DocBody, type DocType, type DocumentTemplate } from '../api.js';
import { Button, EmptyState, Field, Modal, Select } from '../components/ui.js';
import { emptyRich } from '../lib/richtext.js';
import { redirectToLogin } from '../auth.js';
const DOC_TYPE_LABEL: Record<DocType, string> = {
contract: 'Договор', invoice: 'Счёт', act: 'Акт', upd: 'УПД',
@@ -22,10 +23,18 @@ function emptyBody(): DocBody {
};
}
type ImportState =
| { stage: 'idle' }
| { stage: 'uploading' | 'analyzing'; filename: string }
| { stage: 'error'; message: string };
export function TemplatesPage() {
const [items, setItems] = useState<DocumentTemplate[] | null>(null);
const [creating, setCreating] = useState<{ name: string; docType: DocType } | null>(null);
const [importState, setImportState] = useState<ImportState>({ stage: 'idle' });
const [importDocType, setImportDocType] = useState<DocType>('contract');
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
async function load() {
@@ -76,15 +85,99 @@ export function TemplatesPage() {
}
}
async function importFile(file: File) {
setImportState({ stage: 'uploading', filename: file.name });
const fd = new FormData();
fd.append('docType', importDocType);
fd.append('file', file);
try {
const res = await fetch('/api/templates/import', {
method: 'POST',
credentials: 'include',
body: fd,
});
if (res.status === 401) redirectToLogin();
setImportState({ stage: 'analyzing', filename: file.name });
const text = await res.text();
const data = text ? JSON.parse(text) : {};
if (!res.ok) {
const msg =
data.error === 'no_deepseek_key'
? 'DeepSeek API ключ не настроен на сервере.'
: data.error === 'no_ocr'
? 'OCR (tesseract) недоступен на сервере. Загрузка сканов невозможна.'
: data.error === 'unsupported_mime'
? 'Неподдерживаемый формат файла. DOCX, PDF, PNG, JPG.'
: data.error === 'empty_extracted_text'
? 'Не удалось извлечь текст (плохое качество скана).'
: data.error === 'invalid_doc_body'
? 'LLM вернула невалидную структуру. Попробуйте ещё раз.'
: data.message || `${data.error} (HTTP ${res.status})`;
setImportState({ stage: 'error', message: msg });
return;
}
const tpl: DocumentTemplate = data.template;
setImportState({ stage: 'idle' });
navigate(`/templates/${tpl.id}`);
} catch (e) {
setImportState({ stage: 'error', message: String(e) });
}
}
function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
if (f) void importFile(f);
e.target.value = '';
}
return (
<main className="content">
<header className="page-head">
<h2>Шаблоны</h2>
<Button variant="primary" onClick={() => setCreating({ name: '', docType: 'contract' })}>
+ Новый шаблон
</Button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Select
label=""
value={importDocType}
onChange={(v) => setImportDocType(v as DocType)}
options={[
{ value: 'contract', label: 'Договор' },
{ value: 'invoice', label: 'Счёт' },
{ value: 'act', label: 'Акт' },
{ value: 'upd', label: 'УПД' },
]}
/>
<Button onClick={() => fileInputRef.current?.click()} disabled={importState.stage !== 'idle' && importState.stage !== 'error'}>
Загрузить документ
</Button>
<input
ref={fileInputRef}
type="file"
accept=".docx,.pdf,.png,.jpg,.jpeg,.tif,.tiff,application/pdf,image/*"
style={{ display: 'none' }}
onChange={onPickFile}
/>
<Button variant="primary" onClick={() => setCreating({ name: '', docType: 'contract' })}>
+ Новый шаблон
</Button>
</div>
</header>
{importState.stage === 'uploading' || importState.stage === 'analyzing' ? (
<div className="import-banner">
<div className="spinner" />
<span>
{importState.stage === 'uploading' ? 'Загружаю' : 'Распознаю'} «{importState.filename}»
{importState.stage === 'analyzing' ? ' DeepSeek анализирует структуру (10–60 сек).' : ''}
</span>
</div>
) : null}
{importState.stage === 'error' ? (
<div className="error-text">
Импорт не удался: {importState.message}
<Button variant="ghost" onClick={() => setImportState({ stage: 'idle' })}>×</Button>
</div>
) : null}
{error ? <div className="error-text">{error}</div> : null}
{items === null ? (
+17
View File
@@ -275,6 +275,23 @@ body {
.tabs { border-bottom-color: #2a2e35; }
}
/* === import banner === */
.import-banner {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; margin: 12px 0;
background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af;
border-radius: 8px;
}
.spinner {
width: 16px; height: 16px; border-radius: 50%;
border: 2px solid #bfdbfe; border-top-color: #2563eb;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-color-scheme: dark) {
.import-banner { background: #14213d; border-color: #1e3a8a; color: #93c5fd; }
}
/* === inn lookup === */
.inn-lookup { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
.inn-lookup__error { font-size: 12px; color: #c0392b; }