fix(#49): зелёный make check и исправления после ревью (AURORA-144)

- маршрут /documents/:document_id возвращён: старые ссылки («Открыть подтверждённую форму») снова открывают
  документ; ЮЛ и консолидация для переключателя документов берутся из документа и его файла, а не из адреса;
  в списке переключателя — вторая строка (тип · формат · дата), ББ и ОФР одного PDF различимы
- чек-лист: ОФР — ячейка INCOME (было 'OFR' → всегда «Отсутствует»), сборка строк — buildChecklistRows с тестами,
  rowKey по ЮЛ, типы ответа GET …/checklist
- удаление файла по id файла (fileId строки), а не по id документа (было 404); ошибка — без закрытия окна
- замена: настоящая загрузка нового файла в ту же консолидацию (автозамена ядра, 05-domain-rules §9.5 п. 3),
  причина — комментарием к заменяемому документу; кнопка доступна только в версии в работе
- комментарий: путь без хвостового «/», типы, закрытие только после успеха, лимит 4000
- счётчики над таблицей формы — модуль reviewCounters (по needs_review, как подсветка) с тестами;
  buildStatementGrid возвращён к test (падали 3 теста)
- PageTitle и store возвращены к test (ширина переключателя — внутри DocumentPage; имя ЮЛ — пропсом)
- цвет подписей полей в теме: #667085 (опечатка #665087)
- типы во всех новых компонентах: tsc 0 ошибок, ESLint/biome без ошибок, vitest 331/331, сборка

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 13:14:09 +04:00
parent 0fdbd80fbd
commit 0242486eac
28 changed files with 479 additions and 382 deletions

View File

@ -49,7 +49,8 @@ SPA кредитного аналитика АРМ КА: справочники
список для панели и `collapseIssues`; `statementDraft` — весь черновик правки и сборка тела `PATCH` (суммы, подтверждения, коды, даты, шапка); список для панели и `collapseIssues`; `statementDraft` — весь черновик правки и сборка тела `PATCH` (суммы, подтверждения, коды, даты, шапка);
`cellView` — цвета, подсказки, текст ячейки суммы и кода; `codeSuggestions` — подсказки кодов по справочнику; `lineMenu` — меню строки `cellView` — цвета, подсказки, текст ячейки суммы и кода; `codeSuggestions` — подсказки кодов по справочнику; `lineMenu` — меню строки
(«Это расшифровка», «Назначить код»); `editRules` — что можно править и какая кнопка подтверждения нужна ячейке/колонке; («Это расшифровка», «Назначить код»); `editRules` — что можно править и какая кнопка подтверждения нужна ячейке/колонке;
`confirmBlockers` — строки без кода, блокирующие подтверждение; `focusTarget` — к какой строке прокрутить по «Показать». `confirmBlockers` — строки без кода, блокирующие подтверждение; `focusTarget` — к какой строке прокрутить по «Показать»;
`reviewCounters` — счётчики «изменённых» и «требующих проверки» над таблицей (по `needs_review`, как подсветка).
`src/widgets/FilesDrawer/fileWarnings` и `documentRows` — предупреждения файла и документа в панели файлов; `src/utils/percent` — `src/widgets/FilesDrawer/fileWarnings` и `documentRows` — предупреждения файла и документа в панели файлов; `src/utils/percent` —
формат уверенности. формат уверенности.

View File

@ -43,7 +43,7 @@ function App() {
<Route path='/catalogue/groups' element={<CatalogueGroup />} /> <Route path='/catalogue/groups' element={<CatalogueGroup />} />
<Route path='/catalogue/jur_l' element={<CatalogueJur />} /> <Route path='/catalogue/jur_l' element={<CatalogueJur />} />
<Route path='/docstorage' element={<DocStorage />} /> <Route path='/docstorage' element={<DocStorage />} />
<Route path='/documents' element={<DocumentPage />} /> <Route path='/documents/:document_id' element={<DocumentPage />} />
<Route path='/logging' element={<AuditLogging />} /> <Route path='/logging' element={<AuditLogging />} />
</Routes> </Routes>
</Layout.Content> </Layout.Content>

View File

@ -19,16 +19,8 @@ const crumbs = (location: string | string[]) =>
export default function PageTitle({ location, header, description, backable = false, extra }: PageTitleProps) { export default function PageTitle({ location, header, description, backable = false, extra }: PageTitleProps) {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<header <header style={{ display: 'flex', alignItems: 'flex-start', gap: 16, marginBottom: 12, flex: '0 0 auto' }}>
style={{ <div style={{ flex: '1 1 auto', minWidth: 0 }}>
display: 'flex',
alignItems: 'flex-start',
gap: 16,
marginBottom: 12,
flex: '0 0 auto',
}}>
{/* <div style={{ flex: '1 1 auto', minWidth: 0 }}> */}
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, color: '#667085' }}> <div style={{ fontSize: 12, color: '#667085' }}>
{backable && ( {backable && (
<Button <Button
@ -53,17 +45,7 @@ export default function PageTitle({ location, header, description, backable = fa
</h1> </h1>
{description && <div style={{ color: '#667085' }}>{description}</div>} {description && <div style={{ color: '#667085' }}>{description}</div>}
</div> </div>
{extra && ( {extra && <div style={{ flex: '0 0 auto' }}>{extra}</div>}
<div
style={{
marginLeft: 'auto',
width: 'min(520px, 48%)',
minWidth: 0,
display: 'flex',
}}>
{extra}
</div>
)}
</header> </header>
); );
} }

View File

@ -50,7 +50,7 @@ createRoot(rootElement).render(
}, },
Form: { Form: {
labelFontSize: 12, labelFontSize: 12,
labelColor: '#665087', labelColor: '#667085',
}, },
}, },
}}> }}>

View File

