fix(web): вернуть синхронизацию фронта #139/#140, затёртую в ветке шахматки (ревью #158)
Коммит 7891430 откатил синхронизацию фронта (5bec434, #139/#140) и форматирование Prettier (#137). Из origin/main восстановлены файлы, не относящиеся к шахматке: - index.tsx (тема antd: Layout.fontSize, Form.labelFontSize/labelColor); - layouts/DocumentPage.tsx (переключатель документа, комментарий к документу); - services/api: consolidations.ts (getChecklist), documents.ts (postComment), files.ts (removeFile), directories/catalogue_jur.ts; types/consolidations/version_members.ts (ChecklistBackendShape); - widgets: ConsoleLegalEntities (ChecklistModal, checklist_utils), FilesDrawer (DeleteModal, ReplaceDocModal, documentRows), DocumentReview (CommentModal, StatementReview, reviewCounters, reviewView), CatalogueJurForm, CatalogueJurTable, CompanyGroupsList; - hooks/useVirtualList.tsx и components/TableTag/presets.tsx (форматирование Prettier). В файлах шахматки оставлены её изменения: App.tsx, layouts/index.ts, api/client.ts (init в apiFetchBlob), OsvTabs.tsx, ConsolidatedOsvContent.tsx. Из main возвращён key={version_id}: страница отчёта разделена на обёртку с key и содержимое, чтобы при смене версии состояние (карточка, фильтры, страница) создавалось заново, как в ConsolidatedOsvPage. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1413feda44
commit
175343b843
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
/** высота одного элемента в px (фиксированная) */
|
/** высота одного элемента в px (фиксированная) */
|
||||||
@ -17,18 +17,13 @@ export function useVirtualList<T>(items: T[], options: Options) {
|
|||||||
|
|
||||||
const totalHeight = items.length * itemHeight;
|
const totalHeight = items.length * itemHeight;
|
||||||
|
|
||||||
const startIndex = Math.max(
|
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||||
0,
|
|
||||||
Math.floor(scrollTop / itemHeight) - overscan,
|
|
||||||
);
|
|
||||||
|
|
||||||
const endIndex = Math.min(
|
const endIndex = Math.min(items.length, Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan);
|
||||||
items.length,
|
|
||||||
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan,
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleItems = useMemo(
|
const visibleItems = useMemo(
|
||||||
() => items.slice(startIndex, endIndex).map((item, i) => ({
|
() =>
|
||||||
|
items.slice(startIndex, endIndex).map((item, i) => ({
|
||||||
item,
|
item,
|
||||||
index: startIndex + i,
|
index: startIndex + i,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@ -29,7 +29,9 @@ createRoot(rootElement).render(
|
|||||||
components: {
|
components: {
|
||||||
Layout: {
|
Layout: {
|
||||||
siderBg: '#fff',
|
siderBg: '#fff',
|
||||||
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
|
|
||||||
Button: {
|
Button: {
|
||||||
defaultBorderColor: '#c7cdd5',
|
defaultBorderColor: '#c7cdd5',
|
||||||
defaultHoverBg: '#f8fafc',
|
defaultHoverBg: '#f8fafc',
|
||||||
@ -46,6 +48,10 @@ createRoot(rootElement).render(
|
|||||||
headerColor: '#667085',
|
headerColor: '#667085',
|
||||||
headerBg: 'rgb(248, 250, 252)',
|
headerBg: 'rgb(248, 250, 252)',
|
||||||
},
|
},
|
||||||
|
Form: {
|
||||||
|
labelFontSize: 12,
|
||||||
|
labelColor: '#667085',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}}>
|
}}>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
|
|||||||
import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent';
|
import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent';
|
||||||
import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent';
|
import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent';
|
||||||
|
|
||||||
export function ConsolidatioReportPage() {
|
function ReportContent() {
|
||||||
const [card, setCard] = useState<VersionCard | null>(null);
|
const [card, setCard] = useState<VersionCard | null>(null);
|
||||||
const { version_id, tab } = useParams<{ tab?: string; version_id: string }>();
|
const { version_id, tab } = useParams<{ tab?: string; version_id: string }>();
|
||||||
|
|
||||||
@ -67,3 +67,9 @@ export function ConsolidatioReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ConsolidatioReportPage() {
|
||||||
|
const { version_id = '' } = useParams<{ version_id: string }>();
|
||||||
|
// key: при смене версии всё состояние (карточка, фильтры, страница, данные) создаётся заново
|
||||||
|
return <ReportContent key={version_id} />;
|
||||||
|
}
|
||||||
|
|||||||
@ -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 { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } 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 } 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, 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 { type DocumentRow, buildDocumentRows } from '../widgets/FilesDrawer/documentRows';
|
||||||
|
|
||||||
|
type DocumentOption = { value: string | null; label: string; disabled: boolean; row: DocumentRow };
|
||||||
|
|
||||||
export function DocumentPage() {
|
export function DocumentPage() {
|
||||||
const { document_id } = useParams();
|
const { document_id } = useParams();
|
||||||
@ -20,6 +27,8 @@ export function DocumentPage() {
|
|||||||
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 [documentOptions, setDocumentOptions] = useState<DocumentOption[]>([]);
|
||||||
|
const [commentOpen, setCommentOpen] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!document_id) return;
|
if (!document_id) return;
|
||||||
@ -31,7 +40,22 @@ export function DocumentPage() {
|
|||||||
.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 ?? row.fileId,
|
||||||
|
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 : 'Не удалось открыть документ');
|
||||||
@ -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)}`
|
`${documentTypeLabel(doc.document_type, doc.osv_account_code)} · ${periodLabel(doc.period_year, doc.period_quarter, doc.is_cumulative)}`
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Tag variant='filled' color={badge.color} style={{ fontWeight: 600, fontSize: 14, padding: '4px 12px' }}>
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end', width: 'min(520px, 48vw)' }}>
|
||||||
{badge.label}
|
<Form style={{ flex: '1 1 auto', minWidth: 0 }} layout='vertical'>
|
||||||
</Tag>
|
<Form.Item style={{ marginBottom: 0 }} label='Документ'>
|
||||||
|
<Select<string, DocumentOption>
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={doc.id}
|
||||||
|
options={documentOptions}
|
||||||
|
onChange={(value) => navigate(`/documents/${value}`)}
|
||||||
|
optionRender={(option) => (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
|
||||||
|
{/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */}
|
||||||
|
<Tooltip title={option.data.label}>
|
||||||
|
<div style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
<div>{option.data.label}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#667085' }}>{option.data.row.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<TableTag {...optionStatusBadge(option.data.row.status)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<TableTag text={badge.label} {...TAG_PRESETS[badge.color]} />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ alignSelf: 'flex-end' }}>
|
||||||
|
<Button icon={<CommentOutlined />} onClick={() => setCommentOpen(true)}>
|
||||||
|
Добавить комментарий
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<main
|
<main
|
||||||
style={{
|
style={{
|
||||||
flex: '1 1 auto',
|
flex: '1 1 auto',
|
||||||
@ -82,6 +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 open={commentOpen} documentId={doc.id} onClose={() => setCommentOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +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<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`);
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import type {
|
|||||||
import { LE_PAGE } from './company_groups';
|
import { LE_PAGE } from './company_groups';
|
||||||
|
|
||||||
export async function fetchCatalogueJur(params: GetCatalogueJurPropsI) {
|
export async function fetchCatalogueJur(params: GetCatalogueJurPropsI) {
|
||||||
const res = await api.get<Page<ICatalogueJur>>('/directories/legal-entities', {limit:LE_PAGE, offset:0, ...params});
|
const res = await api.get<Page<ICatalogueJur>>('/directories/legal-entities', { limit: LE_PAGE, offset: 0, ...params });
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,3 +21,6 @@ export async function getAllEntries(id: string): Promise<EntryOut[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Комментарий к документу: текст 1–4000 символов после обрезки пробелов (иначе 422). */
|
||||||
|
export const postComment = (documentId: string, text: string) => api.post(`/documents/${documentId}/comments`, { text });
|
||||||
|
|||||||
@ -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';
|
import type { FileOut, Page, UploadResult } from '../../types/api';
|
||||||
|
|
||||||
export function isUploadResult(body: unknown): body is UploadResult {
|
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<FileOut> {
|
export function getFile(id: string): Promise<FileOut> {
|
||||||
return apiFetch<FileOut>(`/files/${id}`);
|
return apiFetch<FileOut>(`/files/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeFile(fileId: string) {
|
||||||
|
return api.del(`/files/${fileId}`);
|
||||||
|
}
|
||||||
|
|||||||
@ -35,6 +35,24 @@ type ChecklistItem = {
|
|||||||
account?: string | null;
|
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 = {
|
type VersionMember = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@ -243,7 +243,7 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur
|
|||||||
<ColumnSettings defs={catalogueJurConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
|
<ColumnSettings defs={catalogueJurConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
{(relatedGroupFilter?.id && !outside_group) && (
|
{relatedGroupFilter?.id && !outside_group && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Input, List, Spin, Empty, Typography, Tag } from 'antd';
|
|
||||||
import { FolderOpenOutlined, SearchOutlined } from '@ant-design/icons';
|
import { FolderOpenOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
import { useAppDispatch, useAppSelector } from '../../store/store';
|
import { Empty, Input, List, Spin, Tag, Typography } from 'antd';
|
||||||
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { setActiveCompanyGroup } from '../../store/slices/companyGroupsSlice/companyGroupsSlice';
|
|
||||||
import { useVirtualList } from '../../hooks/useVirtualList';
|
import { useVirtualList } from '../../hooks/useVirtualList';
|
||||||
|
import { setActiveCompanyGroup } from '../../store/slices/companyGroupsSlice/companyGroupsSlice';
|
||||||
|
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../../store/store';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
|||||||
49
web/src/widgets/ConsoleLegalEntities/ChecklistModal.tsx
Normal file
49
web/src/widgets/ConsoleLegalEntities/ChecklistModal.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { DownloadOutlined } from '@ant-design/icons';
|
||||||
|
import { Modal, Table, message } from 'antd';
|
||||||
|
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 = {
|
||||||
|
open: boolean;
|
||||||
|
onDownload: () => void;
|
||||||
|
consolidationData: IVersionCard;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) {
|
||||||
|
const [checklistData, setChecklistData] = useState<ChecklistBackendShape | null>(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 (
|
||||||
|
<Modal
|
||||||
|
width='100%'
|
||||||
|
open={open}
|
||||||
|
cancelText='Отмена'
|
||||||
|
onCancel={onClose}
|
||||||
|
title='Чеклист'
|
||||||
|
destroyOnHidden
|
||||||
|
onOk={() => {
|
||||||
|
onDownload();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
okText='Скачать чек-лист'
|
||||||
|
okButtonProps={{ icon: <DownloadOutlined /> }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{getMarksItems()}
|
||||||
|
<Table rowKey='key' columns={columns} dataSource={rows} pagination={false} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import { setSelectedLegalEntity, setSelectedVersion } from '../../store/slices/c
|
|||||||
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';
|
||||||
@ -18,6 +19,7 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const consolidation_id = useAppSelector((s) => s.consolidations.current_version_id);
|
const consolidation_id = useAppSelector((s) => s.consolidations.current_version_id);
|
||||||
|
const [checklistOpen, setChecklistOpen] = useState(false);
|
||||||
|
|
||||||
const [consolidationData, setConsolidationData] = useState<IVersionCard | null>(null);
|
const [consolidationData, setConsolidationData] = useState<IVersionCard | null>(null);
|
||||||
|
|
||||||
@ -47,6 +49,10 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
|
|||||||
message.error(e instanceof ApiError ? e.message : 'Не удалось скачать чек-лист');
|
message.error(e instanceof ApiError ? e.message : 'Не удалось скачать чек-лист');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const viewChecklist = () => {
|
||||||
|
if (!consolidationData) return;
|
||||||
|
setChecklistOpen(true);
|
||||||
|
};
|
||||||
const { visibleKeys, setVisibleKeys } = useVisibleColumns(membersConfig);
|
const { visibleKeys, setVisibleKeys } = useVisibleColumns(membersConfig);
|
||||||
const { rawQuery, columnFilters, setColumnFilters, sort, setSort, resetAll, hasActiveFilters } = useTableFilters<MemberRow>();
|
const { rawQuery, columnFilters, setColumnFilters, sort, setSort, resetAll, hasActiveFilters } = useTableFilters<MemberRow>();
|
||||||
// single filtered array — title count and table share it
|
// single filtered array — title count and table share it
|
||||||
@ -60,6 +66,7 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<Space
|
<Space
|
||||||
style={{
|
style={{
|
||||||
@ -82,7 +89,12 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
|
|||||||
<span style={{ fontSize: 13, color: 'rgb(102, 112, 133)' }}>{rawRows.length} шт.</span>
|
<span style={{ fontSize: 13, color: 'rgb(102, 112, 133)' }}>{rawRows.length} шт.</span>
|
||||||
{consolidationData && (
|
{consolidationData && (
|
||||||
// «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7
|
// «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7
|
||||||
<span style={{ fontSize: 13, color: 'rgb(102, 112, 133)', marginLeft: 8 }}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'rgb(102, 112, 133)',
|
||||||
|
marginLeft: 8,
|
||||||
|
}}>
|
||||||
Ответственный: {responsibleName(consolidationData)} Утверждён в РФ: — Верифицирован в ГО: —
|
Ответственный: {responsibleName(consolidationData)} Утверждён в РФ: — Верифицирован в ГО: —
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -98,7 +110,7 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<ColumnSettings defs={membersConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
|
<ColumnSettings defs={membersConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
|
||||||
<Button icon={<DownloadOutlined />} disabled={!consolidationData} onClick={downloadChecklist} style={{ marginLeft: 8 }}>
|
<Button icon={<DownloadOutlined />} disabled={!consolidationData} onClick={viewChecklist} style={{ marginLeft: 8 }}>
|
||||||
Скачать чек-лист
|
Скачать чек-лист
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -116,5 +128,16 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe
|
|||||||
onRow={onRow}
|
onRow={onRow}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{consolidationData && (
|
||||||
|
<ChecklistModal
|
||||||
|
open={checklistOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setChecklistOpen(false);
|
||||||
|
}}
|
||||||
|
onDownload={downloadChecklist}
|
||||||
|
consolidationData={consolidationData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
39
web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts
Normal file
39
web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts
Normal 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: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
127
web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx
Normal file
127
web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx
Normal file
@ -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<string, FileStatus>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const presetMapping: Record<FileStatus, Preset> = {
|
||||||
|
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<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 (
|
||||||
|
<Avatar style={{ color: preset.color, backgroundColor: preset.bgColor, borderColor: preset.borderColor }} icon={iconMapping[status]} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMarksItems() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
backgroundColor: '#f8fafc',
|
||||||
|
padding: '5px 10px',
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 11,
|
||||||
|
}}>
|
||||||
|
<b>Условные обозначения</b>
|
||||||
|
{(Object.entries(statusMapping) as [FileStatus, string][]).map(([status, label]) => (
|
||||||
|
<div key={status}>
|
||||||
|
{mapStatus(status)} <span style={{ color: '#667085' }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строки таблицы из ответа `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<ChecklistTableRow> {
|
||||||
|
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) => (
|
||||||
|
<h5>
|
||||||
|
{r.name}: {r.inn}
|
||||||
|
</h5>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ 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'),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
52
web/src/widgets/DocumentReview/CommentModal.tsx
Normal file
52
web/src/widgets/DocumentReview/CommentModal.tsx
Normal file
@ -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 (
|
||||||
|
<Modal
|
||||||
|
title='Добавить комментарий'
|
||||||
|
okText='Добавить'
|
||||||
|
onOk={handleOk}
|
||||||
|
okButtonProps={{ disabled: comment.trim().length === 0 }}
|
||||||
|
confirmLoading={saving}
|
||||||
|
open={open}
|
||||||
|
cancelText='Отмена'
|
||||||
|
onCancel={close}
|
||||||
|
destroyOnHidden>
|
||||||
|
<Form layout='vertical'>
|
||||||
|
<Form.Item label={<span style={{ fontWeight: 600 }}>Комментарий</span>}>
|
||||||
|
<Input.TextArea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
autoSize={{ minRows: 3 }}
|
||||||
|
maxLength={4000}
|
||||||
|
placeholder='Опишите результат проверки или внесенные правки'
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,6 +301,11 @@ 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} />}
|
||||||
|
{counters && (
|
||||||
|
<p style={{ color: 'red' }}>
|
||||||
|
Изменённых значений: {counters.changed}. Значений, требующих проверки: {counters.needsReview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<StatementTable
|
<StatementTable
|
||||||
columns={grid.columns}
|
columns={grid.columns}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
|
|||||||
67
web/src/widgets/DocumentReview/reviewCounters.test.ts
Normal file
67
web/src/widgets/DocumentReview/reviewCounters.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
31
web/src/widgets/DocumentReview/reviewCounters.ts
Normal file
31
web/src/widgets/DocumentReview/reviewCounters.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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('Ошибка');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
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 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 {
|
||||||
@ -32,6 +34,17 @@ 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 {
|
||||||
|
return OPTION_BADGES[status];
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
return [file.original_name, file.file_type, formatMoscowDate(file.uploaded_at)].filter((p) => p).join(' · ');
|
return [file.original_name, file.file_type, formatMoscowDate(file.uploaded_at)].filter((p) => p).join(' · ');
|
||||||
|
|||||||
88
web/src/widgets/FilesDrawer/DeleteModal.tsx
Normal file
88
web/src/widgets/FilesDrawer/DeleteModal.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 16,
|
||||||
|
padding: 12,
|
||||||
|
border: `1px solid ${token.colorBorder}`,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Text strong>{title}</Text>
|
||||||
|
<Text type='secondary'>{detail}</Text>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal title='Удаление документа' open={open} onCancel={onCancel} footer={null} destroyOnHidden>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<p style={{ color: '#667085', margin: 0 }}>Выберите причину удаления документа «{targetRow.name}»</p>
|
||||||
|
<p style={{ color: 'red', fontSize: 11, margin: 0 }}>Будет удален/заменён сам файл и все его документы</p>
|
||||||
|
|
||||||
|
<OptionCard
|
||||||
|
title='Ошибочный документ'
|
||||||
|
detail='Документ будет удален без возможности восстановления.'
|
||||||
|
action={
|
||||||
|
<Button type='primary' danger loading={isDeleting} onClick={handleDelete}>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionCard
|
||||||
|
title='Замена документа'
|
||||||
|
detail='Загрузите актуальный файл и укажите причину замены.'
|
||||||
|
action={
|
||||||
|
<Tooltip title={canReplace ? undefined : 'Замена доступна только в версии в работе'}>
|
||||||
|
<Button type='primary' disabled={!canReplace} onClick={onReplace}>
|
||||||
|
Заменить
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,19 @@
|
|||||||
import { CloseOutlined, UploadOutlined } from '@ant-design/icons';
|
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 { 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';
|
||||||
|
|
||||||
const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе';
|
const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе';
|
||||||
@ -22,7 +28,21 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
|
|||||||
const desk = current !== null && !showAll;
|
const desk = current !== null && !showAll;
|
||||||
const consolidationId = desk ? current.consolidation_id : null;
|
const consolidationId = desk ? current.consolidation_id : null;
|
||||||
const { files, loading, reload } = useLegalEntityFiles(current_legal_entity_id, consolidationId, open);
|
const { files, loading, reload } = useLegalEntityFiles(current_legal_entity_id, consolidationId, open);
|
||||||
|
const [targetRow, setTargetRow] = useState<DocumentRow | null>(null);
|
||||||
|
const [removalOpen, setRemovalOpen] = useState(false);
|
||||||
const canUpload = current !== null && isInWork(current.version_status);
|
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
|
const labelFor = desk
|
||||||
? undefined
|
? undefined
|
||||||
: (f: FileOut) => consolidationLabel(f.consolidation_id, consolidations, current?.consolidation_id ?? null);
|
: (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]);
|
}, [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 = (
|
const uploadButton = (
|
||||||
<Button
|
<Button
|
||||||
type='primary'
|
type='primary'
|
||||||
@ -65,7 +115,13 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
|
|||||||
extra={<Button icon={<CloseOutlined />} aria-label='Закрыть' onClick={onClose} />}
|
extra={<Button icon={<CloseOutlined />} aria-label='Закрыть' onClick={onClose} />}
|
||||||
title={
|
title={
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text type='secondary' style={{ fontSize: 11, letterSpacing: 0.5, textTransform: 'uppercase' }}>
|
<Typography.Text
|
||||||
|
type='secondary'
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
Первичные документы
|
Первичные документы
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
{entity && (
|
{entity && (
|
||||||
@ -78,7 +134,13 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
}}>
|
||||||
{canUpload ? (
|
{canUpload ? (
|
||||||
uploadButton
|
uploadButton
|
||||||
) : (
|
) : (
|
||||||
@ -93,7 +155,7 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FilesList files={files} loading={loading} labelFor={labelFor} />
|
<FilesList files={files} loading={loading} labelFor={labelFor} onDeleteClick={handleRowDelete} />
|
||||||
{current_legal_entity_id && (
|
{current_legal_entity_id && (
|
||||||
<UploadDocumentsModal
|
<UploadDocumentsModal
|
||||||
legalEntityId={current_legal_entity_id}
|
legalEntityId={current_legal_entity_id}
|
||||||
@ -104,6 +166,26 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
|
|||||||
onUploaded={() => void reload()}
|
onUploaded={() => void reload()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{targetRow && (
|
||||||
|
<>
|
||||||
|
<DeleteReasonModal
|
||||||
|
targetRow={targetRow}
|
||||||
|
open={removalOpen}
|
||||||
|
onCancel={() => setRemovalOpen(false)}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onReplace={handleReplaceClick}
|
||||||
|
canReplace={canUpload}
|
||||||
|
/>
|
||||||
|
<ReplaceDocModal
|
||||||
|
open={replaceOpen}
|
||||||
|
docName={targetRow.name}
|
||||||
|
organizationName={entity?.name ?? null}
|
||||||
|
submitting={replacing}
|
||||||
|
onCancel={() => setReplaceOpen(false)}
|
||||||
|
onSubmit={handleReplaceSubmit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { CheckOutlined, CloseOutlined, FileExcelOutlined, FileImageOutlined, FilePdfOutlined } from '@ant-design/icons';
|
import { CheckOutlined, CloseOutlined, DeleteOutlined, FileExcelOutlined, FileImageOutlined, FilePdfOutlined } from '@ant-design/icons';
|
||||||
import { 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';
|
||||||
@ -55,10 +55,25 @@ function StatusPill({ row }: { row: DocumentRow }) {
|
|||||||
return row.errorReason ? <Tooltip title={row.errorReason}>{pill}</Tooltip> : pill;
|
return row.errorReason ? <Tooltip title={row.errorReason}>{pill}</Tooltip> : pill;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEADER = { fontSize: 11, letterSpacing: 0.5, color: '#8c8c8c', textTransform: 'uppercase' as const };
|
const HEADER = {
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: '#8c8c8c',
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
};
|
||||||
const head = (text: string) => <span style={HEADER}>{text}</span>;
|
const head = (text: string) => <span style={HEADER}>{text}</span>;
|
||||||
|
|
||||||
export function FilesList({ files, loading, labelFor }: { files: FileOut[]; loading: boolean; labelFor?: (f: FileOut) => string | null }) {
|
export function FilesList({
|
||||||
|
files,
|
||||||
|
loading,
|
||||||
|
labelFor,
|
||||||
|
onDeleteClick,
|
||||||
|
}: {
|
||||||
|
files: FileOut[];
|
||||||
|
loading: boolean;
|
||||||
|
labelFor?: (f: FileOut) => string | null;
|
||||||
|
onDeleteClick: (row: DocumentRow) => void;
|
||||||
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const rows = buildDocumentRows(files, labelFor);
|
const rows = buildDocumentRows(files, labelFor);
|
||||||
|
|
||||||
@ -67,7 +82,13 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
|
|||||||
title: head('Документ'),
|
title: head('Документ'),
|
||||||
key: 'document',
|
key: 'document',
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', minWidth: 0 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
minWidth: 0,
|
||||||
|
}}>
|
||||||
<FormatIcon type={r.fileType} />
|
<FormatIcon type={r.fileType} />
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<Tooltip title={r.name}>
|
<Tooltip title={r.name}>
|
||||||
@ -82,7 +103,12 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: head('Период'), key: 'period', width: 110, render: (_, r) => r.period },
|
{
|
||||||
|
title: head('Период'),
|
||||||
|
key: 'period',
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => r.period,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: head('Проверка'),
|
title: head('Проверка'),
|
||||||
key: 'check',
|
key: 'check',
|
||||||
@ -106,6 +132,22 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'action',
|
||||||
|
render: (_, r) => (
|
||||||
|
<Button
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
aria-label='Удалить файл'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteClick(r);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (loading && files.length === 0) {
|
if (loading && files.length === 0) {
|
||||||
@ -125,7 +167,10 @@ export function FilesList({ files, loading, labelFor }: { files: FileOut[]; load
|
|||||||
dataSource={rows}
|
dataSource={rows}
|
||||||
locale={{ emptyText: 'Документов нет' }}
|
locale={{ emptyText: 'Документов нет' }}
|
||||||
onRow={(r) => ({
|
onRow={(r) => ({
|
||||||
onClick: r.linkable && r.documentId ? () => navigate(`/documents/${r.documentId}`) : undefined,
|
onClick:
|
||||||
|
r.linkable && r.documentId
|
||||||
|
? () => navigate(`/documents/${r.documentId}`)
|
||||||
|
: () => message.warning('Просмотр данного документа недоступен'),
|
||||||
style: r.linkable ? { cursor: 'pointer' } : undefined,
|
style: r.linkable ? { cursor: 'pointer' } : undefined,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|||||||
77
web/src/widgets/FilesDrawer/ReplaceDocModal.tsx
Normal file
77
web/src/widgets/FilesDrawer/ReplaceDocModal.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { FileAddOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Input, Modal, Upload } from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
|
type ReplaceDocModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
docName: string;
|
||||||
|
organizationName: string | null;
|
||||||
|
submitting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (file: File, reason: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReplaceDocModal({ open, docName, organizationName, submitting, onCancel, onSubmit }: ReplaceDocModalProps) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// при каждом открытии — пустая форма
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setReason('');
|
||||||
|
setFile(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title='Замена документа'
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={() => file && onSubmit(file, reason.trim())}
|
||||||
|
okText='Загрузить'
|
||||||
|
okButtonProps={{
|
||||||
|
icon: <UploadOutlined />,
|
||||||
|
disabled: file === null || reason.trim().length === 0,
|
||||||
|
}}
|
||||||
|
confirmLoading={submitting}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{organizationName && <span>{organizationName}</span>}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgb(248, 250, 252)',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#667085',
|
||||||
|
padding: '8px 10px',
|
||||||
|
}}>
|
||||||
|
Заменяемый документ: {docName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input placeholder='Укажите причину замены' value={reason} maxLength={4000} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
|
||||||
|
<Dragger
|
||||||
|
multiple={false}
|
||||||
|
style={{ borderStyle: 'dashed' }}
|
||||||
|
fileList={file ? [{ uid: 'replace', name: file.name, status: 'done' }] : []}
|
||||||
|
maxCount={1}
|
||||||
|
accept='.xls,.xlsx,.xlsb,.pdf,.png,.jpg,.jpeg'
|
||||||
|
beforeUpload={(f) => {
|
||||||
|
setFile(f);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
onRemove={() => setFile(null)}>
|
||||||
|
<p className='ant-upload-drag-icon'>
|
||||||
|
<FileAddOutlined />
|
||||||
|
</p>
|
||||||
|
<p className='ant-upload-text' style={{ color: '#667085' }}>
|
||||||
|
Перетащите файл сюда
|
||||||
|
</p>
|
||||||
|
<Button>Выбрать документ</Button>
|
||||||
|
</Dragger>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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()] })]);
|
||||||
|
|||||||
@ -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;
|
||||||
@ -64,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]),
|
||||||
@ -87,6 +90,7 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s
|
|||||||
return {
|
return {
|
||||||
key: d.id,
|
key: d.id,
|
||||||
documentId: d.id,
|
documentId: d.id,
|
||||||
|
fileId: f.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]),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user