feat: [34] панель «Организации» — Ответственный / Утверждён в РФ / Верифицирован в ГО, «Скачать чек-лист» (CSV), подсветка выбранной версии (#3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-08 03:44:03 +04:00
parent 35e52aaa29
commit f756aff6ee
5 changed files with 53 additions and 2 deletions

View File

@ -1,4 +1,4 @@
import { api } from '../../api/client';
import { api, apiFetchBlob } from '../../api/client';
import type { ConsolidationCreate } from '../../types/api';
import type { IConsolidation } from '../../types/consolidations/consolidations';
import type { VersionCard } from '../../types/consolidations/version_members';
@ -6,3 +6,4 @@ import type { VersionCard } from '../../types/consolidations/version_members';
export const getConsolidations = (groupId: string) => api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations`);
export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`);
export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body);
export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`);

View File

@ -1,14 +1,18 @@
import { DownloadOutlined } from '@ant-design/icons';
import { Button, Space, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
import { ColumnSettings, DataTable, useFilteredRows, useVisibleColumns } from '../../components/GenericTable';
import { useTableFilters } from '../../components/GenericTable/useTableFilters';
import { downloadChecklistCsv } from '../../services/api/consolidations';
import { getVersionCard } from '../../services/api/consolidations';
import { setSelectedLegalEntity, setSelectedVersion } from '../../store/slices/consolidations/consolidationsSlice';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { IVersionCard, MemberRow } from '../../types/consolidations/version_members';
import { saveBlob } from '../../utils/download';
import { membersConfig } from './config';
import { normalizeData } from './normalize';
import { checklistFilename, responsibleName } from './panelInfo';
export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawerOpen: () => void; reloadKey: number }) {
const dispatch = useAppDispatch();
@ -32,6 +36,17 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
getLegalEntities();
}, [consolidation_id, reloadKey]);
const rawRows = useMemo(() => normalizeData(consolidationData), [consolidationData]);
const downloadChecklist = async () => {
if (!consolidationData) return;
try {
const { blob, filename } = await downloadChecklistCsv(consolidationData.id);
const card = consolidationData;
saveBlob(blob, filename ?? checklistFilename(card.company_group.name, card.year, card.quarter));
} catch (e: unknown) {
message.error(e instanceof ApiError ? e.message : 'Не удалось скачать чек-лист');
}
};
const { visibleKeys, setVisibleKeys } = useVisibleColumns(membersConfig);
const { rawQuery, columnFilters, setColumnFilters, sort, setSort, resetAll, hasActiveFilters } = useTableFilters<MemberRow>();
// single filtered array — title count and table share it
@ -65,6 +80,12 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
Организации
</h2>
<span style={{ fontSize: 13, color: 'rgb(102, 112, 133)' }}>{rawRows.length} шт.</span>
{consolidationData && (
// «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7
<span style={{ fontSize: 13, color: 'rgb(102, 112, 133)', marginLeft: 8 }}>
Ответственный: {responsibleName(consolidationData)}&nbsp;&nbsp; Утверждён в РФ: —&nbsp;&nbsp; Верифицирован в ГО: —
</span>
)}
</div>
<div>
{hasActiveFilters && (
@ -77,6 +98,9 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
</Button>
)}
<ColumnSettings defs={membersConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
<Button icon={<DownloadOutlined />} disabled={!consolidationData} onClick={downloadChecklist} style={{ marginLeft: 8 }}>
Скачать чек-лист
</Button>
</div>
</Space>
<DataTable

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { checklistFilename, responsibleName } from './panelInfo';
describe('responsibleName', () => {
it('при активной блокировке — кто взял в работу, иначе инициатор (10-user-journey 2.2)', () => {
expect(responsibleName({ lock: { locked_by_name: 'Петрова М. И.' }, initiator: { name: 'Иванов' } })).toBe('Петрова М. И.');
expect(responsibleName({ lock: null, initiator: { name: 'Иванов' } })).toBe('Иванов');
});
});
describe('checklistFilename', () => {
it('запасное имя CSV чек-листа', () => {
expect(checklistFilename('Русский Бисквит', 2024, 'Q3')).toBe('Чек-лист_Русский_Бисквит_2024_Q3.csv');
});
});

View File

@ -0,0 +1,9 @@
/** «Ответственный» панели «Организации» [34]: кто взял версию в работу, иначе инициатор (10-user-journey 2.2). */
export function responsibleName(card: { lock: { locked_by_name: string } | null; initiator: { name: string } }): string {
return card.lock?.locked_by_name ?? card.initiator.name;
}
/** Запасное имя CSV чек-листа, если Content-Disposition недоступен. */
export function checklistFilename(groupName: string, year: number, quarter: string): string {
return `Чек-лист_${groupName.replace(/[\\/:*?"<>|\s]+/g, '_')}_${year}_${quarter}.csv`;
}

View File

@ -44,7 +44,7 @@ const RowActions = memo(function RowActions({
export function ConsoleTableWidget() {
const navigate = useNavigate();
const { group_id } = useParams();
const [, setSearchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const consolidations = useAppSelector((s) => s.consolidations.consolidations);
const loading = useAppSelector((s) => s.consolidations.loading);
@ -142,6 +142,8 @@ export function ConsoleTableWidget() {
const onRow: TableProps<ConsRow>['onRow'] = (row) => ({
// выбранная версия живёт в URL (?version=): переживает уход на «К отчётности» и «Назад»
onClick: () => setSearchParams({ version: row.id }, { replace: true }),
// выбранная версия подсвечена, как в макете [34]
style: row.id === searchParams.get('version') ? { background: '#f0f7ee' } : undefined,
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>