@ -1,63 +1,66 @@
import { Button, Empty, Form, message, Result, Select, Spin, Tooltip } from 'antd'; import { CommentOutlined } from '@ant-design/icons';
import { Button, Empty, Form, Result, Select, Spin, Tooltip } from 'antd';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { ApiError } from '../api/client'; import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle'; import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets';
import { fetchLegalEntity } from '../services/api/directories/company_groups'; import { fetchLegalEntity } from '../services/api/directories/company_groups';
import { getDocument } from '../services/api/documents'; import { getDocument } from '../services/api/documents';
import { getFile, listLegalEntityFiles } from '../services/api/files'; import { getFile, listLegalEntityFiles } from '../services/api/files';
import type { DocumentOut } from '../types/api'; import type { DocumentOut } from '../types/api';
import { documentTypeLabel, periodLabel } from '../utils/labels'; import { documentTypeLabel, periodLabel } from '../utils/labels';
import CommentModal from '../widgets/DocumentReview/CommentModal';
import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; import { OsvDocument } from '../widgets/DocumentReview/OsvDocument';
import { StatementReview } from '../widgets/DocumentReview/StatementReview'; import { StatementReview } from '../widgets/DocumentReview/StatementReview';
import { documentPageTitle, documentSubtitle, optionStatusBadge, statusBadge } from '../widgets/DocumentReview/reviewView'; import { documentPageTitle, documentSubtitle, optionStatusBadge, statusBadge } from '../widgets/DocumentReview/reviewView';
import { documentViewKind } from '../widgets/DocumentReview/viewKind'; import { documentViewKind } from '../widgets/DocumentReview/viewKind';
import { buildDocumentRows } from '../widgets/FilesDrawer/documentRows'; import { type DocumentRow, buildDocumentRows } from '../widgets/FilesDrawer/documentRows';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets'; type DocumentOption = { value: string | null; label: string; disabled: boolean; row: DocumentRow };
import { CommentOutlined } from '@ant-design/icons';
import CommentModal from '../widgets/DocumentReview/CommentModal';
export function DocumentPage() { export function DocumentPage() {
const [searchParams, setSearchParams] = useSearchParams(); const { document_id } = useParams();
const document_id = searchParams.get('document_id');
const legal_entity_id = searchParams.get('legal_entity_id');
const consolidation_id = searchParams.get('consolidation_id');
const navigate = useNavigate(); const navigate = useNavigate();
const [doc, setDoc] = useState<DocumentOut | null>(null); const [doc, setDoc] = useState<DocumentOut | null>(null);
const [entityName, setEntityName] = useState(''); const [entityName, setEntityName] = useState('');
const [subtitle, setSubtitle] = useState<string | null>(null); const [subtitle, setSubtitle] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filesOptions, setFilesOptions] = useState<any[]>([]); const [documentOptions, setDocumentOptions] = useState<DocumentOption[]>([]);
const [commentOpen, setCommentOpen] = useState(false); const [commentOpen, setCommentOpen] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!document_id) return; if (!document_id) return;
try { try {
const d = await getDocument(document_id); const d = await getDocument(document_id);
const filesJson = await listLegalEntityFiles(legal_entity_id, consolidation_id);
const documentOptions = buildDocumentRows(filesJson);
const options = documentOptions.map((d) => ({
disabled: d.documentId === null,
label: d.name,
value: d.documentId,
...d,
title: '',
}));
setFilesOptions(options);
setDoc(d); setDoc(d);
setError(null); setError(null);
fetchLegalEntity(d.legal_entity_id) fetchLegalEntity(d.legal_entity_id)
.then((le) => setEntityName(le.name)) .then((le) => setEntityName(le.name))
.catch(() => setEntityName('')); .catch(() => setEntityName(''));
getFile(d.file_id) getFile(d.file_id)
.then((f) => setSubtitle(documentSubtitle(f))) .then((f) => {
setSubtitle(documentSubtitle(f));
// переключатель документа — файлы того же ЮЛ в той же консолидации, что и файл документа (без неё — все файлы ЮЛ)
listLegalEntityFiles(d.legal_entity_id, f.consolidation_id)
.then((files) =>
setDocumentOptions(
buildDocumentRows(files).map((row) => ({
value: row.documentId,
label: row.name,
disabled: !row.linkable,
row,
})),
),
)
.catch(() => setDocumentOptions([]));
})
.catch(() => setSubtitle(null)); .catch(() => setSubtitle(null));
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ'); setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ');
} }
}, [document_id, legal_entity_id, consolidation_id]); }, [document_id]);
useEffect(() => { useEffect(() => {
void load(); void load();
@ -74,14 +77,7 @@ export function DocumentPage() {
// Документ в статусе ERROR — данных формы/строк нет: показываем карточку и понятный текст, а не пустой экран. // Документ в статусе ERROR — данных формы/строк нет: показываем карточку и понятный текст, а не пустой экран.
const broken = doc.status === 'ERROR' && kind !== 'none'; const broken = doc.status === 'ERROR' && kind !== 'none';
return ( return (
<div <div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100vh', gap: 12 }}>
style={{
padding: 12,
display: 'flex',
flexDirection: 'column',
height: '100vh',
gap: 12,
}}>
<PageTitle <PageTitle
backable backable
location={['Отчётность', entityName, 'Проверка документа']} location={['Отчётность', entityName, 'Проверка документа']}
@ -91,72 +87,38 @@ export function DocumentPage() {
`${documentTypeLabel(doc.document_type, doc.osv_account_code)} · ${periodLabel(doc.period_year, doc.period_quarter, doc.is_cumulative)}` `${documentTypeLabel(doc.document_type, doc.osv_account_code)} · ${periodLabel(doc.period_year, doc.period_quarter, doc.is_cumulative)}`
} }
extra={ extra={
<div <div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', width: 'min(520px, 48vw)' }}>
style={{ <Form style={{ flex: '1 1 auto', minWidth: 0 }} layout='vertical'>
display: 'flex', <Form.Item style={{ marginBottom: 0 }} label='Документ'>
gap: 12, <Select<string, DocumentOption>
alignItems: 'flex-end', style={{ width: '100%' }}
maxWidth: '100%', value={doc.id}
width: '100%', options={documentOptions}
}}> onChange={(value) => navigate(`/documents/${value}`)}
<Form style={{ flex: '1 1 auto', minWidth: 0, overflow: 'hidden' }} layout='vertical'> optionRender={(option) => (
<Form.Item style={{ marginBottom: 0, width: '100%' }} label='Документ'> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
{document_id && ( {/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */}
<Select <Tooltip title={option.data.label}>
style={{ width: '100%' }} <div style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
onChange={(e, option) => { <div>{option.data.label}</div>
if (option.documentId === null) { <div style={{ fontSize: 11, color: '#667085' }}>{option.data.row.subtitle}</div>
message.error('Нельзя перейти к просмотру файла');
} else {
setSearchParams((old) => {
old.set('document_id', option.documentId);
return old;
});
}
}}
defaultValue={document_id}
options={filesOptions}
optionRender={(d) => {
const currentBadge = optionStatusBadge(d.data.status);
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 10,
}}>
<div
style={{
overflowX: 'hidden',
}}>
<Tooltip title={d.data.name}>{d.data.name}</Tooltip>
</div>
<div style={{ flex: '1 1 auto' }}>
<TableTag {...currentBadge} />
</div>
</div> </div>
); </Tooltip>
}}></Select> <TableTag {...optionStatusBadge(option.data.row.status)} />
)} </div>
)}
/>
</Form.Item> </Form.Item>
</Form> </Form>
<div style={{ flex: '0 0 auto' }}> <TableTag text={badge.label} {...TAG_PRESETS[badge.color]} />
<TableTag text={badge.label} {...TAG_PRESETS[badge.color]} />
</div>
</div> </div>
} }
/> />
{true && ( <div style={{ alignSelf: 'flex-end' }}>
<div style={{ alignSelf: 'flex-end' }}> <Button icon={<CommentOutlined />} onClick={() => setCommentOpen(true)}>
<Button Добавить комментарий
onClick={() => { </Button>
setCommentOpen(true); </div>
}}>
<CommentOutlined />
Добавить комментарий
</Button>
</div>
)}
<main <main
style={{ style={{
flex: '1 1 auto', flex: '1 1 auto',
@ -171,13 +133,7 @@ export function DocumentPage() {
{!broken && kind === 'statement' && <StatementReview key={doc.id} document={doc} onChanged={load} />} {!broken && kind === 'statement' && <StatementReview key={doc.id} document={doc} onChanged={load} />}
{kind === 'none' && <Empty description='Документ без данных для проверки' />} {kind === 'none' && <Empty description='Документ без данных для проверки' />}
</main> </main>
<CommentModal <CommentModal open={commentOpen} documentId={doc.id} onClose={() => setCommentOpen(false)} />
open={commentOpen}
document_id={document_id}
onClose={() => {
setCommentOpen(false);
}}
/>
</div> </div>
); );
} }

View File

@ -2,12 +2,12 @@ import { api, apiFetchBlob } from '../../api/client';
import type { ConsolidationCreate } from '../../types/api'; import type { ConsolidationCreate } from '../../types/api';
import type { LockOut } from '../../types/consolidations/common_types'; import type { LockOut } from '../../types/consolidations/common_types';
import type { IConsolidation } from '../../types/consolidations/consolidations'; import type { IConsolidation } from '../../types/consolidations/consolidations';
import type { VersionCard } from '../../types/consolidations/version_members'; import type { ChecklistBackendShape, VersionCard } from '../../types/consolidations/version_members';
export const getConsolidations = (groupId: string) => api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations`); 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 getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`);
export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body); export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body);
export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`); export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`);
export const getChecklist = (versionId: string) => api.get(`/consolidations/versions/${versionId}/checklist`); export const getChecklist = (versionId: string) => api.get<ChecklistBackendShape>(`/consolidations/versions/${versionId}/checklist`);
/** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */ /** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */
export const lockVersion = (versionId: string) => api.post<LockOut>(`/consolidations/versions/${versionId}/lock`); export const lockVersion = (versionId: string) => api.post<LockOut>(`/consolidations/versions/${versionId}/lock`);

