147 lines
5.9 KiB
TypeScript
147 lines
5.9 KiB
TypeScript
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>
|
||
);
|
||
}
|