fix: блокировка версии, доработка визуала таблицы версий консолидации, фильтр 'Только в работе'

This commit is contained in:
maxim 2026-10-22 14:47:11 +03:00
parent 2e75de33cc
commit 5ee54bd94f
10 changed files with 301 additions and 50 deletions

View File

@ -15,6 +15,7 @@
"@ant-design/icons": "^6.0.0",
"@reduxjs/toolkit": "^2.8.2",
"antd": "^6.5.0",
"lucide-react": "0.263.1",
"markdown-to-jsx": "^9.1.2",
"pdfjs-dist": "^5.7.284",
"react": "^18.3.1",

View File

@ -1,4 +1,4 @@
import { SettingOutlined } from '@ant-design/icons';
import { Settings2 } from 'lucide-react';
import { Button, Checkbox, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { type ColumnDef, isGroup } from './tableTypes';
@ -41,7 +41,7 @@ export function ColumnSettings<T>({ defs, visibleKeys, onChange }: Props<T>) {
});
return (
<Dropdown menu={{ items }} trigger={['click']} placement='bottomRight'>
<Button icon={<SettingOutlined />}>Настройка таблицы</Button>
<Button icon={<Settings2 size={14} />}>Настройка таблицы</Button>
</Dropdown>
);
}

View File

@ -1,4 +1,4 @@
import { FilterOutlined } from '@ant-design/icons';
import { Filter } from 'lucide-react';
import { Table } from 'antd';
import type { TableProps } from 'antd';
import type { ColumnGroupType, ColumnType, ColumnsType } from 'antd/es/table';
@ -94,7 +94,7 @@ export function DataTable<T extends Record<string, any>>({
color: active ? 'rgb(22, 101, 52)' : '#bfbfbf',
transition: 'background 0.15s',
}}>
<FilterOutlined style={{ fontSize: 12 }} />
<Filter size={12} />
</span>
);
},

View File

@ -38,7 +38,6 @@ export function ConsolidationPage() {
setComGroupName(data.name);
setLocationPath(`Отчётность>${data.name}>Консолидации`);
setDesc(`${data.legal_entities_count} организаций`);
await dispatch(getConsolidationsListThunk(id)).unwrap();
} catch (e: unknown) {
message.error(errorText(e));
}

View File

