arm-core/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx
Andrey Epifancev 717dec69cf feat(#190): экран «Качество первички» (web)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-07 02:49:24 +04:00

147 lines
5.9 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 { Alert, Button, DatePicker, Descriptions, Drawer, Result, Select, Space, Spin, Tabs, message } from 'antd';
import type { Dayjs } from 'dayjs';
import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client';
import { exportFacts, getMatrix } from '../../services/api/qualityMetrics';
import type { DocumentType } from '../../types/api';
import type { ExportFormat, QualityCell, QualityFilters, QualityMatrixOut, RecognitionPipeline } from '../../types/qualityMetrics';
import { saveBlob } from '../../utils/download';
import { DocumentDrillList } from './DocumentDrillList';
import { MatrixTable } from './MatrixTable';
import { MonthlyTrend } from './MonthlyTrend';
import { formatShare } from './qualityView';
const DOC_TYPES: { value: DocumentType; label: string }[] = [
{ value: 'BALANCE', label: 'Бухгалтерский баланс' },
{ value: 'INCOME', label: 'ОФР' },
{ value: 'OSV', label: 'ОСВ' },
];
const PIPELINES: { value: RecognitionPipeline; label: string }[] = [
{ value: 'image', label: 'PDF (распознавание)' },
{ value: 'tabular', label: 'Excel' },
];
const SCAN_OPTIONS = [
{ value: 'true', label: 'Скан' },
{ value: 'false', label: 'Цифровой PDF' },
];
const iso = (d: Dayjs): string => d.format('YYYY-MM-DD');
/** Экран «Качество первички»: фильтры, матрица причин, технические отказы, динамика по месяцам, выгрузка. */
export function QualityMetricsPage() {
const [filters, setFilters] = useState<QualityFilters>({});
const [matrix, setMatrix] = useState<QualityMatrixOut | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ApiError | Error | null>(null);
const [drill, setDrill] = useState<{ cell: QualityCell; label: string } | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getMatrix(filters)
.then((r) => {
if (!cancelled) setMatrix(r);
})
.catch((e: unknown) => {
if (cancelled) return;
setMatrix(null);
setError(e instanceof Error ? e : new Error('Не удалось загрузить метрики'));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [filters]);
const patch = (changes: Partial<QualityFilters>) => setFilters((f) => ({ ...f, ...changes }));
const download = async (format: ExportFormat) => {
try {
const { blob, filename } = await exportFacts(filters, format);
saveBlob(blob, filename ?? `quality-facts.${format}`);
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : 'Не удалось выгрузить данные');
}
};
if (error instanceof ApiError && error.status === 403) {
return <Result status='403' title='Нет доступа' subTitle='Раздел доступен пользователям с правом просмотра метрик качества' />;
}
return (
<div style={{ margin: 10, padding: 16, background: 'white', borderRadius: 10, height: 'calc(100% - 20px)', overflow: 'auto' }}>
<h2 style={{ marginTop: 0 }}>Качество первички</h2>
<Space wrap style={{ marginBottom: 16 }}>
<DatePicker.RangePicker
format='DD.MM.YYYY'
placeholder={['Принят с', 'Принят по']}
onChange={(range) =>
patch({ date_from: range?.[0] ? iso(range[0]) : undefined, date_to: range?.[1] ? iso(range[1]) : undefined })
}
/>
<Select<DocumentType>
allowClear
placeholder='Тип документа'
style={{ width: 200 }}
options={DOC_TYPES}
onChange={(v) => patch({ document_type: v })}
/>
<Select<RecognitionPipeline>
allowClear
placeholder='Источник'
style={{ width: 200 }}
options={PIPELINES}
onChange={(v) => patch({ pipeline: v })}
/>
<Select<string>
allowClear
placeholder='Скан / цифровой'
style={{ width: 170 }}
options={SCAN_OPTIONS}
onChange={(v) => patch({ is_scan: v === undefined ? undefined : v === 'true' })}
/>
<Button onClick={() => void download('csv')}>Выгрузить CSV</Button>
<Button onClick={() => void download('json')}>Выгрузить JSON</Button>
</Space>
{error && <Alert type='error' showIcon message={error.message} style={{ marginBottom: 16 }} />}
<Spin spinning={loading}>
{matrix && matrix.denominator === 0 && matrix.in_progress_count === 0 && (
<Alert type='info' showIcon message='Нет данных за выбранный период' style={{ marginBottom: 16 }} />
)}
{matrix && (
<>
<Descriptions size='small' bordered column={{ xs: 1, md: 3 }} style={{ marginBottom: 16 }}>
<Descriptions.Item label='Подтверждённых документов'>{matrix.denominator}</Descriptions.Item>
<Descriptions.Item label='Уникальных отчётов'>{matrix.unique_reports_count}</Descriptions.Item>
<Descriptions.Item label='Технические отказы (по файлам)'>
{matrix.technical_failures.failed_files_count} из {matrix.technical_failures.total_files_count} (
{formatShare(matrix.technical_failures.share, matrix.technical_failures.total_files_count)})
</Descriptions.Item>
</Descriptions>
<Tabs
items={[
{
key: 'matrix',
label: 'Причины',
children: <MatrixTable data={matrix} onOpenCell={(cell, label) => setDrill({ cell, label })} />,
},
{ key: 'trend', label: 'Динамика по месяцам', children: <MonthlyTrend filters={filters} /> },
]}
/>
</>
)}
</Spin>
<Drawer title={drill?.label} width={900} open={drill !== null} onClose={() => setDrill(null)} destroyOnClose>
{drill && <DocumentDrillList filters={filters} cell={drill.cell} />}
</Drawer>
</div>
);
}