View File

@ -22,7 +22,5 @@ export async function getAllEntries(id: string): Promise<EntryOut[]> {
} }
} }
export function postComment(document_id: string, comment: string) { /** Комментарий к документу: текст 1–4000 символов после обрезки пробелов (иначе 422). */
const body = { text: comment }; export const postComment = (documentId: string, text: string) => api.post(`/documents/${documentId}/comments`, { text });
return api.post(`/documents/${document_id}/comments/`, body);
}

View File

@ -1,4 +1,4 @@
import { api, ApiError, apiFetch, apiFetchBlob } from '../../api/client'; import { ApiError, api, apiFetch, apiFetchBlob } from '../../api/client';
import type { FileOut, Page, UploadResult } from '../../types/api'; import type { FileOut, Page, UploadResult } from '../../types/api';
export function isUploadResult(body: unknown): body is UploadResult { export function isUploadResult(body: unknown): body is UploadResult {

View File

@ -6,7 +6,6 @@ const initialState: ConsolidationsSliceType = {
consolidations: [], consolidations: [],
current_version_id: null, current_version_id: null,
current_legal_entity_id: null, current_legal_entity_id: null,
current_legal_entity_name: null,
loading: false, loading: false,
error: null, error: null,
}; };
@ -18,12 +17,9 @@ const consolidationsSlice = createSlice({
setSelectedVersion: (state, action: PayloadAction<string | null>) => { setSelectedVersion: (state, action: PayloadAction<string | null>) => {
state.current_version_id = action.payload; state.current_version_id = action.payload;
}, },
setSelectedLegalEntityId: (state, action: PayloadAction<string | null>) => { setSelectedLegalEntity: (state, action: PayloadAction<string | null>) => {
state.current_legal_entity_id = action.payload; state.current_legal_entity_id = action.payload;
}, },
setSelectedLegalEntityName: (state, action: PayloadAction<string | null>) => {
state.current_legal_entity_name = action.payload;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder builder
@ -43,4 +39,4 @@ const consolidationsSlice = createSlice({
}); });
export default consolidationsSlice.reducer; export default consolidationsSlice.reducer;
export const { setSelectedVersion, setSelectedLegalEntityId, setSelectedLegalEntityName } = consolidationsSlice.actions; export const { setSelectedVersion, setSelectedLegalEntity } = consolidationsSlice.actions;

View File

@ -6,5 +6,4 @@ export type ConsolidationsSliceType = {
current_version_id: string | null; current_version_id: string | null;
error: string | null; error: string | null;
current_legal_entity_id: string | null; current_legal_entity_id: string | null;
current_legal_entity_name: string | null;
}; };

View File

@ -44,7 +44,7 @@ type ChecklistRow = {
type ChecklistRule = { type ChecklistRule = {
document_type: DocumentType; document_type: DocumentType;
account: string; account: string | null;
is_mandatory: boolean; is_mandatory: boolean;
}; };

View File

@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { getChecklist } from '../../services/api/consolidations';
import { message, Modal, Table, TableColumnsType } from 'antd';
import { ChecklistBackendShape, type IVersionCard } from '../../types/consolidations/version_members';
import { DownloadOutlined } from '@ant-design/icons'; import { DownloadOutlined } from '@ant-design/icons';
import { buildChecklistColumns, getMarksItems } from './checklist_utils'; import { Modal, Table, message } from 'antd';
// import { useMe } from '../../auth/useMe'; import { useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
import { getChecklist } from '../../services/api/consolidations';
import type { ChecklistBackendShape, IVersionCard } from '../../types/consolidations/version_members';
import { buildChecklistColumns, buildChecklistRows, getMarksItems } from './checklist_utils';
type ChecklistProps = { type ChecklistProps = {
open: boolean; open: boolean;
@ -16,48 +16,16 @@ type ChecklistProps = {
export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) { export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) {
const [checklistData, setChecklistData] = useState<ChecklistBackendShape | null>(null); const [checklistData, setChecklistData] = useState<ChecklistBackendShape | null>(null);
const displayedData = useMemo(() => { const rows = useMemo(() => (checklistData ? buildChecklistRows(checklistData) : []), [checklistData]);
if (!checklistData) return; const columns = useMemo(() => (checklistData ? buildChecklistColumns(checklistData) : []), [checklistData]);
const result = [];
for (const row of checklistData.rows) {
const rowObj = {
name: row.legal_entity_name,
inn: row.legal_entity_inn,
ofr: 'MISSING',
balance: 'MISSING',
};
for (const cell of row.cells) {
if (cell.document_type === 'OSV') {
const code = cell.account;
const key = `osv_${code}`;
rowObj[key] = cell.status;
} else if (cell.document_type === 'BALANCE') {
rowObj.balance = cell.status;
} else if (cell.document_type === 'OFR') {
rowObj.ofr = cell.status;
}
}
result.push(rowObj);
}
return result;
}, [checklistData]);
const columns: TableColumnsType = useMemo(() => {
if (!checklistData) return [];
return buildChecklistColumns(checklistData);
}, [checklistData]);
useEffect(() => { useEffect(() => {
async function fetchChecklist() { if (!consolidationData.id || !open) return;
if (!consolidationData.id || !open) return; getChecklist(consolidationData.id)
try { .then(setChecklistData)
const res = await getChecklist(consolidationData.id); .catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить чек-лист'));
setChecklistData(res);
} catch (e) {
message.error(e.message);
}
}
fetchChecklist();
}, [open, consolidationData.id]); }, [open, consolidationData.id]);
return ( return (
<Modal <Modal
width='100%' width='100%'
@ -74,7 +42,7 @@ export default function ChecklistModal({ open, onDownload, consolidationData, on
okButtonProps={{ icon: <DownloadOutlined /> }}> okButtonProps={{ icon: <DownloadOutlined /> }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{getMarksItems()} {getMarksItems()}
<Table columns={columns} dataSource={displayedData} pagination={false} /> <Table rowKey='key' columns={columns} dataSource={rows} pagination={false} />
</div> </div>
</Modal> </Modal>
); );

View File

@ -6,18 +6,14 @@ import { ColumnSettings, DataTable, useFilteredRows, useVisibleColumns } from '.
import { useTableFilters } from '../../components/GenericTable/useTableFilters'; import { useTableFilters } from '../../components/GenericTable/useTableFilters';
import { downloadChecklistCsv } from '../../services/api/consolidations'; import { downloadChecklistCsv } from '../../services/api/consolidations';
import { getVersionCard } from '../../services/api/consolidations'; import { getVersionCard } from '../../services/api/consolidations';
import { import { setSelectedLegalEntity, setSelectedVersion } from '../../store/slices/consolidations/consolidationsSlice';
setSelectedLegalEntityId,
setSelectedLegalEntityName,
setSelectedVersion,
} from '../../store/slices/consolidations/consolidationsSlice';
import { useAppDispatch, useAppSelector } from '../../store/store'; import { useAppDispatch, useAppSelector } from '../../store/store';
import type { IVersionCard, MemberRow } from '../../types/consolidations/version_members'; import type { IVersionCard, MemberRow } from '../../types/consolidations/version_members';
import { saveBlob } from '../../utils/download'; import { saveBlob } from '../../utils/download';
import ChecklistModal from './ChecklistModal';
import { membersConfig } from './config'; import { membersConfig } from './config';
import { normalizeData } from './normalize'; import { normalizeData } from './normalize';
import { checklistFilename, responsibleName } from './panelInfo'; import { checklistFilename, responsibleName } from './panelInfo';
import ChecklistModal from './ChecklistModal';
export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawerOpen: () => void; reloadKey: number }) { export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawerOpen: () => void; reloadKey: number }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -64,8 +60,7 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
const onRow = (row: MemberRow) => ({ const onRow = (row: MemberRow) => ({
onClick: () => { onClick: () => {
dispatch(setSelectedVersion(row.version_id)); dispatch(setSelectedVersion(row.version_id));
dispatch(setSelectedLegalEntityId(row.id)); dispatch(setSelectedLegalEntity(row.id));
dispatch(setSelectedLegalEntityName(row.name));
onDrawerOpen(); onDrawerOpen();
}, },
}); });

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import type { ChecklistBackendShape } from '../../types/consolidations/version_members';
import { buildChecklistRows } from './checklist_utils';
const checklist: ChecklistBackendShape = {
rules: [],
rows: [
{
legal_entity_id: 'le1',
legal_entity_name: 'РУССКИЙ БИСКВИТ, АО',
legal_entity_inn: '3528052718',
cells: [
{ document_type: 'BALANCE', account: null, status: 'NEEDS_CHECK', missing_quarters: [], document_ids: ['d1'] },
{ document_type: 'INCOME', account: null, status: 'CONFIRMED', missing_quarters: [], document_ids: ['d2'] },
{ document_type: 'OSV', account: '60', status: 'ERROR', missing_quarters: [], document_ids: ['d3'] },
],
},
],
};
describe('buildChecklistRows', () => {
it('ОФР — ячейка INCOME, ББ — BALANCE, ОСВ — по счёту; ключ строки — ЮЛ', () => {
expect(buildChecklistRows(checklist)).toEqual([
{
key: 'le1',
name: 'РУССКИЙ БИСКВИТ, АО',
inn: '3528052718',
balance: 'NEEDS_CHECK',
ofr: 'CONFIRMED',
osv: { '60': 'ERROR' },
},
]);
});
it('нет ячеек ББ/ОФР — «Отсутствует»', () => {
const rows = buildChecklistRows({ rules: [], rows: [{ ...checklist.rows[0], cells: [] }] });
expect(rows[0]).toMatchObject({ balance: 'MISSING', ofr: 'MISSING', osv: {} });
});
});

View File

@ -6,34 +6,47 @@ import {
EyeOutlined, EyeOutlined,
MinusCircleOutlined, MinusCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { TAG_PRESETS } from '../../components/TableTag/presets'; import { Avatar, type TableColumnsType } from 'antd';
import { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members'; import type { ReactNode } from 'react';
import { Avatar, TableColumnsType } from 'antd'; import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets';
import type { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members';
import { statusMapping } from './consts'; import { statusMapping } from './consts';
export const mapStatus = (status: FileStatus) => { /** Строка таблицы чек-листа: ЮЛ и статусы ББ, ОФР и ОСВ по счетам. */
const presetMapping = { export type ChecklistTableRow = {
MISSING: TAG_PRESETS['default'], key: string;
CONFIRMED: TAG_PRESETS['green'], name: string;
NEEDS_CHECK: TAG_PRESETS['orange'], inn: string;
RECOGNIZED: TAG_PRESETS['gold'], balance: FileStatus;
ERROR: TAG_PRESETS['red'], ofr: FileStatus;
PROCESSING: TAG_PRESETS['geekblue'], osv: Record<string, FileStatus>;
}; };
const preset = presetMapping[status];
const iconMapping = { const presetMapping: Record<FileStatus, Preset> = {
MISSING: <MinusCircleOutlined />, MISSING: TAG_PRESETS.default,
NEEDS_CHECK: <EyeOutlined />, CONFIRMED: TAG_PRESETS.green,
ERROR: <ExclamationCircleOutlined />, NEEDS_CHECK: TAG_PRESETS.orange,
CONFIRMED: <CheckCircleOutlined />, RECOGNIZED: TAG_PRESETS.gold,
RECOGNIZED: <EllipsisOutlined />, ERROR: TAG_PRESETS.red,
PROCESSING: <ClockCircleOutlined />, PROCESSING: TAG_PRESETS.geekblue,
}; };
const iconMapping: Record<FileStatus, ReactNode> = {
MISSING: <MinusCircleOutlined />,
NEEDS_CHECK: <EyeOutlined />,
ERROR: <ExclamationCircleOutlined />,
CONFIRMED: <CheckCircleOutlined />,
RECOGNIZED: <EllipsisOutlined />,
PROCESSING: <ClockCircleOutlined />,
};
export const mapStatus = (status: FileStatus) => {
const preset = presetMapping[status];
return ( return (
<Avatar style={{ color: preset.color, backgroundColor: preset.bgColor, borderColor: preset.borderColor }} icon={iconMapping[status]} /> <Avatar style={{ color: preset.color, backgroundColor: preset.bgColor, borderColor: preset.borderColor }} icon={iconMapping[status]} />
); );
}; };
export function getMarksItems() { export function getMarksItems() {
return ( return (
<div <div
@ -47,48 +60,68 @@ export function getMarksItems() {
fontSize: 11, fontSize: 11,
}}> }}>
<b>Условные обозначения</b> <b>Условные обозначения</b>
{Object.entries(statusMapping).map((item) => { {(Object.entries(statusMapping) as [FileStatus, string][]).map(([status, label]) => (
const avatar = mapStatus(item[0] as FileStatus); <div key={status}>
return ( {mapStatus(status)} <span style={{ color: '#667085' }}>{label}</span>
<div key={item[0]}> </div>
{avatar} <span style={{ color: '#667085' }}>{item[1]}</span> ))}
</div>
);
})}
</div> </div>
); );
} }
export function buildChecklistColumns(checklistData: ChecklistBackendShape) {
const osv_children = []; /** Строки таблицы из ответа `GET …/checklist`: ОФР — ячейка `INCOME`; нет ячейки ББ/ОФР — «Отсутствует». */
const osv_codes = new Set(); export function buildChecklistRows(checklistData: ChecklistBackendShape): ChecklistTableRow[] {
return checklistData.rows.map((row) => {
const result: ChecklistTableRow = {
key: row.legal_entity_id,
name: row.legal_entity_name,
inn: row.legal_entity_inn,
balance: 'MISSING',
ofr: 'MISSING',
osv: {},
};
for (const cell of row.cells) {
if (cell.document_type === 'OSV' && cell.account) {
result.osv[cell.account] = cell.status;
} else if (cell.document_type === 'BALANCE') {
result.balance = cell.status;
} else if (cell.document_type === 'INCOME') {
result.ofr = cell.status;
}
}
return result;
});
}
export function buildChecklistColumns(checklistData: ChecklistBackendShape): TableColumnsType<ChecklistTableRow> {
const osvCodes: string[] = [];
for (const row of checklistData.rows) { for (const row of checklistData.rows) {
for (const cell of row.cells) { for (const cell of row.cells) {
if (cell.document_type === 'OSV') { if (cell.document_type === 'OSV' && cell.account && !osvCodes.includes(cell.account)) {
const code = cell.account; osvCodes.push(cell.account);
if (!osv_codes.has(code)) {
osv_children.push({ key: `osv_${code}`, dataIndex: `osv_${code}`, title: code, render: (v) => mapStatus(v) });
osv_codes.add(code);
}
} }
} }
} }
const result: TableColumnsType = [ return [
{ {
key: 'name', key: 'name',
dataIndex: 'leName',
title: 'ОРГАНИЗАЦИЯ', title: 'ОРГАНИЗАЦИЯ',
render: (v, r) => ( render: (_, r) => (
<div> <h5>
<h5> {r.name}: {r.inn}
{r.name}: {r.inn} </h5>
</h5>
</div>
), ),
}, },
{ key: 'balance', dataIndex: 'balance', title: 'БУХ.БАЛАНС', render: (v) => mapStatus(v) }, { key: 'balance', dataIndex: 'balance', title: 'БУХ.БАЛАНС', render: (v: FileStatus) => mapStatus(v) },
{ key: 'ofr', dataIndex: 'ofr', title: 'ОФР', render: (v) => mapStatus(v) }, { key: 'ofr', dataIndex: 'ofr', title: 'ОФР', render: (v: FileStatus) => mapStatus(v) },
{ title: 'ОСВ', children: osv_children, render: (v) => mapStatus(v) }, {
key: 'osv',
title: 'ОСВ',
children: osvCodes.map((code) => ({
key: `osv_${code}`,
title: code,
render: (_: unknown, r: ChecklistTableRow) => mapStatus(r.osv[code] ?? 'MISSING'),
})),
},
]; ];
return result;
} }

View File

@ -1,41 +1,50 @@
import { Form, Input, message, Modal } from 'antd'; import { Form, Input, Modal, message } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import { ApiError } from '../../api/client';
import { postComment } from '../../services/api/documents'; import { postComment } from '../../services/api/documents';
export default function CommentModal({ type CommentModalProps = { documentId: string; open: boolean; onClose: () => void };
document_id, // () => void
open, export default function CommentModal({ documentId, open, onClose }: CommentModalProps) {
onClose,
}) {
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [isLoading, setIsloading] = useState(false); const [saving, setSaving] = useState(false);
const close = () => {
setComment('');
onClose();
};
const handleOk = async () => { const handleOk = async () => {
setIsloading(true); setSaving(true);
try { try {
const res = await postComment(document_id, comment); await postComment(documentId, comment.trim());
} catch (e) { message.success('Комментарий добавлен');
message.error(e.message); close();
} catch (e: unknown) {
// окно не закрываем: текст не теряется, можно повторить
message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий');
} finally { } finally {
setIsloading(false); setSaving(false);
onClose();
} }
}; };
return ( return (
<Modal <Modal
title={<div style={{ height: '50px' }}>Добавить комментарий</div>} title='Добавить комментарий'
okText='Добавить' okText='Добавить'
onOk={handleOk} onOk={handleOk}
okButtonProps={{ disabled: comment.length === 0 }} okButtonProps={{ disabled: comment.trim().length === 0 }}
confirmLoading={saving}
open={open} open={open}
cancelText='Отмена' cancelText='Отмена'
onCancel={onClose} onCancel={close}
destroyOnHidden> destroyOnHidden>
<Form layout='vertical'> <Form layout='vertical'>
<Form.Item label={<span style={{ fontWeight: 600 }}>Комментарий</span>}> <Form.Item label={<span style={{ fontWeight: 600 }}>Комментарий</span>}>
<Input <Input.TextArea
value={comment} value={comment}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setComment(e.target.value)}
placeholder='Опишите результат проверки или внесенные правки'></Input> autoSize={{ minRows: 3 }}
maxLength={4000}
placeholder='Опишите результат проверки или внесенные правки'
/>
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>

View File

@ -20,6 +20,7 @@ import { switchesFor } from './focusTarget';
import { detailCount, filterLayout, formLayout } from './formLayout'; import { detailCount, filterLayout, formLayout } from './formLayout';
import { issueListItems, issuePlaces } from './issuePlaces'; import { issueListItems, issuePlaces } from './issuePlaces';
import { applyLineMenu, showsCodeInput } from './lineMenu'; import { applyLineMenu, showsCodeInput } from './lineMenu';
import { countReviewValues } from './reviewCounters';
import { reviewActions } from './reviewView'; import { reviewActions } from './reviewView';
import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft'; import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft';
import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid'; import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid';
@ -68,6 +69,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
}, [load]); }, [load]);
const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]); const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]);
// счётчики над таблицей — только пока форма на проверке
const counters = useMemo(
() => (statement && statement.document.status === 'NEEDS_CHECK' ? countReviewValues(statement) : null),
[statement],
);
// пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы) // пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы)
const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts, draft.lines) : new Set<string>()), [grid, draft]); const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts, draft.lines) : new Set<string>()), [grid, draft]);
const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]); const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]);
@ -295,9 +301,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut
</span> </span>
)} )}
{serverErrorOffScreen && <Alert type='error' showIcon title={lineServerError.text} />} {serverErrorOffScreen && <Alert type='error' showIcon title={lineServerError.text} />}
{grid.changedValues !== null && ( {counters && (
<p style={{ color: 'red' }}> <p style={{ color: 'red' }}>
Измененных значений: {grid.changedValues}. Значений, требующих проверки: {grid.unrecognizedValues} Изменённых значений: {counters.changed}. Значений, требующих проверки: {counters.needsReview}
</p> </p>
)} )}
<StatementTable <StatementTable

