Andrey Epifancev 6f8f04a707 feat: экраны по макету — панель документов [39]/[40], сводная ОСВ [30], проверка распознавания [41]/[41-02], консолидации [34], группы [03], логотип, модалка [42] (H-2, #3)
- [39] таблица документов: формат, период, проверка Период/Контрагент, статус плашкой; шапка с ЮЛ и ИНН
- [30] шапка «ГК — год Кn» со статусом, вкладки (только ОСВ), баннер «Осталось N» с фильтром
  несопоставленных, колонки и двухуровневые заголовки макета, фильтры в заголовках, «—» для нуля
- [41] карточки «Исходный документ» / «Результат распознавания», режим просмотра и «Редактировать»,
  форма как бумажный документ, суммы без копеек
- [34] Дата создания, Дата верификации (—, в API нет), ГСК (—, П-3), квартал «К3»
- [03] Основной РФ — название филиала, а не UUID
- логотип АРМ КА, заголовок вкладки, «Отчётность»; [42] члены ГСК наверху списка

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-08 03:10:34 +04:00

185 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
import { useCallback, useEffect, useRef, useState } from 'react';
// worker собирается вместе с приложением (без CDN)
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
export const MIN_ZOOM = 0.5;
export const MAX_ZOOM = 3;
export const ZOOM_STEP = 0.25;
const PAGE_MARGIN = 10; // left + right margin of each page wrapper
/** zoom: 1 = по ширине области; управляется кнопками в шапке карточки (SourcePreview). */
export default function PdfPages({ data, zoom }: { data: ArrayBuffer; zoom: number }) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
const [numPages, setNumPages] = useState(0);
const [baseScale, setBaseScale] = useState(1);
const [pageWidthPt, setPageWidthPt] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let pdf: PDFDocumentProxy | null = null;
let cancelled = false;
async function load() {
// pdf.js забирает переданный буфер себе, поэтому отдаём копию (эффект может выполниться повторно)
pdf = await pdfjsLib.getDocument({ data: data.slice(0) }).promise;
if (cancelled) {
pdf.destroy();
return;
}
const firstPage = await pdf.getPage(1);
setPageWidthPt(firstPage.getViewport({ scale: 1 }).width);
canvasRefs.current = new Array(pdf.numPages).fill(null);
setPdfDoc(pdf);
setNumPages(pdf.numPages);
}
load().catch(() => {
if (!cancelled) {
setError('Не удалось открыть PDF');
}
});
return () => {
cancelled = true;
if (pdf) pdf.destroy();
};
}, [data]);
// fit scale = wrapper inner width / page width.
// wrapper width = container client width - container padding - page margins
const updateBaseScale = useCallback(() => {
if (!containerRef.current || !pageWidthPt) return;
const CONTAINER_PADDING = 0; // keep 0 horizontally so margins control it
const available = containerRef.current.clientWidth - CONTAINER_PADDING - PAGE_MARGIN * 2;
if (available <= 0) return;
setBaseScale(available / pageWidthPt);
}, [pageWidthPt]);
useEffect(() => {
updateBaseScale();
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(updateBaseScale);
ro.observe(el);
return () => ro.disconnect();
}, [updateBaseScale]);
const scale = baseScale * zoom;
useEffect(() => {
if (!pdfDoc || !scale) return;
let cancelled = false;
const doc = pdfDoc;
const tasks: RenderTask[] = [];
async function renderAll() {
for (let i = 1; i <= doc.numPages; i++) {
const canvas = canvasRefs.current[i - 1];
if (!canvas) continue;
const page = await doc.getPage(i);
if (cancelled) return;
const outputScale = window.devicePixelRatio || 1;
const viewport = page.getViewport({ scale });
const ctx = canvas.getContext('2d');
if (!ctx) continue;
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : undefined;
const task = page.render({
canvas: null,
canvasContext: ctx,
viewport,
transform,
});
tasks.push(task);
try {
await task.promise;
} catch {
// отменённая отрисовка (смена масштаба) — не ошибка
}
}
}
renderAll();
return () => {
cancelled = true;
tasks.forEach((t) => t.cancel());
};
}, [pdfDoc, scale]);
if (error) return <div>{error}</div>;
if (!pdfDoc) return <div>Загрузка PDF...</div>;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minWidth: 0,
}}>
{/* outer vertical scroll of pages */}
<div
ref={containerRef}
style={{
flex: 1,
minHeight: 0,
minWidth: 0,
overflowY: 'auto',
overflowX: 'hidden', // layout never widens
background: '#f5f7f8',
borderRadius: 8,
paddingTop: 16,
paddingBottom: 16,
}}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
alignItems: 'stretch',
}}>
{Array.from({ length: numPages }, (_, i) => (
// page wrapper: fixed width via margins, does NOT grow.
// canvas larger than this scrolls horizontally inside here.
<div
// biome-ignore lint/suspicious/noArrayIndexKey: страницы PDF идут по порядку и не переупорядочиваются
key={i}
style={{
margin: `0 ${PAGE_MARGIN}px`,
background: '#fff',
border: '1px solid #e4e7ec',
borderRadius: 12,
overflowX: 'auto', // internal horizontal scroll when zoomed
overflowY: 'hidden',
}}>
{/* sticky so header stays visible while scrolling the page horizontally */}
<div
style={{
position: 'sticky',
left: 0,
padding: '6px 12px',
fontSize: 14,
fontWeight: 500,
background: '#f8f9fa',
color: '#344054',
}}>
Страница {i + 1}
</div>
<canvas
ref={(el) => {
canvasRefs.current[i] = el;
}}
style={{ display: 'block' }}
/>
</div>
))}
</div>
</div>
</div>
);
}