diff --git a/web/src/components/TableTag/presets.tsx b/web/src/components/TableTag/presets.tsx index 3647e0f..1846879 100644 --- a/web/src/components/TableTag/presets.tsx +++ b/web/src/components/TableTag/presets.tsx @@ -40,8 +40,8 @@ export const TAG_PRESETS = { color: 'rgb(146, 64, 14)', }, gray: { - color: 'rgb(71, 84, 103)', - bgColor: 'rgb(248, 250, 252)', - borderColor: 'rgba(15, 23, 42, 0.08)', + color: 'rgb(71, 84, 103)', + bgColor: 'rgb(248, 250, 252)', + borderColor: 'rgba(15, 23, 42, 0.08)', }, } as const; diff --git a/web/src/hooks/useVirtualList.tsx b/web/src/hooks/useVirtualList.tsx index fdd04b9..d019b08 100644 --- a/web/src/hooks/useVirtualList.tsx +++ b/web/src/hooks/useVirtualList.tsx @@ -1,51 +1,46 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from 'react'; type Options = { - /** высота одного элемента в px (фиксированная) */ - itemHeight: number; - /** высота контейнера в px (видимая область) */ - containerHeight: number; - /** сколько элементов рендерить дополнительно сверху/снизу */ - overscan?: number; + /** высота одного элемента в px (фиксированная) */ + itemHeight: number; + /** высота контейнера в px (видимая область) */ + containerHeight: number; + /** сколько элементов рендерить дополнительно сверху/снизу */ + overscan?: number; }; export function useVirtualList(items: T[], options: Options) { - const { itemHeight, containerHeight, overscan = 5 } = options; + const { itemHeight, containerHeight, overscan = 5 } = options; - const [scrollTop, setScrollTop] = useState(0); - const ref = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const ref = useRef(null); - const totalHeight = items.length * itemHeight; + const totalHeight = items.length * itemHeight; - const startIndex = Math.max( - 0, - Math.floor(scrollTop / itemHeight) - overscan, - ); + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); - const endIndex = Math.min( - items.length, - Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan, - ); + const endIndex = Math.min(items.length, Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan); - const visibleItems = useMemo( - () => items.slice(startIndex, endIndex).map((item, i) => ({ - item, - index: startIndex + i, - })), - [items, startIndex, endIndex], - ); + const visibleItems = useMemo( + () => + items.slice(startIndex, endIndex).map((item, i) => ({ + item, + index: startIndex + i, + })), + [items, startIndex, endIndex], + ); - const offsetY = startIndex * itemHeight; + const offsetY = startIndex * itemHeight; - const onScroll = useCallback((e: React.UIEvent) => { - setScrollTop(e.currentTarget.scrollTop); - }, []); + const onScroll = useCallback((e: React.UIEvent) => { + setScrollTop(e.currentTarget.scrollTop); + }, []); - return { - ref, - onScroll, - visibleItems, - totalHeight, - offsetY, - }; -} \ No newline at end of file + return { + ref, + onScroll, + visibleItems, + totalHeight, + offsetY, + }; +} diff --git a/web/src/index.tsx b/web/src/index.tsx index 207a715..83b74ef 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -29,7 +29,9 @@ createRoot(rootElement).render( components: { Layout: { siderBg: '#fff', + fontSize: 13, }, + Button: { defaultBorderColor: '#c7cdd5', defaultHoverBg: '#f8fafc', @@ -46,6 +48,10 @@ createRoot(rootElement).render( headerColor: '#667085', headerBg: 'rgb(248, 250, 252)', }, + Form: { + labelFontSize: 12, + labelColor: '#667085', + }, }, }}> diff --git a/web/src/layouts/ConsolidatioReportPage.tsx b/web/src/layouts/ConsolidatioReportPage.tsx index 06179ae..f3bb56f 100644 --- a/web/src/layouts/ConsolidatioReportPage.tsx +++ b/web/src/layouts/ConsolidatioReportPage.tsx @@ -14,7 +14,7 @@ import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent'; import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent'; -export function ConsolidatioReportPage() { +function ReportContent() { const [card, setCard] = useState(null); const { version_id, tab } = useParams<{ tab?: string; version_id: string }>(); @@ -67,3 +67,9 @@ export function ConsolidatioReportPage() { ); } + +export function ConsolidatioReportPage() { + const { version_id = '' } = useParams<{ version_id: string }>(); + // key: при смене версии всё состояние (карточка, фильтры, страница, данные) создаётся заново + return ; +} diff --git a/web/src/layouts/DocumentPage.tsx b/web/src/layouts/DocumentPage.tsx index 676f51e..0b2846d 100644 --- a/web/src/layouts/DocumentPage.tsx +++ b/web/src/layouts/DocumentPage.tsx @@ -1,17 +1,24 @@ -import { Button, Empty, Result, Spin, Tag } 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 { useNavigate, useParams } from 'react-router-dom'; import { ApiError } from '../api/client'; 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 { getDocument } from '../services/api/documents'; -import { getFile } from '../services/api/files'; +import { getFile, listLegalEntityFiles } from '../services/api/files'; import type { DocumentOut } from '../types/api'; import { documentTypeLabel, periodLabel } from '../utils/labels'; +import CommentModal from '../widgets/DocumentReview/CommentModal'; import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; import { StatementReview } from '../widgets/DocumentReview/StatementReview'; -import { documentPageTitle, documentSubtitle, statusBadge } from '../widgets/DocumentReview/reviewView'; +import { documentPageTitle, documentSubtitle, optionStatusBadge, statusBadge } from '../widgets/DocumentReview/reviewView'; import { documentViewKind } from '../widgets/DocumentReview/viewKind'; +import { type DocumentRow, buildDocumentRows } from '../widgets/FilesDrawer/documentRows'; + +type DocumentOption = { value: string | null; label: string; disabled: boolean; row: DocumentRow }; export function DocumentPage() { const { document_id } = useParams(); @@ -20,6 +27,8 @@ export function DocumentPage() { const [entityName, setEntityName] = useState(''); const [subtitle, setSubtitle] = useState(null); const [error, setError] = useState(null); + const [documentOptions, setDocumentOptions] = useState([]); + const [commentOpen, setCommentOpen] = useState(false); const load = useCallback(async () => { if (!document_id) return; @@ -31,7 +40,22 @@ export function DocumentPage() { .then((le) => setEntityName(le.name)) .catch(() => setEntityName('')); 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 ?? row.fileId, + label: row.name, + disabled: !row.linkable, + row, + })), + ), + ) + .catch(() => setDocumentOptions([])); + }) .catch(() => setSubtitle(null)); } catch (e: unknown) { setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ'); @@ -63,11 +87,38 @@ export function DocumentPage() { `${documentTypeLabel(doc.document_type, doc.osv_account_code)} · ${periodLabel(doc.period_year, doc.period_quarter, doc.is_cumulative)}` } extra={ - - {badge.label} - +
+
+ + + style={{ width: '100%' }} + value={doc.id} + options={documentOptions} + onChange={(value) => navigate(`/documents/${value}`)} + optionRender={(option) => ( +
+ {/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */} + +
+
{option.data.label}
+
{option.data.row.subtitle}
+
+
+ +
+ )} + /> +
+
+ +
} /> +
+ +
} {kind === 'none' && }
+ setCommentOpen(false)} /> ); } diff --git a/web/src/services/api/consolidations.ts b/web/src/services/api/consolidations.ts index e794227..28e1220 100644 --- a/web/src/services/api/consolidations.ts +++ b/web/src/services/api/consolidations.ts @@ -2,11 +2,12 @@ import { api, apiFetchBlob } from '../../api/client'; import type { ConsolidationCreate } from '../../types/api'; import type { LockOut } from '../../types/consolidations/common_types'; 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(`/company-groups/${groupId}/consolidations`); export const getVersionCard = (versionId: string) => api.get(`/consolidations/versions/${versionId}`); export const createConsolidation = (body: ConsolidationCreate) => api.post('/consolidations', body); export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`); +export const getChecklist = (versionId: string) => api.get(`/consolidations/versions/${versionId}/checklist`); /** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */ export const lockVersion = (versionId: string) => api.post(`/consolidations/versions/${versionId}/lock`); diff --git a/web/src/services/api/directories/catalogue_jur.ts b/web/src/services/api/directories/catalogue_jur.ts index c84de06..9b1d30a 100644 --- a/web/src/services/api/directories/catalogue_jur.ts +++ b/web/src/services/api/directories/catalogue_jur.ts @@ -10,7 +10,7 @@ import type { import { LE_PAGE } from './company_groups'; export async function fetchCatalogueJur(params: GetCatalogueJurPropsI) { - const res = await api.get>('/directories/legal-entities', {limit:LE_PAGE, offset:0, ...params}); + const res = await api.get>('/directories/legal-entities', { limit: LE_PAGE, offset: 0, ...params }); return res; } diff --git a/web/src/services/api/documents.ts b/web/src/services/api/documents.ts index fa4547e..64bd52d 100644 --- a/web/src/services/api/documents.ts +++ b/web/src/services/api/documents.ts @@ -21,3 +21,6 @@ export async function getAllEntries(id: string): Promise { } } } + +/** Комментарий к документу: текст 1–4000 символов после обрезки пробелов (иначе 422). */ +export const postComment = (documentId: string, text: string) => api.post(`/documents/${documentId}/comments`, { text }); diff --git a/web/src/services/api/files.ts b/web/src/services/api/files.ts index 921f864..9b419b0 100644 --- a/web/src/services/api/files.ts +++ b/web/src/services/api/files.ts @@ -1,4 +1,4 @@ -import { ApiError, apiFetch, apiFetchBlob } from '../../api/client'; +import { ApiError, api, apiFetch, apiFetchBlob } from '../../api/client'; import type { FileOut, Page, UploadResult } from '../../types/api'; export function isUploadResult(body: unknown): body is UploadResult { @@ -56,3 +56,7 @@ export function fetchFileContent(fileId: string): Promise<{ blob: Blob; filename export function getFile(id: string): Promise { return apiFetch(`/files/${id}`); } + +export function removeFile(fileId: string) { + return api.del(`/files/${fileId}`); +} diff --git a/web/src/types/consolidations/version_members.ts b/web/src/types/consolidations/version_members.ts index 1834baf..a8212ac 100644 --- a/web/src/types/consolidations/version_members.ts +++ b/web/src/types/consolidations/version_members.ts @@ -35,6 +35,24 @@ type ChecklistItem = { account?: string | null; }; +type ChecklistRow = { + cells: ChecklistItem[]; + legal_entity_id: string; + legal_entity_name: string; + legal_entity_inn: string; +}; + +type ChecklistRule = { + document_type: DocumentType; + account: string | null; + is_mandatory: boolean; +}; + +export type ChecklistBackendShape = { + rows: ChecklistRow[]; + rules: ChecklistRule[]; +}; + type VersionMember = { id: string; name: string; diff --git a/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx b/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx index ccf1364..bd865e0 100644 --- a/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx +++ b/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx @@ -54,7 +54,7 @@ export const CatalogueJurForm: React.FC = ({ open, onCancel, onOk, jurId } catch (e) { if (e instanceof ApiError) { notification.error({ description: e.message }); - } + } } finally { setInitialDataLoading(false); } diff --git a/web/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx b/web/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx index edb5b12..9311884 100644 --- a/web/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx +++ b/web/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx @@ -243,7 +243,7 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur - {(relatedGroupFilter?.id && !outside_group) && ( + {relatedGroupFilter?.id && !outside_group && (
void; + consolidationData: IVersionCard; + onClose: () => void; +}; + +export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) { + const [checklistData, setChecklistData] = useState(null); + + const rows = useMemo(() => (checklistData ? buildChecklistRows(checklistData) : []), [checklistData]); + const columns = useMemo(() => (checklistData ? buildChecklistColumns(checklistData) : []), [checklistData]); + + useEffect(() => { + if (!consolidationData.id || !open) return; + getChecklist(consolidationData.id) + .then(setChecklistData) + .catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить чек-лист')); + }, [open, consolidationData.id]); + + return ( + { + onDownload(); + onClose(); + }} + okText='Скачать чек-лист' + okButtonProps={{ icon: }}> +
+ {getMarksItems()} + + + + ); +} diff --git a/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx b/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx index d21cf2f..44aa8fa 100644 --- a/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx +++ b/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx @@ -10,6 +10,7 @@ import { setSelectedLegalEntity, setSelectedVersion } from '../../store/slices/c import { useAppDispatch, useAppSelector } from '../../store/store'; import type { IVersionCard, MemberRow } from '../../types/consolidations/version_members'; import { saveBlob } from '../../utils/download'; +import ChecklistModal from './ChecklistModal'; import { membersConfig } from './config'; import { normalizeData } from './normalize'; import { checklistFilename, responsibleName } from './panelInfo'; @@ -18,6 +19,7 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe const dispatch = useAppDispatch(); const [loading, setLoading] = useState(false); const consolidation_id = useAppSelector((s) => s.consolidations.current_version_id); + const [checklistOpen, setChecklistOpen] = useState(false); const [consolidationData, setConsolidationData] = useState(null); @@ -47,6 +49,10 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe message.error(e instanceof ApiError ? e.message : 'Не удалось скачать чек-лист'); } }; + const viewChecklist = () => { + if (!consolidationData) return; + setChecklistOpen(true); + }; const { visibleKeys, setVisibleKeys } = useVisibleColumns(membersConfig); const { rawQuery, columnFilters, setColumnFilters, sort, setSort, resetAll, hasActiveFilters } = useTableFilters(); // single filtered array — title count and table share it @@ -60,61 +66,78 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe }); return ( -
- -
-

- Организации -

- {rawRows.length} шт. - {consolidationData && ( - // «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7 - - Ответственный: {responsibleName(consolidationData)}   Утверждён в РФ: —   Верифицирован в ГО: — - - )} -
-
- {hasActiveFilters && ( - + )} + + - )} - - -
-
- -
+ + + + + {consolidationData && ( + { + setChecklistOpen(false); + }} + onDownload={downloadChecklist} + consolidationData={consolidationData} + /> + )} + ); } diff --git a/web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts b/web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts new file mode 100644 index 0000000..fc67fd6 --- /dev/null +++ b/web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts @@ -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: {} }); + }); +}); diff --git a/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx b/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx new file mode 100644 index 0000000..606d72b --- /dev/null +++ b/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx @@ -0,0 +1,127 @@ +import { + CheckCircleOutlined, + ClockCircleOutlined, + EllipsisOutlined, + ExclamationCircleOutlined, + EyeOutlined, + MinusCircleOutlined, +} from '@ant-design/icons'; +import { Avatar, type TableColumnsType } from 'antd'; +import type { ReactNode } from 'react'; +import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; +import type { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members'; +import { statusMapping } from './consts'; + +/** Строка таблицы чек-листа: ЮЛ и статусы ББ, ОФР и ОСВ по счетам. */ +export type ChecklistTableRow = { + key: string; + name: string; + inn: string; + balance: FileStatus; + ofr: FileStatus; + osv: Record; +}; + +const presetMapping: Record = { + MISSING: TAG_PRESETS.default, + CONFIRMED: TAG_PRESETS.green, + NEEDS_CHECK: TAG_PRESETS.orange, + RECOGNIZED: TAG_PRESETS.gold, + ERROR: TAG_PRESETS.red, + PROCESSING: TAG_PRESETS.geekblue, +}; + +const iconMapping: Record = { + MISSING: , + NEEDS_CHECK: , + ERROR: , + CONFIRMED: , + RECOGNIZED: , + PROCESSING: , +}; + +export const mapStatus = (status: FileStatus) => { + const preset = presetMapping[status]; + return ( + + ); +}; + +export function getMarksItems() { + return ( +
+ Условные обозначения + {(Object.entries(statusMapping) as [FileStatus, string][]).map(([status, label]) => ( +
+ {mapStatus(status)} {label} +
+ ))} +
+ ); +} + +/** Строки таблицы из ответа `GET …/checklist`: ОФР — ячейка `INCOME`; нет ячейки ББ/ОФР — «Отсутствует». */ +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 { + const osvCodes: string[] = []; + for (const row of checklistData.rows) { + for (const cell of row.cells) { + if (cell.document_type === 'OSV' && cell.account && !osvCodes.includes(cell.account)) { + osvCodes.push(cell.account); + } + } + } + return [ + { + key: 'name', + title: 'ОРГАНИЗАЦИЯ', + render: (_, r) => ( +
+ {r.name}: {r.inn} +
+ ), + }, + { key: 'balance', dataIndex: 'balance', title: 'БУХ.БАЛАНС', render: (v: FileStatus) => mapStatus(v) }, + { key: 'ofr', dataIndex: 'ofr', title: 'ОФР', render: (v: FileStatus) => mapStatus(v) }, + { + key: 'osv', + title: 'ОСВ', + children: osvCodes.map((code) => ({ + key: `osv_${code}`, + title: code, + render: (_: unknown, r: ChecklistTableRow) => mapStatus(r.osv[code] ?? 'MISSING'), + })), + }, + ]; +} diff --git a/web/src/widgets/DocumentReview/CommentModal.tsx b/web/src/widgets/DocumentReview/CommentModal.tsx new file mode 100644 index 0000000..bfc491c --- /dev/null +++ b/web/src/widgets/DocumentReview/CommentModal.tsx @@ -0,0 +1,52 @@ +import { Form, Input, Modal, message } from 'antd'; +import { useState } from 'react'; +import { ApiError } from '../../api/client'; +import { postComment } from '../../services/api/documents'; + +type CommentModalProps = { documentId: string; open: boolean; onClose: () => void }; + +export default function CommentModal({ documentId, open, onClose }: CommentModalProps) { + const [comment, setComment] = useState(''); + const [saving, setSaving] = useState(false); + const close = () => { + setComment(''); + onClose(); + }; + const handleOk = async () => { + setSaving(true); + try { + await postComment(documentId, comment.trim()); + message.success('Комментарий добавлен'); + close(); + } catch (e: unknown) { + // окно не закрываем: текст не теряется, можно повторить + message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий'); + } finally { + setSaving(false); + } + }; + return ( + +
+ Комментарий}> + setComment(e.target.value)} + autoSize={{ minRows: 3 }} + maxLength={4000} + placeholder='Опишите результат проверки или внесенные правки' + /> + + +
+ ); +} diff --git a/web/src/widgets/DocumentReview/StatementReview.tsx b/web/src/widgets/DocumentReview/StatementReview.tsx index 5ac0ffd..349b107 100644 --- a/web/src/widgets/DocumentReview/StatementReview.tsx +++ b/web/src/widgets/DocumentReview/StatementReview.tsx @@ -20,6 +20,7 @@ import { switchesFor } from './focusTarget'; import { detailCount, filterLayout, formLayout } from './formLayout'; import { issueListItems, issuePlaces } from './issuePlaces'; import { applyLineMenu, showsCodeInput } from './lineMenu'; +import { countReviewValues } from './reviewCounters'; import { reviewActions } from './reviewView'; import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft'; import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid'; @@ -68,6 +69,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut }, [load]); const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]); + // счётчики над таблицей — только пока форма на проверке + const counters = useMemo( + () => (statement && statement.document.status === 'NEEDS_CHECK' ? countReviewValues(statement) : null), + [statement], + ); // пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы) const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts, draft.lines) : new Set()), [grid, draft]); const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]); @@ -295,6 +301,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut )} {serverErrorOffScreen && } + {counters && ( +

+ Изменённых значений: {counters.changed}. Значений, требующих проверки: {counters.needsReview} +

+ )} = {}): 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 => ({ + value: 'x', + original_value: 'x', + verified: null, + modified: null, + ...extra, +}); + +const statement = (values: ValueOut[], header: Partial = {}) => + ({ + 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 }); + }); +}); diff --git a/web/src/widgets/DocumentReview/reviewCounters.ts b/web/src/widgets/DocumentReview/reviewCounters.ts new file mode 100644 index 0000000..7dfb197 --- /dev/null +++ b/web/src/widgets/DocumentReview/reviewCounters.ts @@ -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 }; +} diff --git a/web/src/widgets/DocumentReview/reviewView.test.ts b/web/src/widgets/DocumentReview/reviewView.test.ts index 108cae4..8ec4a94 100644 --- a/web/src/widgets/DocumentReview/reviewView.test.ts +++ b/web/src/widgets/DocumentReview/reviewView.test.ts @@ -1,5 +1,14 @@ 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', () => { it('ISO → дд.мм.гггг', () => { @@ -69,3 +78,12 @@ describe('columnTitle', () => { 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('Ошибка'); + }); +}); diff --git a/web/src/widgets/DocumentReview/reviewView.ts b/web/src/widgets/DocumentReview/reviewView.ts index 5ad4efa..9007893 100644 --- a/web/src/widgets/DocumentReview/reviewView.ts +++ b/web/src/widgets/DocumentReview/reviewView.ts @@ -1,6 +1,8 @@ +import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; import type { ColumnOut, DocumentStatus, DocumentType, FileOut } from '../../types/api'; import { formatMoscowDate } from '../../utils/dates'; import { documentStatusLabel } from '../../utils/labels'; +import type { RowStatus } from '../FilesDrawer/documentRows'; /** '2025-09-30' (или ISO с временем) → '30.09.2025'; пусто → null; нераспознанное — как есть. */ export function formatReportDate(value: string | null | undefined): string | null { @@ -32,6 +34,17 @@ export function statusBadge(status: DocumentStatus): { label: string; color: 'gr } return { label, color: status === 'ERROR' ? 'red' : 'orange' }; } +const OPTION_BADGES: Record = { + 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 { + return OPTION_BADGES[status]; +} export function documentSubtitle(file: Pick): string { return [file.original_name, file.file_type, formatMoscowDate(file.uploaded_at)].filter((p) => p).join(' · '); diff --git a/web/src/widgets/FilesDrawer/DeleteModal.tsx b/web/src/widgets/FilesDrawer/DeleteModal.tsx new file mode 100644 index 0000000..c01ac60 --- /dev/null +++ b/web/src/widgets/FilesDrawer/DeleteModal.tsx @@ -0,0 +1,88 @@ +import { Button, Modal, Tooltip, Typography, message, theme } from 'antd'; +import { type ReactNode, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { removeFile } from '../../services/api/files'; +import type { DocumentRow } from './documentRows'; + +const { Text } = Typography; + +const OptionCard = ({ title, detail, action }: { title: string; detail: string; action: ReactNode }) => { + const { token } = theme.useToken(); + return ( +
+
+ {title} + {detail} +
+ {action} +
+ ); +}; + +type DeleteReasonModalProps = { + targetRow: DocumentRow; + open: boolean; + onCancel: () => 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 handleDelete = async () => { + setIsDeleting(true); + try { + await removeFile(targetRow.fileId); + message.success('Файл удалён'); + onDelete(); + } catch (e: unknown) { + // 409 — например, в файле подтверждённый документ; сервер объясняет причину + message.error(e instanceof ApiError ? e.message : 'Не удалось удалить файл'); + } finally { + setIsDeleting(false); + } + }; + return ( + +
+

Выберите причину удаления документа «{targetRow.name}»

+

Будет удален/заменён сам файл и все его документы

+ + + Удалить + + } + /> + + + + + } + /> +
+
+ ); +} diff --git a/web/src/widgets/FilesDrawer/FilesDrawer.tsx b/web/src/widgets/FilesDrawer/FilesDrawer.tsx index 3026379..7d18a64 100644 --- a/web/src/widgets/FilesDrawer/FilesDrawer.tsx +++ b/web/src/widgets/FilesDrawer/FilesDrawer.tsx @@ -1,13 +1,19 @@ import { CloseOutlined, UploadOutlined } from '@ant-design/icons'; -import { Button, Drawer, Switch, Tooltip, Typography } from 'antd'; +import { Button, Drawer, Switch, Tooltip, Typography, message } from 'antd'; import { useEffect, useState } from 'react'; +import { ApiError } from '../../api/client'; 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 { useAppSelector } from '../../store/store'; import type { FileOut, LegalEntityBrief } from '../../types/api'; +import DeleteReasonModal from './DeleteModal'; import { FilesList } from './FilesList'; +import ReplaceDocModal from './ReplaceDocModal'; import { UploadDocumentsModal } from './UploadDocumentsModal'; import { consolidationLabel } from './consolidationLabel'; +import type { DocumentRow } from './documentRows'; import { useLegalEntityFiles } from './useLegalEntityFiles'; const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе'; @@ -22,7 +28,21 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () const desk = current !== null && !showAll; const consolidationId = desk ? current.consolidation_id : null; const { files, loading, reload } = useLegalEntityFiles(current_legal_entity_id, consolidationId, open); + const [targetRow, setTargetRow] = useState(null); + const [removalOpen, setRemovalOpen] = useState(false); const canUpload = current !== null && isInWork(current.version_status); + const [replaceOpen, setReplaceOpen] = useState(false); + const [replacing, setReplacing] = useState(false); + + const onDelete = () => { + void reload(); + setRemovalOpen(false); + }; + + const handleReplaceClick = () => { + setRemovalOpen(false); + setReplaceOpen(true); + }; const labelFor = desk ? undefined : (f: FileOut) => consolidationLabel(f.consolidation_id, consolidations, current?.consolidation_id ?? null); @@ -44,6 +64,36 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () }; }, [open, current_legal_entity_id]); + const handleRowDelete = (row: DocumentRow) => { + setTargetRow(row); + setRemovalOpen(true); + }; + // Замена = загрузка нового файла в ту же консолидацию: прежний ядро переводит в SUPERSEDED после разбора, + // если совпали тип, счёт и период (05-domain-rules §9.5 п. 3). Причина — комментарием к заменяемому документу. + const handleReplaceSubmit = async (file: File, reason: string) => { + if (!current_legal_entity_id || !targetRow) return; + setReplacing(true); + try { + const result = summarizeUpload(await uploadFiles(current_legal_entity_id, [file], current?.consolidation_id ?? null)); + 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); + void reload(); + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить файл'); + } finally { + setReplacing(false); + } + }; + const uploadButton = ( + + + + ); +} diff --git a/web/src/widgets/FilesDrawer/documentRows.test.ts b/web/src/widgets/FilesDrawer/documentRows.test.ts index 88683a7..d99e3fc 100644 --- a/web/src/widgets/FilesDrawer/documentRows.test.ts +++ b/web/src/widgets/FilesDrawer/documentRows.test.ts @@ -145,6 +145,13 @@ describe('buildDocumentRows: предупреждения', () => { 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 — пустые списки; старый формат строк не ломает', () => { expect(buildDocumentRows([file({ documents: [doc()] })])[0]).toMatchObject({ fileWarnings: [], mappingWarnings: [] }); const legacy = buildDocumentRows([file({ job: job({ warnings: ['старое'] as never }), documents: [doc()] })]); diff --git a/web/src/widgets/FilesDrawer/documentRows.ts b/web/src/widgets/FilesDrawer/documentRows.ts index d5873ee..2a62265 100644 --- a/web/src/widgets/FilesDrawer/documentRows.ts +++ b/web/src/widgets/FilesDrawer/documentRows.ts @@ -10,6 +10,8 @@ export interface DocumentRow { key: string; /** id документа; null — строка файла без документов */ documentId: string | null; + /** id файла: удаление и замена — файлом целиком */ + fileId: string; name: string; fileType: FileType; subtitle: string; @@ -64,6 +66,7 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s { key: f.id, documentId: null, + fileId: f.id, name, fileType: f.file_type, subtitle: join([f.file_type, date, extra]), @@ -87,6 +90,7 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s return { key: d.id, documentId: d.id, + fileId: f.id, name, fileType: f.file_type, subtitle: join([typeText, f.file_type, date, extra]),