View File

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import type { HeaderFieldOut, StatementOut, ValueOut } from '../../types/api';
import { countReviewValues } from './reviewCounters';
const v = (extra: Partial<ValueOut> = {}): ValueOut => ({
id: 'v',
column_id: 'c1',
column_index: 0,
amount: '1.00',
string_value: null,
text: null,
confidence: '100',
is_recognized: true,
is_modified: false,
original_amount: null,
needs_review: false,
is_confirmed: false,
...extra,
});
const field = (extra: Partial<HeaderFieldOut> = {}): HeaderFieldOut => ({
value: 'x',
original_value: 'x',
verified: null,
modified: null,
...extra,
});
const statement = (values: ValueOut[], header: Partial<StatementOut['header']> = {}) =>
({
header: {
inn: field(),
report_date: field(),
units: field(),
organization_name_modified: false,
needs_review_fields: [],
...header,
},
lines: [{ id: 'l1', values }],
}) as unknown as StatementOut;
describe('countReviewValues', () => {
it('чистая форма — нули', () => {
expect(countReviewValues(statement([v(), v()]))).toEqual({ changed: 0, needsReview: 0 });
});
it('на проверку — needs_review без подтверждения и без правки (как подсветка ячейки)', () => {
const values = [
v({ needs_review: true }),
v({ needs_review: true, is_confirmed: true }),
v({ needs_review: true, is_modified: true }),
// достроенная сервером ячейка: is_recognized = null, на проверку не считается
v({ id: null, is_recognized: null, amount: null }),
];
expect(countReviewValues(statement(values))).toEqual({ changed: 1, needsReview: 1 });
});
it('шапка: правки полей и наименования, поля из needs_review_fields без подтверждения', () => {
const s = statement([], {
inn: field({ modified: true }),
report_date: field({ verified: true }),
organization_name_modified: true,
needs_review_fields: ['report_date', 'units'],
});
expect(countReviewValues(s)).toEqual({ changed: 2, needsReview: 1 });
});
});

