feat(#190): метрики качества первички (AURORA-1759) #229

Merged
aep merged 45 commits from feat/190-ocr-metrics into main 2026-11-07 07:50:00 +03:00
14 changed files with 637 additions and 6 deletions
Showing only changes of commit 717dec69cf - Show all commits

View File

@ -18,6 +18,7 @@ import {
Mono,
} from './layouts/';
import { ConsolidationReportPage } from './layouts/ConsolidationReportPage';
import { QualityMetrics } from './layouts/QualityMetrics';
import { store } from './store/store';
import { Sidebar } from './widgets/Sidebar/Sidebar';
@ -44,6 +45,7 @@ function App() {
<Route path='/catalogue/jur_l' element={<CatalogueJur />} />
<Route path='/docstorage' element={<DocStorage />} />
<Route path='/documents/:document_id' element={<DocumentPage />} />
<Route path='/quality-metrics' element={<QualityMetrics />} />
<Route path='/logging' element={<AuditLogging />} />
</Routes>
</Layout.Content>

View File

@ -1,3 +1,5 @@
// Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер:
// проверки на клиенте только прячут то, что пользователю всё равно не разрешат.
export const USERS_ADMIN = 'USERS_ADMIN';
// Экран «Качество первички» (метрики качества распознавания).
export const QUALITY_METRICS_VIEW = 'QUALITY_METRICS_VIEW';

View File

@ -0,0 +1,13 @@
import { Result } from 'antd';
import { useCurrentUser } from '../auth/MeContext';
import { QUALITY_METRICS_VIEW } from '../auth/permissions';
import { QualityMetricsPage } from '../widgets/QualityMetrics/QualityMetricsPage';
/** Маршрут `/quality-metrics`: без права `QUALITY_METRICS_VIEW` страница не создаётся, запросов к API нет. */
export function QualityMetrics() {
const me = useCurrentUser();
if (!me.permissions.includes(QUALITY_METRICS_VIEW)) {
return <Result status='403' title='Нет доступа' subTitle='Раздел доступен пользователям с правом просмотра метрик качества' />;
}
return <QualityMetricsPage />;
}

View File

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { queryString } from './qualityMetrics';
describe('queryString', () => {
it('пропускает пустые поля, булево пишет как true/false', () => {
expect(queryString({ date_from: '2026-01-01', pipeline: undefined, is_scan: false, format: 'csv' })).toBe(
'?date_from=2026-01-01&is_scan=false&format=csv',
);
});
it('без параметров — пустая строка', () => {
expect(queryString({})).toBe('');
});
});

View File

@ -0,0 +1,45 @@
import { api, apiFetchBlob } from '../../api/client';
import type { Page } from '../../api/types';
import type {
ExportFormat,
QualityCause,
QualityCell,
QualityCellDocumentOut,
QualityFilters,
QualityMatrixOut,
QualityMonthlySeriesOut,
} from '../../types/qualityMetrics';
const BASE = '/quality-metrics';
export function getMatrix(filters: QualityFilters): Promise<QualityMatrixOut> {
return api.get<QualityMatrixOut>(`${BASE}/matrix`, filters);
}
export function getSeries(filters: QualityFilters, cause: QualityCause): Promise<QualityMonthlySeriesOut> {
return api.get<QualityMonthlySeriesOut>(`${BASE}/series`, { ...filters, cause });
}
export function getCellDocuments(
filters: QualityFilters,
cell: QualityCell,
paging: { limit: number; offset: number },
): Promise<Page<QualityCellDocumentOut>> {
return api.get<Page<QualityCellDocumentOut>>(`${BASE}/cells/${cell}/documents`, { ...filters, ...paging });
}
/** Выгрузка фактов за период; скачивание идёт через fetch, потому что нужен заголовок Authorization. */
export function exportFacts(filters: QualityFilters, format: ExportFormat): Promise<{ blob: Blob; filename: string | null }> {
return apiFetchBlob(`${BASE}/export${queryString({ ...filters, format })}`);
}
/** Строка запроса для выгрузки (пустые поля пропускаются) — вынесено отдельно ради теста. */
export function queryString(params: object): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
search.append(key, String(value));
}
const qs = search.toString();
return qs ? `?${qs}` : '';
}

View File

