diff --git a/web/src/App.tsx b/web/src/App.tsx index 871c730..abe63b2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/web/src/auth/permissions.ts b/web/src/auth/permissions.ts index f6a49b8..65eabfe 100644 --- a/web/src/auth/permissions.ts +++ b/web/src/auth/permissions.ts @@ -1,3 +1,5 @@ // Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер: // проверки на клиенте только прячут то, что пользователю всё равно не разрешат. export const USERS_ADMIN = 'USERS_ADMIN'; +// Экран «Качество первички» (метрики качества распознавания). +export const QUALITY_METRICS_VIEW = 'QUALITY_METRICS_VIEW'; diff --git a/web/src/layouts/QualityMetrics.tsx b/web/src/layouts/QualityMetrics.tsx new file mode 100644 index 0000000..679ad3b --- /dev/null +++ b/web/src/layouts/QualityMetrics.tsx @@ -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 ; + } + return ; +} diff --git a/web/src/services/api/qualityMetrics.test.ts b/web/src/services/api/qualityMetrics.test.ts new file mode 100644 index 0000000..361f439 --- /dev/null +++ b/web/src/services/api/qualityMetrics.test.ts @@ -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(''); + }); +}); diff --git a/web/src/services/api/qualityMetrics.ts b/web/src/services/api/qualityMetrics.ts new file mode 100644 index 0000000..f9d2f37 --- /dev/null +++ b/web/src/services/api/qualityMetrics.ts @@ -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 { + return api.get(`${BASE}/matrix`, filters); +} + +export function getSeries(filters: QualityFilters, cause: QualityCause): Promise { + return api.get(`${BASE}/series`, { ...filters, cause }); +} + +export function getCellDocuments( + filters: QualityFilters, + cell: QualityCell, + paging: { limit: number; offset: number }, +): Promise> { + return api.get>(`${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}` : ''; +} diff --git a/web/src/types/qualityMetrics.ts b/web/src/types/qualityMetrics.ts new file mode 100644 index 0000000..c3f3332 --- /dev/null +++ b/web/src/types/qualityMetrics.ts @@ -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; + shares: Record; + 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; +} diff --git a/web/src/widgets/QualityMetrics/DocumentDrillList.tsx b/web/src/widgets/QualityMetrics/DocumentDrillList.tsx new file mode 100644 index 0000000..dc7676e --- /dev/null +++ b/web/src/widgets/QualityMetrics/DocumentDrillList.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 && } + + 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 ? ( + {documentTypeLabel(d.document_type, null)} + ) : ( + {documentTypeLabel(d.document_type, null)} (удалён) + ), + }, + { 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 ?? '—' }, + ]} + /> + + ); +} diff --git a/web/src/widgets/QualityMetrics/MatrixTable.tsx b/web/src/widgets/QualityMetrics/MatrixTable.tsx new file mode 100644 index 0000000..9ab35c3 --- /dev/null +++ b/web/src/widgets/QualityMetrics/MatrixTable.tsx @@ -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 = [ + { + title: 'Причина', + dataIndex: 'label', + render: (label: string, row) => ( +
+
{label}
+ {row.note &&
{row.note}
} +
+ ), + }, + { + title: 'Документов', + dataIndex: 'count', + align: 'right', + width: 140, + render: (count: number, row) => + onOpenCell && count > 0 ? ( + + ) : ( + count + ), + }, + { + title: 'Доля от подтверждённых', + dataIndex: 'share', + align: 'right', + width: 200, + render: (share: string | null) => share ?? '—', + }, + ]; + return rowKey='key' columns={columns} dataSource={buildMatrixRows(data)} pagination={false} size='small' />; +} diff --git a/web/src/widgets/QualityMetrics/MonthlyTrend.tsx b/web/src/widgets/QualityMetrics/MonthlyTrend.tsx new file mode 100644 index 0000000..3492d22 --- /dev/null +++ b/web/src/widgets/QualityMetrics/MonthlyTrend.tsx @@ -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('WITHOUT_PARTICIPATION'); + const [points, setPoints] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + + value={cause} + onChange={setCause} + style={{ width: 360 }} + options={CAUSE_ORDER.map((c) => ({ value: c, label: causeLabel(c) }))} + /> + {error && } + + 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) }, + ]} + /> + + ); +} diff --git a/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx b/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx new file mode 100644 index 0000000..72619b2 --- /dev/null +++ b/web/src/widgets/QualityMetrics/QualityMetricsPage.tsx @@ -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({}); + const [matrix, setMatrix] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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) => 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 ; + } + + return ( +
+

Качество первички

+ + + patch({ date_from: range?.[0] ? iso(range[0]) : undefined, date_to: range?.[1] ? iso(range[1]) : undefined }) + } + /> + + allowClear + placeholder='Тип документа' + style={{ width: 200 }} + options={DOC_TYPES} + onChange={(v) => patch({ document_type: v })} + /> + + allowClear + placeholder='Источник' + style={{ width: 200 }} + options={PIPELINES} + onChange={(v) => patch({ pipeline: v })} + /> + + allowClear + placeholder='Скан / цифровой' + style={{ width: 170 }} + options={SCAN_OPTIONS} + onChange={(v) => patch({ is_scan: v === undefined ? undefined : v === 'true' })} + /> + + + + + {error && } + + {matrix && matrix.denominator === 0 && matrix.in_progress_count === 0 && ( + + )} + {matrix && ( + <> + + {matrix.denominator} + {matrix.unique_reports_count} + + {matrix.technical_failures.failed_files_count} из {matrix.technical_failures.total_files_count} ( + {formatShare(matrix.technical_failures.share, matrix.technical_failures.total_files_count)}) + + + setDrill({ cell, label })} />, + }, + { key: 'trend', label: 'Динамика по месяцам', children: }, + ]} + /> + + )} + + + setDrill(null)} destroyOnClose> + {drill && } + +
+ ); +} diff --git a/web/src/widgets/QualityMetrics/qualityView.test.ts b/web/src/widgets/QualityMetrics/qualityView.test.ts new file mode 100644 index 0000000..c7f8cc4 --- /dev/null +++ b/web/src/widgets/QualityMetrics/qualityView.test.ts @@ -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'); + }); +}); diff --git a/web/src/widgets/QualityMetrics/qualityView.ts b/web/src/widgets/QualityMetrics/qualityView.ts new file mode 100644 index 0000000..73feae8 --- /dev/null +++ b/web/src/widgets/QualityMetrics/qualityView.ts @@ -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 = { + 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; +} diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index db12ff7..85f21cf 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -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: , label: 'Журнал действий', }, + { + link: '/quality-metrics', + icon: , + label: 'Качество первички', + permission: QUALITY_METRICS_VIEW, + }, { link: '/admin', icon: , @@ -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 ? (
{group.title}
- {group.children.map((i) => ( + {children.map((i) => ( {i.icon} {i.label} @@ -141,7 +152,7 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo
) : ( <> - {group.children.map((i) => ( + {children.map((i) => ( ))} @@ -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) => ( -
+
))} (items: readonly T[], permissions: readonly string[]): T[] { + return items.filter((item) => !item.permission || permissions.includes(item.permission)); +}