View File

@ -0,0 +1,31 @@
import type { HeaderFieldOut, StatementOut } from '../../types/api';
const HEADER_FIELDS = ['inn', 'report_date', 'units'] as const;
/**
* Счётчики над таблицей формы: «изменённых» — правки аналитика (ячейки, поля шапки, наименование организации);
* «требующих проверки» — то же, что подсвечено на экране: ячейка с `needs_review` и поле из `needs_review_fields`
* (решает ядро), ещё не подтверждённые и не исправленные. Достроенные сервером пустые ячейки не считаются.
*/
export function countReviewValues(statement: StatementOut): { changed: number; needsReview: number } {
let changed = 0;
let needsReview = 0;
for (const line of statement.lines) {
for (const c of line.values) {
if (c.is_modified) changed++;
else if (c.needs_review && !c.is_confirmed) needsReview++;
}
}
const header = statement.header;
for (const key of HEADER_FIELDS) {
if (header[key].modified) changed++;
}
if (header.organization_name_modified) changed++;
for (const name of header.needs_review_fields ?? []) {
const field: HeaderFieldOut | undefined = (HEADER_FIELDS as readonly string[]).includes(name)
? header[name as (typeof HEADER_FIELDS)[number]]
: undefined;
if (!field?.modified && !field?.verified) needsReview++;
}
return { changed, needsReview };
}