@ -0,0 +1,98 @@
// Типы экрана «Качество первички» 1:1 со схемами API (api/src/schemas/quality_metrics.py).
// Доли — числа 0..1; даты — строки ISO (`2026-11-01`), момент времени — ISO с часовым поясом.
import type { DocumentType } from './api';
/** Исход документа (`QualityOutcome`). */
export type QualityOutcome =
| 'AUTO_CONFIRMED'
| 'CONFIRMED'
| 'DELETED'
| 'SUPERSEDED'
| 'DELETED_AFTER_CONFIRM'
| 'SUPERSEDED_AFTER_CONFIRM';
/** Причина документа (`QualityCause`). */
export type QualityCause = 'SERVICE_ERROR' | 'OUR_ERROR' | 'CLIENT_DOCUMENT' | 'WITHOUT_PARTICIPATION' | 'AS_IS';
/** Ячейка матрицы, из которой раскрывается список документов (`QualityCell`). */
export type QualityCell = QualityCause | 'REMOVED_BEFORE_CONFIRM' | 'REMOVED_AFTER_CONFIRM' | 'IN_PROGRESS';
/** Источник: image = PDF/скан, tabular = Excel (`RecognitionPipeline`). */
export type RecognitionPipeline = 'image' | 'tabular';
export type ExportFormat = 'csv' | 'json';
/** Фильтры (`QualityFilters`); пустые поля в запрос не попадают. */
export interface QualityFilters {
date_from?: string;
date_to?: string;
group_id?: string;
legal_entity_id?: string;
document_type?: DocumentType;
pipeline?: RecognitionPipeline;
is_scan?: boolean;
}
export interface TechnicalFailuresOut {
failed_files_count: number;
total_files_count: number;
share: number;
}
export interface QualityMatrixOut {
counts: Record<QualityCause, number>;
shares: Record<QualityCause, number>;
denominator: number;
removed_before_confirm: number;
removed_after_confirm: number;
deleted_after_confirm_count: number;
superseded_after_confirm_count: number;
in_progress_count: number;
unique_reports_count: number;
technical_failures: TechnicalFailuresOut;
}
export interface MonthPointOut {
month: string;
count: number;
denominator: number;
share: number;
}
export interface QualityMonthlySeriesOut {
cause: QualityCause;
points: MonthPointOut[];
}
export interface QualityFixesOut {
value_service: number;
value_ours: number;
code_service: number;
code_ours: number;
added_row_service: number;
added_row_ours: number;
header_service: number;
header_ours: number;
osv_period_account: number;
}
export interface QualityCellDocumentOut {
document_id: string;
document_type: DocumentType;
legal_entity_id: string;
legal_entity_name: string;
group_name: string | null;
received_at: string;
outcome: QualityOutcome | null;
outcome_at: string | null;
previous_outcome: QualityOutcome | null;
primary_cause: QualityCause | null;
report_date: string | null;
fixes: QualityFixesOut | null;
checks_accepted: boolean | null;
issue_count_at_outcome: number | null;
confirmed_by_id: string | null;
confirmed_by_name: string | null;
document_link: string | null;
}

View File

