From 2ea8a96f264e4d589a7992caa6509b9c03bb7e24 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 10:16:17 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D1=81=D0=B8=D0=BD=D1=85=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=84?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D1=82=D0=B0=20=D1=81=20arm-web=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенос в arm-core/web правок RSHB/arm-web PR #27 «Панель «Первичные документы»: рабочий стол консолидации и загрузка в консолидацию» (ветка test, диапазон коммитов fc75edf..100fbfb), только src/: - services/api/files.ts: filesPath, маршруты /consolidations/{cid}/legal-entities/{le}/files вместо ?consolidation_version_id=, которое ядро игнорирует; - store/slices/consolidations/selectors.ts: текущая консолидация и статус версии (новый файл); - widgets/FilesDrawer: рабочий стол консолидации, переключатель «Все документы организации», подпись консолидации у файла (consolidationLabel.ts), загрузка только в версии «в работе»; - widgets/Sidebar: перечитывание списка консолидаций после создания; - типы api.ts и consolidations.ts, тесты. Закрывает находку I-1 из #98: загрузка из встроенного фронта снова переводит версию INITIATED -> DOCUMENTS_PROCESSING. Спеки, CLAUDE.md и scripts из arm-web не переносятся. После переноса web/src и arm-web@test идентичны (diff -rq пуст). Co-Authored-By: Claude Sonnet 5.5 --- web/src/services/api/files.test.ts | 42 +++++++--- web/src/services/api/files.ts | 18 ++-- .../slices/consolidations/selectors.test.ts | 84 +++++++++++++++++++ .../store/slices/consolidations/selectors.ts | 31 +++++++ web/src/types/api.ts | 2 + .../types/consolidations/consolidations.ts | 2 +- web/src/widgets/FilesDrawer/FilesDrawer.tsx | 60 +++++++++---- web/src/widgets/FilesDrawer/FilesList.tsx | 4 +- .../FilesDrawer/UploadDocumentsModal.tsx | 6 +- .../FilesDrawer/consolidationLabel.test.ts | 25 ++++++ .../widgets/FilesDrawer/consolidationLabel.ts | 14 ++++ .../widgets/FilesDrawer/documentRows.test.ts | 10 +++ web/src/widgets/FilesDrawer/documentRows.ts | 7 +- .../FilesDrawer/useLegalEntityFiles.ts | 14 ++-- web/src/widgets/Sidebar/Sidebar.tsx | 5 ++ 15 files changed, 279 insertions(+), 45 deletions(-) create mode 100644 web/src/store/slices/consolidations/selectors.test.ts create mode 100644 web/src/store/slices/consolidations/selectors.ts create mode 100644 web/src/widgets/FilesDrawer/consolidationLabel.test.ts create mode 100644 web/src/widgets/FilesDrawer/consolidationLabel.ts diff --git a/web/src/services/api/files.test.ts b/web/src/services/api/files.test.ts index 3a73fa9..07fce57 100644 --- a/web/src/services/api/files.test.ts +++ b/web/src/services/api/files.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiError } from '../../api/client'; import type { FileOut, UploadResult } from '../../types/api'; -import { hasPendingFiles, isUploadResult, summarizeUpload, uploadFiles } from './files'; +import { filesPath, hasPendingFiles, isUploadResult, listLegalEntityFiles, summarizeUpload, uploadFiles } from './files'; const result: UploadResult = { accepted: [{ file_id: 'f1', job_id: 'j1', name: 'osv.xlsx' }], @@ -35,6 +35,7 @@ describe('hasPendingFiles', () => { version_no: 1, size_bytes: null, uploaded_at: null, + consolidation_id: null, job: null, documents: [], }); @@ -48,28 +49,37 @@ describe('hasPendingFiles', () => { }); }); +describe('filesPath', () => { + it('с консолидацией — маршрут консолидации, без неё — маршрут ЮЛ', () => { + expect(filesPath('le', 'c1')).toBe('/consolidations/c1/legal-entities/le/files'); + expect(filesPath('le', null)).toBe('/legal-entities/le/files'); + }); +}); + describe('uploadFiles', () => { afterEach(() => vi.restoreAllMocks()); - it('422 с UploadResult возвращается как результат', async () => { + it('422 с UploadResult возвращается как результат (и с консолидацией)', async () => { const client = await import('../../api/client'); vi.spyOn(client, 'apiFetch').mockRejectedValue(new ApiError(422, undefined, 'x', { accepted: [], rejected: result.rejected })); - await expect(uploadFiles('le', [new File(['a'], 'dup.xlsx')], null)).resolves.toEqual({ accepted: [], rejected: result.rejected }); + await expect(uploadFiles('le', [new File(['a'], 'dup.xlsx')], 'c1')).resolves.toEqual({ accepted: [], rejected: result.rejected }); }); - it('422 с detail пробрасывается', async () => { + it('422 с detail и 409 пробрасываются', async () => { const client = await import('../../api/client'); - const error = new ApiError(422, 'ENTITY_NOT_IN_PERIMETER', 'ЮЛ вне периметра', { detail: { code: 'ENTITY_NOT_IN_PERIMETER' } }); + const error = new ApiError(409, 'CONSOLIDATION_NOT_EDITABLE', 'нет версии в работе', { + detail: { code: 'CONSOLIDATION_NOT_EDITABLE' }, + }); vi.spyOn(client, 'apiFetch').mockRejectedValue(error); - await expect(uploadFiles('le', [new File(['a'], 'a.xlsx')], 'v1')).rejects.toBe(error); + await expect(uploadFiles('le', [new File(['a'], 'a.xlsx')], 'c1')).rejects.toBe(error); }); - it('версия попадает в query, лишних полей формы нет', async () => { + it('консолидация — в пути, query нет, лишних полей формы нет', async () => { const client = await import('../../api/client'); const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue(result); - await uploadFiles('le', [new File(['a'], 'a.xlsx'), new File(['b'], 'b.pdf')], 'v1'); + await uploadFiles('le', [new File(['a'], 'a.xlsx'), new File(['b'], 'b.pdf')], 'c1'); const [path, init] = spy.mock.calls[0]; - expect(path).toBe('/legal-entities/le/files?consolidation_version_id=v1'); + expect(path).toBe('/consolidations/c1/legal-entities/le/files'); const form = init?.body as FormData; expect(form.getAll('files')).toHaveLength(2); const keys: string[] = []; @@ -77,10 +87,22 @@ describe('uploadFiles', () => { expect(keys.every((k) => k === 'files')).toBe(true); }); - it('без версии — без query', async () => { + it('без консолидации — маршрут ЮЛ без query', async () => { const client = await import('../../api/client'); const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue(result); await uploadFiles('le', [new File(['a'], 'a.xlsx')], null); expect(spy.mock.calls[0][0]).toBe('/legal-entities/le/files'); }); }); + +describe('listLegalEntityFiles', () => { + afterEach(() => vi.restoreAllMocks()); + it('стол консолидации и список ЮЛ', async () => { + const client = await import('../../api/client'); + const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + await listLegalEntityFiles('le', 'c1'); + await listLegalEntityFiles('le', null); + expect(spy.mock.calls[0][0]).toBe('/consolidations/c1/legal-entities/le/files?limit=100'); + expect(spy.mock.calls[1][0]).toBe('/legal-entities/le/files?limit=100'); + }); +}); diff --git a/web/src/services/api/files.ts b/web/src/services/api/files.ts index 9ff317e..921f864 100644 --- a/web/src/services/api/files.ts +++ b/web/src/services/api/files.ts @@ -10,15 +10,21 @@ export function isUploadResult(body: unknown): body is UploadResult { ); } -/** Загрузка файлов ЮЛ одним запросом; 422 «ни один не принят» возвращается как результат, прочие ошибки — throw. */ -export async function uploadFiles(legalEntityId: string, files: File[], versionId: string | null): Promise { +/** Маршрут файлов ЮЛ: с консолидацией — файлы в контексте консолидации, без неё — файлы ЮЛ (design D-2). */ +export function filesPath(legalEntityId: string, consolidationId: string | null): string { + return consolidationId + ? `/consolidations/${encodeURIComponent(consolidationId)}/legal-entities/${legalEntityId}/files` + : `/legal-entities/${legalEntityId}/files`; +} + +/** Загрузка файлов ЮЛ одним запросом (в консолидацию или без контекста); 422 «ни один не принят» возвращается как результат, прочие ошибки — throw. */ +export async function uploadFiles(legalEntityId: string, files: File[], consolidationId: string | null): Promise { const form = new FormData(); for (const file of files) { form.append('files', file); } - const query = versionId ? `?consolidation_version_id=${encodeURIComponent(versionId)}` : ''; try { - return await apiFetch(`/legal-entities/${legalEntityId}/files${query}`, { method: 'POST', body: form }); + return await apiFetch(filesPath(legalEntityId, consolidationId), { method: 'POST', body: form }); } catch (e: unknown) { if (e instanceof ApiError && e.status === 422 && isUploadResult(e.body)) { return e.body; @@ -34,8 +40,8 @@ export function summarizeUpload(result: UploadResult): { accepted: string[]; rej }; } -export async function listLegalEntityFiles(legalEntityId: string): Promise { - const page = await apiFetch>(`/legal-entities/${legalEntityId}/files?limit=100`); +export async function listLegalEntityFiles(legalEntityId: string, consolidationId: string | null): Promise { + const page = await apiFetch>(`${filesPath(legalEntityId, consolidationId)}?limit=100`); return page.items; } diff --git a/web/src/store/slices/consolidations/selectors.test.ts b/web/src/store/slices/consolidations/selectors.test.ts new file mode 100644 index 0000000..91a429a --- /dev/null +++ b/web/src/store/slices/consolidations/selectors.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import type { IConsolidation } from '../../../types/consolidations/consolidations'; +import { findCurrentConsolidation, isInWork } from './selectors'; + +const cons: IConsolidation[] = [ + { + consolidation_id: 'c-2024q3', + year: 2024, + quarter: 'Q3', + reporting_date: '2024-10-01', + versions: [ + { + id: 'v1', + version_no: 1, + status: 'VERIFIED', + scenario: 'CREATE', + approach: 'BASIC', + lock: null, + created_at: null, + members_count: 2, + }, + { + id: 'v2', + version_no: 2, + status: 'INITIATED', + scenario: 'CREATE', + approach: 'BASIC', + lock: null, + created_at: null, + members_count: 2, + }, + ], + }, + { + consolidation_id: 'c-2024q4', + year: 2024, + quarter: 'Q4', + reporting_date: '2025-01-01', + versions: [ + { + id: 'v3', + version_no: 1, + status: 'CANCELLED', + scenario: 'CREATE', + approach: 'BASIC', + lock: null, + created_at: null, + members_count: 2, + }, + ], + }, +]; + +describe('findCurrentConsolidation', () => { + it('находит консолидацию и статус версии по id версии', () => { + expect(findCurrentConsolidation(cons, 'v2')).toEqual({ + consolidation_id: 'c-2024q3', + year: 2024, + quarter: 'Q3', + version_status: 'INITIATED', + }); + expect(findCurrentConsolidation(cons, 'v3')).toEqual({ + consolidation_id: 'c-2024q4', + year: 2024, + quarter: 'Q4', + version_status: 'CANCELLED', + }); + }); + it('null без версии, при неизвестной версии и при пустом списке (прямая ссылка до загрузки)', () => { + expect(findCurrentConsolidation(cons, null)).toBeNull(); + expect(findCurrentConsolidation(cons, 'nope')).toBeNull(); + expect(findCurrentConsolidation([], 'v2')).toBeNull(); + }); +}); + +describe('isInWork', () => { + it('в работе — три статуса', () => { + expect(isInWork('INITIATED')).toBe(true); + expect(isInWork('DOCUMENTS_PROCESSING')).toBe(true); + expect(isInWork('DATA_CORRECTION')).toBe(true); + expect(isInWork('VERIFIED')).toBe(false); + expect(isInWork('CANCELLED')).toBe(false); + }); +}); diff --git a/web/src/store/slices/consolidations/selectors.ts b/web/src/store/slices/consolidations/selectors.ts new file mode 100644 index 0000000..ee50b7f --- /dev/null +++ b/web/src/store/slices/consolidations/selectors.ts @@ -0,0 +1,31 @@ +import type { ConsStatus, QuarterEnum } from '../../../types/consolidations/common_types'; +import type { IConsolidation } from '../../../types/consolidations/consolidations'; +import { useAppSelector } from '../../store'; + +/** Консолидация открытой версии и статус этой версии (design D-1): выводится из списка ГК, в сторе не хранится. */ +export interface CurrentConsolidation { + consolidation_id: string; + year: number; + quarter: QuarterEnum; + version_status: ConsStatus; +} + +/** Статусы «в работе» (§9.1): только в них разрешена загрузка файлов в консолидацию. */ +export const IN_WORK_STATUSES: ReadonlySet = new Set(['INITIATED', 'DOCUMENTS_PROCESSING', 'DATA_CORRECTION']); + +export const isInWork = (status: ConsStatus): boolean => IN_WORK_STATUSES.has(status); + +export function findCurrentConsolidation(consolidations: IConsolidation[], versionId: string | null): CurrentConsolidation | null { + if (!versionId) return null; + for (const c of consolidations) { + const version = c.versions.find((v) => v.id === versionId); + if (version) return { consolidation_id: c.consolidation_id, year: c.year, quarter: c.quarter, version_status: version.status }; + } + return null; +} + +export function useCurrentConsolidation(): CurrentConsolidation | null { + const consolidations = useAppSelector((s) => s.consolidations.consolidations); + const versionId = useAppSelector((s) => s.consolidations.current_version_id); + return findCurrentConsolidation(consolidations, versionId); +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 91e2c7d..222a082 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -63,6 +63,8 @@ export interface FileOut { version_no: number; size_bytes: number | null; uploaded_at: string | null; + /** консолидация, в контексте которой загружен файл (v5.18); null — без контекста */ + consolidation_id: string | null; job: JobBrief | null; documents: DocumentBrief[]; } diff --git a/web/src/types/consolidations/consolidations.ts b/web/src/types/consolidations/consolidations.ts index 23bebbb..f546b70 100644 --- a/web/src/types/consolidations/consolidations.ts +++ b/web/src/types/consolidations/consolidations.ts @@ -1,6 +1,6 @@ import type { Approach, ConsRussianStatus, ConsStatus, LockOut, QuarterEnum, Scenario } from './common_types'; -interface IConsolidationVersion { +export interface IConsolidationVersion { id: string; version_no: number; status: ConsStatus; diff --git a/web/src/widgets/FilesDrawer/FilesDrawer.tsx b/web/src/widgets/FilesDrawer/FilesDrawer.tsx index 1ac9e2a..3026379 100644 --- a/web/src/widgets/FilesDrawer/FilesDrawer.tsx +++ b/web/src/widgets/FilesDrawer/FilesDrawer.tsx @@ -1,20 +1,34 @@ import { CloseOutlined, UploadOutlined } from '@ant-design/icons'; -import { Button, Drawer, Typography } from 'antd'; +import { Button, Drawer, Switch, Tooltip, Typography } from 'antd'; import { useEffect, useState } from 'react'; import { fetchLegalEntity } from '../../services/api/directories/company_groups'; +import { isInWork, useCurrentConsolidation } from '../../store/slices/consolidations/selectors'; import { useAppSelector } from '../../store/store'; -import type { LegalEntityBrief } from '../../types/api'; +import type { FileOut, LegalEntityBrief } from '../../types/api'; import { FilesList } from './FilesList'; import { UploadDocumentsModal } from './UploadDocumentsModal'; +import { consolidationLabel } from './consolidationLabel'; import { useLegalEntityFiles } from './useLegalEntityFiles'; +const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе'; + export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { const [uploadOpen, setUploadOpen] = useState(false); const [entity, setEntity] = useState(null); - const { current_version_id, current_legal_entity_id } = useAppSelector((s) => s.consolidations); - const { files, loading, reload } = useLegalEntityFiles(current_legal_entity_id, open); + const [showAll, setShowAll] = useState(false); + const { current_legal_entity_id, consolidations } = useAppSelector((s) => s.consolidations); + const current = useCurrentConsolidation(); + // консолидация не определена (версии нет в списке ГК) — режим «все» без переключателя (design D-3) + const desk = current !== null && !showAll; + const consolidationId = desk ? current.consolidation_id : null; + const { files, loading, reload } = useLegalEntityFiles(current_legal_entity_id, consolidationId, open); + const canUpload = current !== null && isInWork(current.version_status); + const labelFor = desk + ? undefined + : (f: FileOut) => consolidationLabel(f.consolidation_id, consolidations, current?.consolidation_id ?? null); useEffect(() => { + setShowAll(false); // новая панель открывается столом консоли setEntity(null); if (!open || !current_legal_entity_id) return; let cancelled = false; @@ -30,6 +44,17 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () }; }, [open, current_legal_entity_id]); + const uploadButton = ( + + ); + return ( }> -
- +
+ {canUpload ? ( + uploadButton + ) : ( + + {uploadButton} + + )} + {current !== null && ( + + + Все документы организации + + )}
- + {current_legal_entity_id && ( setUploadOpen(false)} diff --git a/web/src/widgets/FilesDrawer/FilesList.tsx b/web/src/widgets/FilesDrawer/FilesList.tsx index 41f46c0..8e4eaf8 100644 --- a/web/src/widgets/FilesDrawer/FilesList.tsx +++ b/web/src/widgets/FilesDrawer/FilesList.tsx @@ -57,9 +57,9 @@ function StatusPill({ row }: { row: DocumentRow }) { const HEADER = { fontSize: 11, letterSpacing: 0.5, color: '#8c8c8c', textTransform: 'uppercase' as const }; const head = (text: string) => {text}; -export function FilesList({ files, loading }: { files: FileOut[]; loading: boolean }) { +export function FilesList({ files, loading, labelFor }: { files: FileOut[]; loading: boolean; labelFor?: (f: FileOut) => string | null }) { const navigate = useNavigate(); - const rows = buildDocumentRows(files); + const rows = buildDocumentRows(files, labelFor); const columns: ColumnsType = [ { diff --git a/web/src/widgets/FilesDrawer/UploadDocumentsModal.tsx b/web/src/widgets/FilesDrawer/UploadDocumentsModal.tsx index a5dddf2..ee9b608 100644 --- a/web/src/widgets/FilesDrawer/UploadDocumentsModal.tsx +++ b/web/src/widgets/FilesDrawer/UploadDocumentsModal.tsx @@ -7,7 +7,7 @@ import { summarizeUpload, uploadFiles } from '../../services/api/files'; type Props = { legalEntityId: string; - versionId: string | null; + consolidationId: string | null; organizationName?: string | null; open: boolean; onClose: () => void; @@ -16,7 +16,7 @@ type Props = { type Summary = ReturnType; -export function UploadDocumentsModal({ legalEntityId, versionId, organizationName, open, onClose, onUploaded }: Props) { +export function UploadDocumentsModal({ legalEntityId, consolidationId, organizationName, open, onClose, onUploaded }: Props) { const [fileList, setFileList] = useState([]); const [uploading, setUploading] = useState(false); const [summary, setSummary] = useState(null); @@ -36,7 +36,7 @@ export function UploadDocumentsModal({ legalEntityId, versionId, organizationNam const files = fileList.map((f) => f.originFileObj).filter((f): f is NonNullable => Boolean(f)); setUploading(true); try { - const result = summarizeUpload(await uploadFiles(legalEntityId, files, versionId)); + const result = summarizeUpload(await uploadFiles(legalEntityId, files, consolidationId)); setSummary(result); if (result.accepted.length > 0) onUploaded(); else message.error('Ни один файл не принят'); diff --git a/web/src/widgets/FilesDrawer/consolidationLabel.test.ts b/web/src/widgets/FilesDrawer/consolidationLabel.test.ts new file mode 100644 index 0000000..3fc6447 --- /dev/null +++ b/web/src/widgets/FilesDrawer/consolidationLabel.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import type { IConsolidation } from '../../types/consolidations/consolidations'; +import { consolidationLabel } from './consolidationLabel'; + +const cons: IConsolidation[] = [ + { consolidation_id: 'c-2024q3', year: 2024, quarter: 'Q3', reporting_date: '2024-10-01', versions: [] }, + { consolidation_id: 'c-2025q1', year: 2025, quarter: 'Q1', reporting_date: '2025-04-01', versions: [] }, +]; + +describe('consolidationLabel', () => { + it('файл текущей консоли — без подписи', () => { + expect(consolidationLabel('c-2024q3', cons, 'c-2024q3')).toBeNull(); + }); + it('другая консоль из списка — год и квартал', () => { + expect(consolidationLabel('c-2025q1', cons, 'c-2024q3')).toBe('2025 К1'); + }); + it('без контекста и неизвестный id', () => { + expect(consolidationLabel(null, cons, 'c-2024q3')).toBe('вне консолидации'); + expect(consolidationLabel('ghost', cons, 'c-2024q3')).toBe('другая консолидация'); + }); + it('без текущей консолидации подпись есть у всех файлов', () => { + expect(consolidationLabel('c-2024q3', cons, null)).toBe('2024 К3'); + expect(consolidationLabel(null, cons, null)).toBe('вне консолидации'); + }); +}); diff --git a/web/src/widgets/FilesDrawer/consolidationLabel.ts b/web/src/widgets/FilesDrawer/consolidationLabel.ts new file mode 100644 index 0000000..b406016 --- /dev/null +++ b/web/src/widgets/FilesDrawer/consolidationLabel.ts @@ -0,0 +1,14 @@ +import type { IConsolidation } from '../../types/consolidations/consolidations'; +import { quarterShortLabel } from '../../utils/labels'; + +/** Подпись консолидации у файла в режиме «Все документы организации» (design D-4): null — файл текущей консоли. */ +export function consolidationLabel( + fileConsolidationId: string | null, + consolidations: IConsolidation[], + currentConsolidationId: string | null, +): string | null { + if (fileConsolidationId === null) return 'вне консолидации'; + if (fileConsolidationId === currentConsolidationId) return null; + const found = consolidations.find((c) => c.consolidation_id === fileConsolidationId); + return found ? `${found.year} ${quarterShortLabel(found.quarter)}` : 'другая консолидация'; +} diff --git a/web/src/widgets/FilesDrawer/documentRows.test.ts b/web/src/widgets/FilesDrawer/documentRows.test.ts index 33c6c9a..9b20410 100644 --- a/web/src/widgets/FilesDrawer/documentRows.test.ts +++ b/web/src/widgets/FilesDrawer/documentRows.test.ts @@ -26,6 +26,7 @@ const file = (over: Partial = {}): FileOut => ({ version_no: 1, size_bytes: 10, uploaded_at: '2024-03-12T10:00:00Z', + consolidation_id: null, job: null, documents: [], ...over, @@ -104,4 +105,13 @@ describe('buildDocumentRows', () => { expect(rows[0].subtitle).toBe('Прочий документ · PDF · 12.03.2024'); expect(rows[0].linkable).toBe(false); }); + + it('подпись консолидации добавляется в subtitle последней, null — не добавляется', () => { + const rows = buildDocumentRows( + [file({ id: 'f1', documents: [doc()] }), file({ id: 'f2', documents: [], consolidation_id: 'c9' })], + (f) => (f.id === 'f2' ? 'вне консолидации' : null), + ); + expect(rows[0].subtitle.endsWith('вне консолидации')).toBe(false); + expect(rows[1].subtitle.endsWith(' · вне консолидации')).toBe(true); + }); }); diff --git a/web/src/widgets/FilesDrawer/documentRows.ts b/web/src/widgets/FilesDrawer/documentRows.ts index 785392a..bfeef40 100644 --- a/web/src/widgets/FilesDrawer/documentRows.ts +++ b/web/src/widgets/FilesDrawer/documentRows.ts @@ -36,8 +36,9 @@ function documentRowStatus(status: DocumentStatus): RowStatus { } /** Файлы → строки таблицы: одна строка на документ, файл без документов — одна строка на файл. */ -export function buildDocumentRows(files: FileOut[]): DocumentRow[] { +export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => string | null): DocumentRow[] { return files.flatMap((f): DocumentRow[] => { + const extra = labelFor?.(f) ?? null; const name = f.original_name ?? 'без имени'; const date = formatMoscowDate(f.uploaded_at); if (f.documents.length === 0) { @@ -48,7 +49,7 @@ export function buildDocumentRows(files: FileOut[]): DocumentRow[] { documentId: null, name, fileType: f.file_type, - subtitle: join([f.file_type, date]), + subtitle: join([f.file_type, date, extra]), period: '—', status: failed ? 'error' : 'processing', statusLabel: failed ? 'Ошибка' : 'Обработка', @@ -68,7 +69,7 @@ export function buildDocumentRows(files: FileOut[]): DocumentRow[] { documentId: d.id, name, fileType: f.file_type, - subtitle: join([typeText, f.file_type, date]), + subtitle: join([typeText, f.file_type, date, extra]), period: periodLabel(d.period_year, d.period_quarter, d.is_cumulative), periodCheck: d.period_check, counterpartyCheck: d.counterparty_check, diff --git a/web/src/widgets/FilesDrawer/useLegalEntityFiles.ts b/web/src/widgets/FilesDrawer/useLegalEntityFiles.ts index df6e399..c877f8e 100644 --- a/web/src/widgets/FilesDrawer/useLegalEntityFiles.ts +++ b/web/src/widgets/FilesDrawer/useLegalEntityFiles.ts @@ -6,8 +6,11 @@ import type { FileOut } from '../../types/api'; const POLL_MS = 5000; -/** Файлы ЮЛ; пока панель открыта и есть UPLOADED/PROCESSING — перечитываем раз в 5 с. */ -export function useLegalEntityFiles(legalEntityId: string | null, open: boolean) { +/** + * Файлы рабочего стола консолидации (`consolidationId`) или все файлы ЮЛ (`null`); + * пока панель открыта и есть UPLOADED/PROCESSING — перечитываем раз в 5 с. + */ +export function useLegalEntityFiles(legalEntityId: string | null, consolidationId: string | null, open: boolean) { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(false); const requestRef = useRef(0); @@ -20,7 +23,7 @@ export function useLegalEntityFiles(legalEntityId: string | null, open: boolean) inFlightRef.current = true; setLoading(true); try { - const items = await listLegalEntityFiles(legalEntityId); + const items = await listLegalEntityFiles(legalEntityId, consolidationId); if (requestId === requestRef.current) { setFiles(items); errorShownRef.current = false; @@ -37,10 +40,11 @@ export function useLegalEntityFiles(legalEntityId: string | null, open: boolean) setLoading(false); } } - }, [legalEntityId]); + }, [legalEntityId, consolidationId]); + // смена режима (consolidationId) приходит через reload: он пересоздаётся и перезапускает этот эффект useEffect(() => { - // при смене ЮЛ / закрытии панели: старые ответы не применяем, файлы прежнего ЮЛ очищаем + // при смене ЮЛ, режима (консолидация/все) или закрытии панели: старые ответы не применяем, прежние файлы очищаем requestRef.current++; inFlightRef.current = false; errorShownRef.current = false; diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index 7e75df4..a615a1e 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -15,6 +15,8 @@ import { NavLink, useNavigate } from 'react-router-dom'; import { USERS_ADMIN } from '../../auth/permissions'; import { Logo } from '../../components/Logo'; import { createConsolidation } from '../../services/api/consolidations'; +import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks'; +import { useAppDispatch } from '../../store/store'; import { quarterLabel } from '../../utils/labels'; import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal'; import styles from './styles.module.css'; @@ -165,6 +167,7 @@ const Section = memo(function Section({ export function Sidebar() { const navigate = useNavigate(); + const dispatch = useAppDispatch(); const [modalOpen, setModalOpen] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); @@ -218,6 +221,8 @@ export function Sidebar() { }} onOk={async (values) => { const card = await createConsolidation(values); + // перечитываем список: на странице группы он сам не обновится, и новая консолидация не станет текущей + await dispatch(getConsolidationsListThunk(values.company_group_id)); setModalOpen(false); message.success(`Создана консолидация ${values.year}, ${quarterLabel(values.quarter)}`); navigate(`/groups/${values.company_group_id}?version=${card.id}`);