View File

@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { columnTitle, documentPageTitle, documentSubtitle, formTitle, formatReportDate, reviewActions, statusBadge } from './reviewView'; import {
columnTitle,
documentPageTitle,
documentSubtitle,
formTitle,
formatReportDate,
optionStatusBadge,
reviewActions,
statusBadge,
} from './reviewView';
describe('formatReportDate', () => { describe('formatReportDate', () => {
it('ISO → дд.мм.гггг', () => { it('ISO → дд.мм.гггг', () => {
@ -69,3 +78,12 @@ describe('columnTitle', () => {
expect(columnTitle(col(null, null))).toBe('Колонка 2'); expect(columnTitle(col(null, null))).toBe('Колонка 2');
}); });
}); });
describe('optionStatusBadge', () => {
it('текст значка по статусу строки документа', () => {
expect(optionStatusBadge('success').text).toBe('Загружен');
expect(optionStatusBadge('warning').text).toBe('Требуется проверка');
expect(optionStatusBadge('processing').text).toBe('Обработка');
expect(optionStatusBadge('error').text).toBe('Ошибка');
});
});

View File

@ -1,8 +1,8 @@
import { Preset, TAG_PRESETS } from '../../components/TableTag/presets'; import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets';
import type { ColumnOut, DocumentStatus, DocumentType, FileOut } from '../../types/api'; import type { ColumnOut, DocumentStatus, DocumentType, FileOut } from '../../types/api';
import { formatMoscowDate } from '../../utils/dates'; import { formatMoscowDate } from '../../utils/dates';
import { documentStatusLabel } from '../../utils/labels'; import { documentStatusLabel } from '../../utils/labels';
import { RowStatus } from '../FilesDrawer/documentRows'; import type { RowStatus } from '../FilesDrawer/documentRows';
/** '2025-09-30' (или ISO с временем) → '30.09.2025'; пусто → null; нераспознанное — как есть. */ /** '2025-09-30' (или ISO с временем) → '30.09.2025'; пусто → null; нераспознанное — как есть. */
export function formatReportDate(value: string | null | undefined): string | null { export function formatReportDate(value: string | null | undefined): string | null {
@ -34,17 +34,16 @@ export function statusBadge(status: DocumentStatus): { label: string; color: 'gr
} }
return { label, color: status === 'ERROR' ? 'red' : 'orange' }; return { label, color: status === 'ERROR' ? 'red' : 'orange' };
} }
const OPTION_BADGES: Record<RowStatus, { text: string } & Preset> = {
success: { text: 'Загружен', ...TAG_PRESETS.green },
warning: { text: 'Требуется проверка', ...TAG_PRESETS.orange },
processing: { text: 'Обработка', ...TAG_PRESETS.geekblue },
error: { text: 'Ошибка', ...TAG_PRESETS.red },
};
/** Значок статуса документа в переключателе документов на экране проверки. */
export function optionStatusBadge(status: RowStatus): { text: string } & Preset { export function optionStatusBadge(status: RowStatus): { text: string } & Preset {
if (status === 'success') { return OPTION_BADGES[status];
const preset = TAG_PRESETS.green;
const text = 'Загружен';
return { text, ...preset };
} else if (status === 'warning') {
return { text: 'Требуется проверка', ...TAG_PRESETS.orange };
} else if (status === 'processing') {
return { text: 'Обработка', ...TAG_PRESETS.geekblue };
}
return { text: 'Ошибка', ...TAG_PRESETS.red };
} }
export function documentSubtitle(file: Pick<FileOut, 'original_name' | 'file_type' | 'uploaded_at'>): string { export function documentSubtitle(file: Pick<FileOut, 'original_name' | 'file_type' | 'uploaded_at'>): string {

View File

@ -28,39 +28,7 @@ export type GridRow = {
/** Ключ строки: id из БД, у достроенной строки (id = null) — код; двоеточия в ключе нет (разделитель cellKey). */ /** Ключ строки: id из БД, у достроенной строки (id = null) — код; двоеточия в ключе нет (разделитель cellKey). */
const rowKey = (id: string | null, code: string | null) => id ?? `code-${code ?? ''}`; const rowKey = (id: string | null, code: string | null) => id ?? `code-${code ?? ''}`;
export function buildStatementGrid(statement: StatementOut): { export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } {
columns: ColumnOut[];
rows: GridRow[];
changedValues: number | null;
unrecognizedValues: number | null;
} {
let changedValues: number | null = 0;
let unrecognizedValues: number | null = 0;
if (statement.document.status === 'NEEDS_CHECK') {
for (const key of ['inn', 'report_date', 'units']) {
const val = statement.header[key];
if (!val['verified'] && !val['modified']) {
unrecognizedValues++;
}
if (val['modified']) {
changedValues++;
}
}
for (const l of statement.lines) {
for (const c of l.values) {
if (!c.is_recognized && !c.is_modified) {
unrecognizedValues++;
}
if (c.is_modified) {
changedValues++;
}
}
}
} else {
unrecognizedValues = null;
changedValues = null;
}
const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index); const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index);
const rows = statement.lines.map((line) => { const rows = statement.lines.map((line) => {
const cells: Record<string, ValueOut | undefined> = {}; const cells: Record<string, ValueOut | undefined> = {};
@ -86,7 +54,7 @@ export function buildStatementGrid(statement: StatementOut): {
cells, cells,
}; };
}); });
return { columns, rows, changedValues, unrecognizedValues }; return { columns, rows };
} }
/** /**

View File

@ -1,10 +1,12 @@
import React, { useCallback, useState } from 'react'; import { Button, Modal, Tooltip, Typography, message, theme } from 'antd';
import { Modal, Button, Typography, theme, message } from 'antd'; import { type ReactNode, useState } from 'react';
import { ApiError } from '../../api/client';
import { removeFile } from '../../services/api/files'; import { removeFile } from '../../services/api/files';
import type { DocumentRow } from './documentRows';
const { Text } = Typography; const { Text } = Typography;
const OptionCard = ({ title, detail, action }) => { const OptionCard = ({ title, detail, action }: { title: string; detail: string; action: ReactNode }) => {
const { token } = theme.useToken(); const { token } = theme.useToken();
return ( return (
<div <div
@ -26,26 +28,33 @@ const OptionCard = ({ title, detail, action }) => {
); );
}; };
export default function DeleteReasonModal({ type DeleteReasonModalProps = {
targetRow, targetRow: DocumentRow;
open, open: boolean;
onCancel, onCancel: () => void;
onDelete, // () => void /** файл удалён — обновить список */
onReplace, // () => void onDelete: () => void;
}) { onReplace: () => void;
/** замена = загрузка нового файла: только в версии в работе, как кнопка «Загрузить документы» */
canReplace: boolean;
};
/** Удаление или замена — файлом целиком (`DELETE /files/{file_id}`): уходят все документы файла. */
export default function DeleteReasonModal({ targetRow, open, onCancel, onDelete, onReplace, canReplace }: DeleteReasonModalProps) {
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = useCallback(async () => { const handleDelete = async () => {
setIsDeleting(true); setIsDeleting(true);
try { try {
await removeFile(targetRow.key); await removeFile(targetRow.fileId);
message.success('Файл удалён'); message.success('Файл удалён');
} catch (e) { onDelete();
message.error(e.message); } catch (e: unknown) {
// 409 — например, в файле подтверждённый документ; сервер объясняет причину
message.error(e instanceof ApiError ? e.message : 'Не удалось удалить файл');
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
onDelete();
} }
}, [targetRow.key, onDelete]); };
return ( return (
<Modal title='Удаление документа' open={open} onCancel={onCancel} footer={null} destroyOnHidden> <Modal title='Удаление документа' open={open} onCancel={onCancel} footer={null} destroyOnHidden>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@ -66,9 +75,11 @@ export default function DeleteReasonModal({
title='Замена документа' title='Замена документа'
detail='Загрузите актуальный файл и укажите причину замены.' detail='Загрузите актуальный файл и укажите причину замены.'
action={ action={
<Button type='primary' onClick={onReplace}> <Tooltip title={canReplace ? undefined : 'Замена доступна только в версии в работе'}>
Заменить <Button type='primary' disabled={!canReplace} onClick={onReplace}>
</Button> Заменить
</Button>
</Tooltip>
} }
/> />
</div> </div>

View File

@ -1,17 +1,20 @@
import { CloseOutlined, UploadOutlined } from '@ant-design/icons'; import { CloseOutlined, UploadOutlined } from '@ant-design/icons';
import { Button, Drawer, message, Switch, Tooltip, Typography } from 'antd'; import { Button, Drawer, Switch, Tooltip, Typography, message } from 'antd';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client';
import { fetchLegalEntity } from '../../services/api/directories/company_groups'; import { fetchLegalEntity } from '../../services/api/directories/company_groups';
import { postComment } from '../../services/api/documents';
import { summarizeUpload, uploadFiles } from '../../services/api/files';
import { isInWork, useCurrentConsolidation } from '../../store/slices/consolidations/selectors'; import { isInWork, useCurrentConsolidation } from '../../store/slices/consolidations/selectors';
import { useAppSelector } from '../../store/store'; import { useAppSelector } from '../../store/store';
import type { FileOut, LegalEntityBrief } from '../../types/api'; import type { FileOut, LegalEntityBrief } from '../../types/api';
import DeleteReasonModal from './DeleteModal';
import { FilesList } from './FilesList'; import { FilesList } from './FilesList';
import ReplaceDocModal from './ReplaceDocModal';
import { UploadDocumentsModal } from './UploadDocumentsModal'; import { UploadDocumentsModal } from './UploadDocumentsModal';
import { consolidationLabel } from './consolidationLabel'; import { consolidationLabel } from './consolidationLabel';
import type { DocumentRow } from './documentRows';
import { useLegalEntityFiles } from './useLegalEntityFiles'; import { useLegalEntityFiles } from './useLegalEntityFiles';
import { DocumentRow } from './documentRows';
import DeleteReasonModal from './DeleteModal';
import ReplaceDocModal from './ReplaceDocModal';
const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе'; const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе';
@ -65,14 +68,27 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
setTargetRow(row); setTargetRow(row);
setRemovalOpen(true); setRemovalOpen(true);
}; };
const handleReplaceSubmit = ({ file, comment }) => { // Замена = загрузка нового файла в ту же консолидацию: прежний ядро переводит в SUPERSEDED после разбора,
// если совпали тип, счёт и период (05-domain-rules §9.5 п. 3). Причина — комментарием к заменяемому документу.
const handleReplaceSubmit = async (file: File, reason: string) => {
if (!current_legal_entity_id || !targetRow) return;
setReplacing(true);
try { try {
setReplacing(true); const result = summarizeUpload(await uploadFiles(current_legal_entity_id, [file], current?.consolidation_id ?? null));
message.success('Документ заменен'); if (result.accepted.length === 0) {
message.error(result.rejected[0]?.reason ?? 'Файл не принят');
return;
}
if (targetRow.documentId) {
await postComment(targetRow.documentId, `Замена документа: ${reason}`).catch(() =>
message.warning('Файл загружен, но причину замены сохранить не удалось'),
);
}
message.success('Файл загружен: прежний будет заменён после разбора, если совпадут тип, счёт и период');
setReplaceOpen(false); setReplaceOpen(false);
reload(); void reload();
} catch (e) { } catch (e: unknown) {
message.error(e.message); message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить файл');
} finally { } finally {
setReplacing(false); setReplacing(false);
} }
@ -158,10 +174,12 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
onCancel={() => setRemovalOpen(false)} onCancel={() => setRemovalOpen(false)}
onDelete={onDelete} onDelete={onDelete}
onReplace={handleReplaceClick} onReplace={handleReplaceClick}
canReplace={canUpload}
/> />
<ReplaceDocModal <ReplaceDocModal
open={replaceOpen} open={replaceOpen}
docName={targetRow?.name} docName={targetRow.name}
organizationName={entity?.name ?? null}
submitting={replacing} submitting={replacing}
onCancel={() => setReplaceOpen(false)} onCancel={() => setReplaceOpen(false)}
onSubmit={handleReplaceSubmit} onSubmit={handleReplaceSubmit}

View File

@ -1,5 +1,5 @@
import { CheckOutlined, CloseOutlined, DeleteOutlined, FileExcelOutlined, FileImageOutlined, FilePdfOutlined } from '@ant-design/icons'; import { CheckOutlined, CloseOutlined, DeleteOutlined, FileExcelOutlined, FileImageOutlined, FilePdfOutlined } from '@ant-design/icons';
import { Button, message, Spin, Table, Tooltip, Typography } from 'antd'; import { Button, Spin, Table, Tooltip, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@ -140,6 +140,7 @@ export function FilesList({
render: (_, r) => ( render: (_, r) => (
<Button <Button
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
aria-label='Удалить файл'
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDeleteClick(r); onDeleteClick(r);
@ -167,13 +168,9 @@ export function FilesList({
locale={{ emptyText: 'Документов нет' }} locale={{ emptyText: 'Документов нет' }}
onRow={(r) => ({ onRow={(r) => ({
onClick: onClick:
r.linkable && r.documentId && r.legalEntityId && r.consolidationId r.linkable && r.documentId
? () => { ? () => navigate(`/documents/${r.documentId}`)
navigate(`/documents?consolidation_id=${r.consolidationId}&legal_entity_id=${r.legalEntityId}&document_id=${r.documentId}`); : () => message.warning('Просмотр данного документа недоступен'),
}
: () => {
message.warning('Просмотр данного документа недоступен');
},
style: r.linkable ? { cursor: 'pointer' } : undefined, style: r.linkable ? { cursor: 'pointer' } : undefined,
})} })}
/> />

View File

@ -1,42 +1,44 @@
import React, { useEffect, useState } from 'react'; import { FileAddOutlined, UploadOutlined } from '@ant-design/icons';
import { Modal, Input, Upload, Button } from 'antd'; import { Button, Input, Modal, Upload } from 'antd';
import { UploadOutlined, FileAddOutlined } from '@ant-design/icons'; import { useEffect, useState } from 'react';
import { useAppSelector } from '../../store/store';
const { Dragger } = Upload; const { Dragger } = Upload;
export default function ReplaceDocModal({ open, docName, submitting, onCancel, onSubmit }) { type ReplaceDocModalProps = {
const [comment, setComment] = useState(''); open: boolean;
const [fileList, setFileList] = useState([]); docName: string;
const leName = useAppSelector((s) => s.consolidations.current_legal_entity_name); organizationName: string | null;
submitting: boolean;
onCancel: () => void;
onSubmit: (file: File, reason: string) => void;
};
// reset form every time the modal is opened export default function ReplaceDocModal({ open, docName, organizationName, submitting, onCancel, onSubmit }: ReplaceDocModalProps) {
const [reason, setReason] = useState('');
const [file, setFile] = useState<File | null>(null);
// при каждом открытии — пустая форма
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setComment(''); setReason('');
setFileList([]); setFile(null);
} }
}, [open]); }, [open]);
const handleOk = () => {
const file = fileList[0]?.originFileObj ?? fileList[0];
onSubmit({ file, comment });
};
return ( return (
<Modal <Modal
title='Замена документа' title='Замена документа'
open={open} open={open}
onCancel={onCancel} onCancel={onCancel}
onOk={handleOk} onOk={() => file && onSubmit(file, reason.trim())}
okText='Загрузить' okText='Загрузить'
okButtonProps={{ okButtonProps={{
icon: <UploadOutlined />, // icon is rendered before the text icon: <UploadOutlined />,
disabled: fileList.length === 0 || comment.length === 0, disabled: file === null || reason.trim().length === 0,
}} }}
confirmLoading={submitting}> confirmLoading={submitting}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<span>{leName}</span> {organizationName && <span>{organizationName}</span>}
<div <div
style={{ style={{
backgroundColor: 'rgb(248, 250, 252)', backgroundColor: 'rgb(248, 250, 252)',
@ -48,24 +50,24 @@ export default function ReplaceDocModal({ open, docName, submitting, onCancel, o
Заменяемый документ: {docName} Заменяемый документ: {docName}
</div> </div>
<Input placeholder='Укажите причину замены' value={comment} onChange={(e) => setComment(e.target.value)} /> <Input placeholder='Укажите причину замены' value={reason} maxLength={4000} onChange={(e) => setReason(e.target.value)} />
<Dragger <Dragger
multiple={false} multiple={false}
style={{ borderStyle: 'dashed' }} style={{ borderStyle: 'dashed' }}
fileList={fileList} fileList={file ? [{ uid: 'replace', name: file.name, status: 'done' }] : []}
maxCount={1} maxCount={1}
accept='.xls,.xlsx,.xlsb,.pdf,.png,.jpg,.jpeg' accept='.xls,.xlsx,.xlsb,.pdf,.png,.jpg,.jpeg'
beforeUpload={(file) => { beforeUpload={(f) => {
setFileList([file]); setFile(f);
return false; return false;
}} }}
onRemove={() => setFileList([])}> onRemove={() => setFile(null)}>
<p className='ant-upload-drag-icon'> <p className='ant-upload-drag-icon'>
<FileAddOutlined /> <FileAddOutlined />
</p> </p>
<p className='ant-upload-text' style={{ color: '#667085' }}> <p className='ant-upload-text' style={{ color: '#667085' }}>
Перетащите файлы сюда Перетащите файл сюда
</p> </p>
<Button>Выбрать документ</Button> <Button>Выбрать документ</Button>
</Dragger> </Dragger>

View File

@ -145,6 +145,13 @@ describe('buildDocumentRows: предупреждения', () => {
expect(rows[0].issueCounts).toEqual({ error: 0, info: 0 }); expect(rows[0].issueCounts).toEqual({ error: 0, info: 0 });
}); });
it('fileId — id файла и у строки документа, и у строки файла без документов (удаление идёт по файлу)', () => {
const withDoc = buildDocumentRows([file({ id: 'f7', documents: [doc({ id: 'd7' })] })]);
expect(withDoc[0]).toMatchObject({ key: 'd7', documentId: 'd7', fileId: 'f7' });
const failed = buildDocumentRows([file({ id: 'f8', status: 'ERROR' })]);
expect(failed[0]).toMatchObject({ key: 'f8', documentId: null, fileId: 'f8' });
});
it('job null — пустые списки; старый формат строк не ломает', () => { it('job null — пустые списки; старый формат строк не ломает', () => {
expect(buildDocumentRows([file({ documents: [doc()] })])[0]).toMatchObject({ fileWarnings: [], mappingWarnings: [] }); expect(buildDocumentRows([file({ documents: [doc()] })])[0]).toMatchObject({ fileWarnings: [], mappingWarnings: [] });
const legacy = buildDocumentRows([file({ job: job({ warnings: ['старое'] as never }), documents: [doc()] })]); const legacy = buildDocumentRows([file({ job: job({ warnings: ['старое'] as never }), documents: [doc()] })]);

View File

@ -10,6 +10,8 @@ export interface DocumentRow {
key: string; key: string;
/** id документа; null — строка файла без документов */ /** id документа; null — строка файла без документов */
documentId: string | null; documentId: string | null;
/** id файла: удаление и замена — файлом целиком */
fileId: string;
name: string; name: string;
fileType: FileType; fileType: FileType;
subtitle: string; subtitle: string;
@ -20,8 +22,6 @@ export interface DocumentRow {
statusLabel: string; statusLabel: string;
errorReason: string | null; errorReason: string | null;
linkable: boolean; linkable: boolean;
consolidationId: string | null;
legalEntityId: string | null;
/** предупреждения разбора файла (общие для всех документов файла) */ /** предупреждения разбора файла (общие для всех документов файла) */
fileWarnings: FileWarningOut[]; fileWarnings: FileWarningOut[];
mappingWarnings: string[]; mappingWarnings: string[];
@ -66,6 +66,7 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
{ {
key: f.id, key: f.id,
documentId: null, documentId: null,
fileId: f.id,
name, name,
fileType: f.file_type, fileType: f.file_type,
subtitle: join([f.file_type, date, extra]), subtitle: join([f.file_type, date, extra]),
@ -74,8 +75,6 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
statusLabel: failed ? 'Ошибка' : 'Обработка', statusLabel: failed ? 'Ошибка' : 'Обработка',
errorReason: errorReason(f), errorReason: errorReason(f),
linkable: false, linkable: false,
consolidationId: f.consolidation_id,
legalEntityId: f.legal_entity_id,
fileWarnings, fileWarnings,
mappingWarnings, mappingWarnings,
issueCounts: { error: 0, info: 0 }, issueCounts: { error: 0, info: 0 },
@ -90,10 +89,8 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
const status = documentRowStatus(d.status); const status = documentRowStatus(d.status);
return { return {
key: d.id, key: d.id,
consolidationId: f.consolidation_id,
documentId: d.id, documentId: d.id,
file_id: f.id, fileId: f.id,
legalEntityId: f.legal_entity_id,
name, name,
fileType: f.file_type, fileType: f.file_type,
subtitle: join([typeText, f.file_type, date, extra]), subtitle: join([typeText, f.file_type, date, extra]),