@ -4,10 +4,17 @@ import type { LockOut } from '../../types/consolidations/common_types';
import type { IConsolidation } from '../../types/consolidations/consolidations';
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, in_work_only: boolean) =>
api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations?in_work_only=${in_work_only}`);
export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`);
export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body);
export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`);
export const getChecklist = (versionId: string) => api.get<ChecklistBackendShape>(`/consolidations/versions/${versionId}/checklist`);
/** «Взять в работу»: 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, reason?: string | null) => {
const body = {};
if (reason) {
body['takeover_reason'] = reason;
}
return api.post<LockOut>(`/consolidations/versions/${versionId}/lock`, body);
};

View File

@ -2,13 +2,14 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { getConsolidations } from '../../../services/api/consolidations';
import type { IConsolidation } from '../../../types/consolidations/consolidations';
export const getConsolidationsListThunk = createAsyncThunk<IConsolidation[], string, { rejectValue: string }>(
'consolidations/getByCompanyGroup',
async (company_group_id, thunkApi) => {
try {
return await getConsolidations(company_group_id);
} catch (e) {
return thunkApi.rejectWithValue(e instanceof Error ? e.message : 'Ошибка');
}
},
);
export const getConsolidationsListThunk = createAsyncThunk<
IConsolidation[],
{ company_group_id: string; in_work_only: boolean },
{ rejectValue: string }
>('consolidations/getByCompanyGroup', async ({ company_group_id, in_work_only = false }, thunkApi) => {
try {
return await getConsolidations(company_group_id, in_work_only);
} catch (e) {
return thunkApi.rejectWithValue(e instanceof Error ? e.message : 'Ошибка');
}
});

View File

@ -1,22 +1,55 @@
import { ArrowRightOutlined, DownOutlined, MoreOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Dropdown, Space, message } from 'antd';
import { DownOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Checkbox, Dropdown, Space, Tooltip, message } from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ColumnSettings, DataTable, type TableConfig, isGroup, useFilteredRows, useVisibleColumns } from '../../components/GenericTable';
import { useTableFilters } from '../../components/GenericTable/useTableFilters';
import { useAppSelector } from '../../store/store';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { ConsRow } from '../../types/consolidations/consolidations';
import { consConfigBase } from './config';
import { flattenCons } from './flatten';
import { groupAdjacent } from './groupAdjacent';
import { useGrouping } from './useGrouping';
import { ArrowRight, Briefcase, GitBranchPlus, History, MoreHorizontal, ToggleLeft } from 'lucide-react';
import { useCurrentUser } from '../../auth/MeContext';
import LockModal from './LockModal';
import { LockOut } from '../../types/consolidations/common_types';
import { Me } from '../../api/types';
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
type ActionKey = 'copy' | 'disable_confirm' | 'history';
const ITEMS = [
{ key: 'copy', label: 'Добавить версию' },
{ key: 'disable_confirm', label: 'Отключить подвтерждение КФО' },
{ key: 'history', label: 'История изменений' },
{
key: 'copy',
label: (
<Tooltip title='Будет позже'>
<span style={{ display: 'block', width: '100%', cursor: 'not-allowed' }}>Добавить версию</span>
</Tooltip>
),
className: 'ant-dropdown-menu-item-disabled',
icon: <GitBranchPlus size={14} />,
},
{
key: 'disable_confirm',
label: (
<Tooltip title='Будет позже'>
<span style={{ display: 'block', width: '100%', cursor: 'not-allowed' }}>Отключить подтверждение КФО</span>
</Tooltip>
),
className: 'ant-dropdown-menu-item-disabled',
icon: <ToggleLeft size={14} />,
},
{
key: 'history',
label: (
<Tooltip title='Будет позже'>
<span style={{ display: 'block', width: '100%', cursor: 'not-allowed' }}>История изменений</span>
</Tooltip>
),
className: 'ant-dropdown-menu-item-disabled',
icon: <History size={14} />,
},
{},
] as const;
@ -31,19 +64,29 @@ const RowActions = memo(function RowActions({ row, onAction }: { row: ConsRow; o
onAction(key as ActionKey, row);
},
}}>
<Button icon={<MoreOutlined />} onClick={(e) => e.stopPropagation()} />
<Button icon={<MoreHorizontal size={14} />} onClick={(e) => e.stopPropagation()} />
</Dropdown>
);
});
function isLockedByCurrentUser(currentUser: Me, lockData: LockOut | null) {
if (lockData === null) return false;
if (lockData.locked_by !== currentUser.id) return false;
return true;
}
export function ConsoleTableWidget() {
const navigate = useNavigate();
const { group_id } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const consolidations = useAppSelector((s) => s.consolidations.consolidations);
const loading = useAppSelector((s) => s.consolidations.loading);
const inWorkOnly = searchParams.get('in_work_only') || true;
const currentUser = useCurrentUser();
const dispatch = useAppDispatch();
const { columnFilters, setColumnFilters, sort, setSort, resetAll, hasActiveFilters } = useTableFilters<ConsRow>();
const rawRows = useMemo(() => flattenCons(consolidations), [consolidations]);
const [lockModalOpen, setLockModalOpen] = useState<boolean>(false);
const [lockVersionId, setLockVersionId] = useState<string | null>(null);
// const [sort, setSort] = useState<SortState<ConsRow>>(null);
@ -51,6 +94,17 @@ export function ConsoleTableWidget() {
const { rows: filtered } = useFilteredRows(rawRows, consConfigBase, '', columnFilters, sort);
const grouped = useMemo(() => groupAdjacent(filtered), [filtered]);
const { visibleRows, expanded, toggle } = useGrouping(grouped);
const loadConsolidations = useCallback(async () => {
if (!group_id) return;
let in_work_only = true;
if (inWorkOnly === 'false') {
in_work_only = false;
}
await dispatch(getConsolidationsListThunk({ company_group_id: group_id, in_work_only })).unwrap();
}, [dispatch, group_id, inWorkOnly]);
useEffect(() => {
loadConsolidations();
}, [loadConsolidations]);
const config: TableConfig<ConsRow> = useMemo(
() => ({
@ -115,22 +169,41 @@ export function ConsoleTableWidget() {
title: '',
width: 120,
render: (_: unknown, row: ConsRow) => (
<div style={{ display: 'flex' }}>
<div style={{ display: 'flex', gap: 4 }}>
{isLockedByCurrentUser(currentUser, row.lock) ? (
<Button disabled icon={<Briefcase size={16} />} type='default'>
В работе
</Button>
) : (
<Button
iconPlacement='start'
icon={<Briefcase size={16} />}
type='default'
disabled={row.status === 'Отменён'}
onClick={(e) => {
e.stopPropagation();
setLockVersionId(row.id);
setLockModalOpen(true);
}}>
Взять в работу
</Button>
)}
<Button
type='default'
icon={<ArrowRight size={16} />}
onClick={(e) => {
// иначе клик всплывает до onRow строки, и его setSearchParams(replace) отменяет переход
e.stopPropagation();
navigate(`/groups/${group_id}/versions/${row.id}/osv`);
}}>
К отчётности <ArrowRightOutlined />
К отчётности
</Button>
<RowActions row={row} onAction={handleDropdownAction} />
</div>
),
},
],
[navigate, group_id, handleDropdownAction],
[navigate, group_id, handleDropdownAction, currentUser],
);
const onRow: TableProps<ConsRow>['onRow'] = (row) => ({
@ -171,6 +244,19 @@ export function ConsoleTableWidget() {
Сбросить фильтры
</Button>
)}
<Checkbox
checked={searchParams.get('in_work_only') === 'false' ? false : true}
onChange={(e) => {
setSearchParams(
(searchParams) => {
searchParams.set('in_work_only', String(e.target.checked));
return searchParams;
},
{ replace: true },
);
}}>
Только в работе
</Checkbox>
<ColumnSettings defs={config.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
</div>
</Space>
@ -187,6 +273,13 @@ export function ConsoleTableWidget() {
extraColumns={extraColumns}
onRow={onRow}
/>
<LockModal
open={lockModalOpen}
versionId={lockVersionId}
onOpenChange={setLockModalOpen}
handleReload={loadConsolidations}
currentUserId={currentUser.id}
/>
</div>
);
}

View File

@ -0,0 +1,161 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { LockOut } from '../../types/consolidations/common_types';
import { Alert, Button, Input, message, Modal, Skeleton } from 'antd';
import { ApiError } from '../../api/client';
import { getVersionCard, lockVersion } from '../../services/api/consolidations';
type LockModalProps = {
versionId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
currentUserId: string;
handleReload: () => void;
};
function isValidReason(reason: string) {
const trimmed = reason.trim();
if (trimmed.length > 0 && trimmed.length < 1000) return true;
return false;
}
export default function LockModal({ versionId, open, onOpenChange, currentUserId, handleReload }: LockModalProps) {
const [data, setData] = useState<LockOut | null>(null);
const [consolidationName, setConsolidationName] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const [reason, setReason] = useState('');
const [conflict, setConflict] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Stale-response guard for fast open/switch/close sequences.
const reqId = useRef(0);
const close = useCallback(() => onOpenChange(false), [onOpenChange]);
const load = useCallback(async () => {
if (!versionId) return;
const my = ++reqId.current;
setLoading(true);
setLoadError(false);
try {
const currentCard = await getVersionCard(versionId);
const lockData = currentCard.lock;
const { company_group, year, quarter } = currentCard;
const consName = `${year} ${quarter} · ${company_group.name}`;
if (my !== reqId.current) return; // a newer request won
setData(lockData);
setConsolidationName(consName);
} catch (e) {
if (my !== reqId.current) return;
if (e instanceof ApiError && e.status === 404) {
message.warning('Версия консолидации не обнаржуена');
close();
return;
}
setLoadError(true);
} finally {
if (my === reqId.current) setLoading(false);
}
}, [versionId, close]);
// Reset + fetch on every open, and on target change while open.
useEffect(() => {
if (!open) return;
setReason('');
setConflict(false);
setData(null);
setLoadError(false);
load();
return () => {
reqId.current++; // invalidate in-flight response on close/switch
};
}, [open, versionId, load]);
const hasToReason = !loading && data !== null && data.locked_by !== currentUserId;
const handleOk = async () => {
if (!versionId || loading || submitting) return;
if (hasToReason && !reason.trim()) return;
setSubmitting(true);
try {
const result = await lockVersion(versionId, hasToReason ? reason.trim() : null);
close();
message.success('Консолидация взята в работу.');
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
if (e.code === 'VERSION_NOT_EDITABLE') {
message.error('Нельзя взять в работу отменённую версию.');
close();
} else if (e.code === 'LOCKED_BY_OTHER' && !conflict) {
await load();
setConflict(true);
setReason('');
}
} else {
message.error('Что-то пошло не так. Попробуйте снова.');
}
} finally {
setSubmitting(false);
handleReload();
}
};
return (
<Modal
open={open}
title='Взять консолидацию в работу?'
onCancel={() => !submitting && close()}
centered
closable={!submitting}
okText='Взять в работу'
okButtonProps={{ disabled: loading || loadError || (hasToReason && !isValidReason(reason)) }}
cancelText='Отмена'
onOk={handleOk}
cancelButtonProps={{ disabled: submitting }}>
{loading && <Skeleton active paragraph={{ rows: 2 }} />}
{!loading && loadError && (
<Alert
type='error'
showIcon
title='Не получилось загрузить нформацию о текущей блокировке'
action={
<Button size='small' onClick={load}>
Попробовать перезагрузить
</Button>
}
/>
)}
{!loading && !loadError && (
<>
{conflict && hasToReason && data && (
<Alert
type='warning'
showIcon
title={`${data.locked_by_name} только что взял(-а) консолидацию в работу.`}
style={{ marginBottom: 12 }}
/>
)}
{hasToReason && data ? (
<>
<p style={{ marginTop: 0 }}>Консолидация взята в работу {data.locked_by_name}. Укажите причину замены.</p>
<Input.TextArea
autoFocus
placeholder='Type message'
value={reason}
maxLength={1000}
rows={3}
onChange={(e) => setReason(e.target.value)}
/>
</>
) : (
<p style={{ marginBottom: 0 }}>Консолидация {consolidationName} будет закреплена за вами для редактирования.</p>
)}
</>
)}
</Modal>
);
}

View File

@ -15,7 +15,7 @@ export const consConfigBase: TableConfig<ConsRow> = {
{ key: 'year', title: 'Год', features: { sort: true, multiselect: true } },
{ key: 'quarter', title: 'Квартал', features: { multiselect: true }, render: (v) => quarterShortLabel(v as QuarterEnum) },
{ key: 'version_no', title: 'Версия', features: { sort: true } },
{ key: 'version_no', title: 'Версия', features: { sort: true }, render: (v) => (String(v).trim() !== '1' ? '' : v) },
{
key: 'status',
title: 'Статус',
@ -23,8 +23,8 @@ export const consConfigBase: TableConfig<ConsRow> = {
render: (v) => <TableTag {...statusColorMapping[v as ConsRussianStatus]} text={v as string} />,
},
// ГСК в версии не хранится (решение владельца П-3) — колонка как в макете [34], пустая
{ key: 'gsk', title: 'ГСК', features: {}, render: () => '—' },
{ key: 'created_at', title: 'Дата создания', features: { sort: true }, render: ruDate },
{ key: 'gsk', title: 'ГСК', features: { search: true, multiselect: true }, render: () => '—' },
{ key: 'created_at', title: 'Дата создания', features: {}, render: ruDate },
// даты верификации в API нет (VersionBrief), до маршрута согласования (Срез 7) — прочерк
{ key: 'verified_at', title: 'Дата верификации', features: {}, render: () => '—' },
{ key: 'members_count', title: 'Организаций', features: {} },

View File

@ -1,14 +1,4 @@
import {
BankOutlined,
ContainerOutlined,
InboxOutlined,
LeftOutlined,
PlusOutlined,
RightOutlined,
SolutionOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
import { LeftOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Divider, Layout, Tooltip, message } from 'antd';
import { type ReactNode, memo, useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
@ -20,6 +10,7 @@ import { useAppDispatch } from '../../store/store';
import { quarterLabel } from '../../utils/labels';
import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal';
import styles from './styles.module.css';
import { Archive, Building2, ScrollText, UserCog2, Users2 } from 'lucide-react';
type CollapsedIconProps = {
icon: ReactNode;
@ -51,7 +42,7 @@ function CollapsedIcon({ icon, onClick, title, to, cn = '' }: CollapsedIconProps
type NavItem = {
link: string;
label: string;
icon: ReactNode;
icon?: ReactNode;
/** Право из `/me`, без которого пункт не показывается; нет — пункт виден всем. */
permission?: string;
};
@ -69,12 +60,10 @@ const config: Group[] = [
children: [
{
link: '/groups',
icon: <BankOutlined />,
label: 'Группы компаний',
},
{
link: '/groups/monozaemchiki',
icon: <UserOutlined />,
label: 'Монозаемщики',
},
],
@ -85,12 +74,12 @@ const config: Group[] = [
children: [
{
link: '/catalogue/groups',
icon: <TeamOutlined />,
icon: <Building2 size={14} />,
label: 'ГК и организации',
},
{
link: '/catalogue/jur_l',
icon: <SolutionOutlined />,
icon: <Users2 size={14} />,
label: 'ЮЛ вне ГК',
},
],
@ -101,7 +90,7 @@ const config: Group[] = [
children: [
{
link: '/docstorage',
icon: <InboxOutlined />,
icon: <Archive size={14} />,
label: 'Единое хранилище',
},
],
@ -112,12 +101,12 @@ const config: Group[] = [
children: [
{
link: '/logging',
icon: <ContainerOutlined />,
icon: <ScrollText size={14} />,
label: 'Журнал действий',
},
{
link: '/admin',
icon: <TeamOutlined />,
icon: <UserCog2 size={14} />,
label: 'Пользователи и роли',
permission: USERS_ADMIN,
},