@ -0,0 +1,72 @@
import { Alert, Table } from 'antd';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getCellDocuments } from '../../services/api/qualityMetrics';
import type { QualityCell, QualityCellDocumentOut, QualityFilters } from '../../types/qualityMetrics';
import { documentTypeLabel } from '../../utils/labels';
const PAGE_SIZE = 20;
/** Документы одной ячейки матрицы; постранично, имя документа ведёт на карточку документа. */
export function DocumentDrillList({ filters, cell }: { filters: QualityFilters; cell: QualityCell }) {
const [items, setItems] = useState<QualityCellDocumentOut[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// смена фильтров или ячейки — снова с первой страницы
// biome-ignore lint/correctness/useExhaustiveDependencies: сброс страницы по смене входных данных
useEffect(() => setPage(1), [filters, cell]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getCellDocuments(filters, cell, { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE })
.then((r) => {
if (cancelled) return;
setItems(r.items);
setTotal(r.total);
})
.catch((e: unknown) => {
if (!cancelled) setError(e instanceof Error ? e.message : 'Не удалось загрузить документы');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [filters, cell, page]);
return (
<>
{error && <Alert type='error' message={error} showIcon style={{ marginBottom: 8 }} />}
<Table<QualityCellDocumentOut>
rowKey='document_id'
size='small'
loading={loading}
dataSource={items}
locale={{ emptyText: 'Нет документов' }}
pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, onChange: setPage }}
columns={[
{
title: 'Документ',
render: (_: unknown, d) =>
d.document_link ? (
<Link to={`/documents/${d.document_id}`}>{documentTypeLabel(d.document_type, null)}</Link>
) : (
<span title='Документ удалён'>{documentTypeLabel(d.document_type, null)} (удалён)</span>
),
},
{ title: 'Юрлицо', dataIndex: 'legal_entity_name' },
{ title: 'ГК', dataIndex: 'group_name', render: (g: string | null) => g ?? '—' },
{ title: 'Отчётная дата', dataIndex: 'report_date', render: (v: string | null) => v ?? '—' },
{ title: 'Принят', dataIndex: 'received_at', render: (v: string) => v.slice(0, 10) },
{ title: 'Подтвердил', dataIndex: 'confirmed_by_name', render: (v: string | null) => v ?? '—' },
]}
/>
</>
);
}

View File

@ -0,0 +1,48 @@
import { Button, Table } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { QualityCell, QualityMatrixOut } from '../../types/qualityMetrics';
import { type MatrixRow, buildMatrixRows } from './qualityView';
interface Props {
data: QualityMatrixOut;
/** Клик по счётчику ячейки — открыть список документов. */
onOpenCell?: (cell: QualityCell, label: string) => void;
}
/** Матрица причин: строка — причина или служебная группа, счётчик раскрывается в список документов. */
export function MatrixTable({ data, onOpenCell }: Props) {
const columns: ColumnsType<MatrixRow> = [
{
title: 'Причина',
dataIndex: 'label',
render: (label: string, row) => (
<div>
<div>{label}</div>
{row.note && <div style={{ color: '#888', fontSize: 12 }}>{row.note}</div>}
</div>
),
},
{
title: 'Документов',
dataIndex: 'count',
align: 'right',
width: 140,
render: (count: number, row) =>
onOpenCell && count > 0 ? (
<Button type='link' size='small' onClick={() => onOpenCell(row.key, row.label)}>
{count}
</Button>
) : (
count
),
},
{
title: 'Доля от подтверждённых',
dataIndex: 'share',
align: 'right',
width: 200,
render: (share: string | null) => share ?? '—',
},
];
return <Table<MatrixRow> rowKey='key' columns={columns} dataSource={buildMatrixRows(data)} pagination={false} size='small' />;
}

View File

@ -0,0 +1,58 @@
import { Alert, Select, Space, Table } from 'antd';
import { useEffect, useState } from 'react';
import { getSeries } from '../../services/api/qualityMetrics';
import type { MonthPointOut, QualityCause, QualityFilters } from '../../types/qualityMetrics';
import { CAUSE_ORDER, causeLabel, formatShare, monthLabel } from './qualityView';
/** Динамика по месяцам даты приёма для выбранной причины (таблицей: библиотеки графиков в проекте нет). */
export function MonthlyTrend({ filters }: { filters: QualityFilters }) {
const [cause, setCause] = useState<QualityCause>('WITHOUT_PARTICIPATION');
const [points, setPoints] = useState<MonthPointOut[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getSeries(filters, cause)
.then((r) => {
if (!cancelled) setPoints(r.points);
})
.catch((e: unknown) => {
if (!cancelled) setError(e instanceof Error ? e.message : 'Не удалось загрузить динамику');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [filters, cause]);
return (
<Space direction='vertical' style={{ width: '100%' }}>
<Select<QualityCause>
value={cause}
onChange={setCause}
style={{ width: 360 }}
options={CAUSE_ORDER.map((c) => ({ value: c, label: causeLabel(c) }))}
/>
{error && <Alert type='error' message={error} showIcon />}
<Table<MonthPointOut>
rowKey='month'
size='small'
loading={loading}
pagination={false}
dataSource={points}
locale={{ emptyText: 'Нет данных за период' }}
columns={[
{ title: 'Месяц приёма', dataIndex: 'month', render: monthLabel },
{ title: 'Документов', dataIndex: 'count', align: 'right' },
{ title: 'Подтверждено всего', dataIndex: 'denominator', align: 'right' },
{ title: 'Доля', dataIndex: 'share', align: 'right', render: (_: number, p) => formatShare(p.share, p.denominator) },
]}
/>
</Space>
);
}

View File

@ -0,0 +1,146 @@
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>
);
}

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import type { QualityMatrixOut } from '../../types/qualityMetrics';
import { filterByPermission } from '../Sidebar/visibility';
import { buildMatrixRows, formatShare, monthLabel } from './qualityView';
const matrixFixture: QualityMatrixOut = {
counts: { SERVICE_ERROR: 3, OUR_ERROR: 2, CLIENT_DOCUMENT: 1, WITHOUT_PARTICIPATION: 12, AS_IS: 5 },
shares: { SERVICE_ERROR: 0.13, OUR_ERROR: 0.087, CLIENT_DOCUMENT: 0.043, WITHOUT_PARTICIPATION: 0.522, AS_IS: 0.217 },
denominator: 23,
removed_before_confirm: 4,
removed_after_confirm: 3,
deleted_after_confirm_count: 1,
superseded_after_confirm_count: 2,
in_progress_count: 6,
unique_reports_count: 20,
technical_failures: { failed_files_count: 1, total_files_count: 30, share: 0.033 },
};
describe('пункт меню по праву', () => {
const items = [{ link: '/a' }, { link: '/q', permission: 'QUALITY_METRICS_VIEW' }];
it('скрыт без права', () => {
expect(filterByPermission(items, ['USERS_ADMIN']).map((i) => i.link)).toEqual(['/a']);
});
it('виден с правом', () => {
expect(filterByPermission(items, ['QUALITY_METRICS_VIEW']).map((i) => i.link)).toEqual(['/a', '/q']);
});
});
describe('buildMatrixRows', () => {
const rows = buildMatrixRows(matrixFixture);
it('счётчики из ответа API', () => {
expect(rows.find((r) => r.key === 'WITHOUT_PARTICIPATION')?.count).toBe(12);
expect(rows.find((r) => r.key === 'REMOVED_BEFORE_CONFIRM')?.count).toBe(4);
expect(rows.find((r) => r.key === 'IN_PROGRESS')?.count).toBe(6);
});
it('доли только у причин; разбивка удалённых после подтверждения', () => {
expect(rows.find((r) => r.key === 'WITHOUT_PARTICIPATION')?.share).toBe('52,2%');
expect(rows.find((r) => r.key === 'IN_PROGRESS')?.share).toBeNull();
expect(rows.find((r) => r.key === 'REMOVED_AFTER_CONFIRM')?.note).toBe('удалено: 1, заменено: 2');
});
});
describe('formatShare', () => {
it('нет знаменателя — «нет данных», не 0%', () => {
expect(formatShare(0, 0)).toBe('нет данных');
});
});
describe('monthLabel', () => {
it('месяц.год', () => {
expect(monthLabel('2026-03-01')).toBe('03.2026');
});
});

View File

@ -0,0 +1,59 @@
// Чистые функции экрана «Качество первички»: подписи и строки матрицы. Отдельно от React — чтобы тестировать.
import type { QualityCause, QualityCell, QualityMatrixOut } from '../../types/qualityMetrics';
export const CAUSE_ORDER: readonly QualityCause[] = ['WITHOUT_PARTICIPATION', 'AS_IS', 'CLIENT_DOCUMENT', 'OUR_ERROR', 'SERVICE_ERROR'];
const CAUSE_LABEL: Record<QualityCause, string> = {
WITHOUT_PARTICIPATION: 'Без участия аналитика (автоподтверждён)',
AS_IS: 'Подтверждён как есть',
CLIENT_DOCUMENT: 'Ошибка в документе клиента',
OUR_ERROR: 'Наша ошибка разбора',
SERVICE_ERROR: 'Ошибка сервиса распознавания',
};
export const causeLabel = (cause: QualityCause): string => CAUSE_LABEL[cause];
/** Доля 0..1 → «12,3%»; нет знаменателя — «нет данных», а не «0%». */
export function formatShare(share: number, denominator: number): string {
if (denominator <= 0) return 'нет данных';
return `${(share * 100).toFixed(1).replace('.', ',')}%`;
}
export interface MatrixRow {
key: QualityCell;
label: string;
count: number;
/** Подпись доли; null — строка в доли не входит (удалённые, заменённые, «в работе»). */
share: string | null;
/** Пояснение под названием (разбивка «удалён / заменён»). */
note?: string;
}
/** Строки матрицы: пять причин (с долями от подтверждённых) и три служебные строки без долей. */
export function buildMatrixRows(data: QualityMatrixOut): MatrixRow[] {
const causeRows: MatrixRow[] = CAUSE_ORDER.map((cause) => ({
key: cause,
label: causeLabel(cause),
count: data.counts[cause] ?? 0,
share: formatShare(data.shares[cause] ?? 0, data.denominator),
}));
return [
...causeRows,
{ key: 'REMOVED_BEFORE_CONFIRM', label: 'Удалены или заменены до подтверждения', count: data.removed_before_confirm, share: null },
{
key: 'REMOVED_AFTER_CONFIRM',
label: 'Удалены или заменены после подтверждения',
count: data.removed_after_confirm,
share: null,
note: `удалено: ${data.deleted_after_confirm_count}, заменено: ${data.superseded_after_confirm_count}`,
},
{ key: 'IN_PROGRESS', label: 'В работе (исхода ещё нет)', count: data.in_progress_count, share: null },
];
}
/** Месяц `2026-03-01` → «03.2026». */
export function monthLabel(iso: string): string {
const [year, month] = iso.split('-');
return month && year ? `${month}.${year}` : iso;
}

View File

@ -1,9 +1,10 @@
import { LeftOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Divider, Layout, Tooltip, message } from 'antd';
import { Archive, Building2, ScrollText, UserCog2, Users2 } from 'lucide-react';
import { Archive, Building2, ClipboardCheck, ScrollText, UserCog2, Users2 } from 'lucide-react';
import { type ReactNode, memo, useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { USERS_ADMIN } from '../../auth/permissions';
import { useCurrentUser } from '../../auth/MeContext';
import { QUALITY_METRICS_VIEW, USERS_ADMIN } from '../../auth/permissions';
import { createConsolidation } from '../../services/api/consolidations';
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
import { useAppDispatch } from '../../store/store';
@ -11,6 +12,7 @@ import { quarterLabel } from '../../utils/labels';
import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal';
import { UserMenu } from '../UserMenu/UserMenu';
import styles from './styles.module.css';
import { filterByPermission } from './visibility';
type CollapsedIconProps = {
icon: ReactNode;
@ -104,6 +106,12 @@ const config: Group[] = [
icon: <ScrollText size={14} />,
label: 'Журнал действий',
},
{
link: '/quality-metrics',
icon: <ClipboardCheck size={14} />,
label: 'Качество первички',
permission: QUALITY_METRICS_VIEW,
},
{
link: '/admin',
icon: <UserCog2 size={14} />,
@ -127,11 +135,14 @@ function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) {
);
}
const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) {
const Section = memo(function Section({ isCollapsed, group, permissions }: { isCollapsed: boolean; group: Group; permissions: string[] }) {
// пункты без нужного права не показываем; пустую секцию не рисуем совсем
const children = filterByPermission(group.children, permissions);
if (children.length === 0) return null;
return !isCollapsed ? (
<div className={styles.section}>
<div className={styles.sectionTitle}>{group.title}</div>
{group.children.map((i) => (
{children.map((i) => (
<NavLinkStyled key={i.link} to={i.link}>
{i.icon}
{i.label}
@ -141,7 +152,7 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo
</div>
) : (
<>
{group.children.map((i) => (
{children.map((i) => (
<CollapsedIcon key={i.link} icon={i.icon} to={i.link} title={i.label} />
))}
</>
@ -149,6 +160,7 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo
});
export function Sidebar() {
const me = useCurrentUser();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [modalOpen, setModalOpen] = useState(false);
@ -194,7 +206,7 @@ export function Sidebar() {
</>
)}
{config.map((g) => (
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
<Section key={g.key} group={g} isCollapsed={isCollapsed} permissions={me.permissions} />
))}
</div>
<NewConsolidationModal

View File

@ -0,0 +1,10 @@
// Видимость пунктов меню по правам из `/me`. Прячет лишнее для удобства; доступ всё равно решает сервер.
export interface PermissionItem {
/** Право, без которого пункт не показывается; нет — пункт виден всем. */
permission?: string;
}
export function filterByPermission<T extends PermissionItem>(items: readonly T[], permissions: readonly string[]): T[] {
return items.filter((item) => !item.permission || permissions.includes(item.permission));
}