fix(web): список консолидаций и «Взять в работу» по спеке (AURORA-1589, PR #166)

«Только в работе» выключен по умолчанию, параметр — boolean; ошибка загрузки списка
показывается; пункты «⋯» disabled с подсказкой «Будет позже», клик не выбирает строку;
«Взять в работу» неактивна по коду CANCELLED. Модалка [35]: опечатки, плейсхолдер,
≤ 1000 символов, «К3», ошибки (403 и прочие) — текст сервера, ФИО без второй точки;
после взятия в работу перечитываются список и панель «Организации» («Ответственный»).
Боковое меню: thunk списка с объектом, отступ секций. Порядок импортов (biome).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 01:15:55 +04:00
parent ccca6dfa13
commit d71c46e846
7 changed files with 104 additions and 127 deletions

View File

@ -1,6 +1,6 @@
import { Settings2 } from 'lucide-react';
import { Button, Checkbox, Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import { Settings2 } from 'lucide-react';
import { type ColumnDef, isGroup } from './tableTypes';
interface Props<T> {

View File

@ -1,7 +1,7 @@
import { Filter } from 'lucide-react';
import { Table } from 'antd';
import type { TableProps } from 'antd';
import type { ColumnGroupType, ColumnType, ColumnsType } from 'antd/es/table';
import { Filter } from 'lucide-react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { CustomFilterDropdown } from './CustomFilterDropdown';
import { Highlight } from './Highlight';

View File

@ -5,16 +5,14 @@ import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
import { fetchOneCompanyGroup } from '../services/api/directories/company_groups';
import { setSelectedVersion } from '../store/slices/consolidations/consolidationsSlice';
import { getConsolidationsListThunk } from '../store/slices/consolidations/thunks';
import { useAppDispatch } from '../store/store';
import { ConsoleLegalEntitiesTable } from '../widgets/ConsoleLegalEntities/ConsoleLegalEntities';
import { ConsoleTableWidget } from '../widgets/ConsoleTable/ConsoleTable';
import { DocumentsDrawer } from '../widgets/FilesDrawer/FilesDrawer';
// unwrap() бросает rejectValue (строку), fetch группы — ApiError
// список консолидаций грузит ConsoleTableWidget (с фильтром «Только в работе»), здесь — только группа
function errorText(e: unknown): string {
if (e instanceof ApiError) return e.message;
if (typeof e === 'string') return e;
return 'Не удалось загрузить группу компаний';
}
@ -43,7 +41,7 @@ export function ConsolidationPage() {
}
}
getAllInfo(group_id);
}, [dispatch, group_id]);
}, [group_id]);
// выбор версии следует за ?version= и не перезагружает список
useEffect(() => {
@ -85,7 +83,8 @@ export function ConsolidationPage() {
flexDirection: 'column',
minHeight: 0,
}}>
<ConsoleTableWidget />
{/* после «Взять в работу» перечитываем и панель «Организации»: в ней «Ответственный» */}
<ConsoleTableWidget onVersionLocked={() => setReloadKey((k) => k + 1)} />
</div>
<div
style={{

View File

@ -1,85 +1,63 @@
import { DownOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Checkbox, Dropdown, Space, Tooltip, message } from 'antd';
import { Button, Checkbox, Dropdown, type MenuProps, Space, Tooltip, message } from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { ArrowRight, Briefcase, GitBranchPlus, History, MoreHorizontal, ToggleLeft } from 'lucide-react';
import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useCurrentUser } from '../../auth/MeContext';
import { ColumnSettings, DataTable, type TableConfig, isGroup, useFilteredRows, useVisibleColumns } from '../../components/GenericTable';
import { useTableFilters } from '../../components/GenericTable/useTableFilters';
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { ConsRow } from '../../types/consolidations/consolidations';
import { canTakeInWork, isLockedBy, parseInWorkOnly } from '../../utils/consolidations';
import LockModal from './LockModal';
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: (
<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;
const SOON = 'Будет позже';
const RowActions = memo(function RowActions({ row, onAction }: { row: ConsRow; onAction: (key: ActionKey, _row: ConsRow) => void }) {
// пункт неактивен (disabled — клик не обрабатывается), подсказка — на подписи
const soonItem = (key: string, label: string, icon: ReactNode) => ({
key,
disabled: true,
icon,
label: (
<Tooltip title={SOON}>
<span style={{ display: 'block', width: '100%' }}>{label}</span>
</Tooltip>
),
});
// функций ещё нет: «История изменений» — после журнала с фильтрами [43-02], остальное — зонтичная задача экранов консолидаций
const ITEMS: MenuProps['items'] = [
soonItem('copy', 'Добавить версию', <GitBranchPlus size={14} />),
soonItem('disable_confirm', 'Отключить подтверждение КФО', <ToggleLeft size={14} />),
soonItem('history', 'История изменений', <History size={14} />),
];
const RowActions = memo(function RowActions() {
return (
<Dropdown
trigger={['click']}
menu={{
items: ITEMS as any,
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
onAction(key as ActionKey, row);
},
}}>
<Button icon={<MoreHorizontal size={14} />} onClick={(e) => e.stopPropagation()} />
</Dropdown>
// меню рендерится в портал, но React-событие всплывает по дереву до onRow строки — гасим здесь
<span onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
<Dropdown trigger={['click']} menu={{ items: ITEMS }}>
<Button icon={<MoreHorizontal size={14} />} aria-label='Действия' />
</Dropdown>
</span>
);
});
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() {
/** `onVersionLocked` — версию взяли в работу: страница перечитывает панель «Организации» («Ответственный»). */
export function ConsoleTableWidget({ onVersionLocked }: { onVersionLocked?: () => void }) {
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;
// «Только в работе» по умолчанию выключен (макет [34], умолчание API — false)
const inWorkOnly = parseInWorkOnly(searchParams.get('in_work_only'));
const currentUser = useCurrentUser();
const dispatch = useAppDispatch();
@ -96,11 +74,12 @@ export function ConsoleTableWidget() {
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;
try {
await dispatch(getConsolidationsListThunk({ company_group_id: group_id, in_work_only: inWorkOnly })).unwrap();
} catch (e: unknown) {
// unwrap() бросает rejectValue — текст ошибки сервера
message.error(typeof e === 'string' ? e : 'Не удалось загрузить консолидации');
}
await dispatch(getConsolidationsListThunk({ company_group_id: group_id, in_work_only })).unwrap();
}, [dispatch, group_id, inWorkOnly]);
useEffect(() => {
loadConsolidations();
@ -152,16 +131,6 @@ export function ConsoleTableWidget() {
const { visibleKeys, setVisibleKeys } = useVisibleColumns(config);
const handleDropdownAction = useCallback(
(key: ActionKey, _row: ConsRow) => {
if (key === 'history') {
navigate('/logging');
} else {
message.info('Будет позже');
}
},
[navigate],
);
const extraColumns: ColumnsType<ConsRow> = useMemo(
() => [
{
@ -170,7 +139,7 @@ export function ConsoleTableWidget() {
width: 120,
render: (_: unknown, row: ConsRow) => (
<div style={{ display: 'flex', gap: 4 }}>
{isLockedByCurrentUser(currentUser, row.lock) ? (
{isLockedBy(row.lock, currentUser.id) ? (
<Button disabled icon={<Briefcase size={16} />} type='default'>
В работе
</Button>
@ -179,7 +148,7 @@ export function ConsoleTableWidget() {
iconPlacement='start'
icon={<Briefcase size={16} />}
type='default'
disabled={row.status === 'Отменён'}
disabled={!canTakeInWork(row.status_code)}
onClick={(e) => {
e.stopPropagation();
setLockVersionId(row.id);
@ -198,13 +167,17 @@ export function ConsoleTableWidget() {
}}>
К отчётности
</Button>
<RowActions row={row} onAction={handleDropdownAction} />
<RowActions />
</div>
),
},
],
[navigate, group_id, handleDropdownAction, currentUser],
[navigate, group_id, currentUser],
);
const afterLock = useCallback(() => {
loadConsolidations();
onVersionLocked?.();
}, [loadConsolidations, onVersionLocked]);
const onRow: TableProps<ConsRow>['onRow'] = (row) => ({
// выбранная версия живёт в URL (?version=): переживает уход на «К отчётности» и «Назад»
@ -252,7 +225,7 @@ export function ConsoleTableWidget() {
</Button>
)}
<Checkbox
checked={searchParams.get('in_work_only') === 'false' ? false : true}
checked={inWorkOnly}
onChange={(e) => {
setSearchParams(
(searchParams) => {
@ -284,7 +257,7 @@ export function ConsoleTableWidget() {
open={lockModalOpen}
versionId={lockVersionId}
onOpenChange={setLockModalOpen}
handleReload={loadConsolidations}
handleReload={afterLock}
currentUserId={currentUser.id}
/>
</div>

View File

@ -1,8 +1,11 @@
import { Alert, Button, Input, Modal, Skeleton, message } from 'antd';
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 { useBeforeUnload } from '../../hooks/useBeforeUnload';
import { getVersionCard, lockVersion } from '../../services/api/consolidations';
import type { LockOut } from '../../types/consolidations/common_types';
import { MAX_REASON_LENGTH, endSentence, isValidReason } from '../../utils/consolidations';
import { quarterShortLabel } from '../../utils/labels';
type LockModalProps = {
versionId: string | null;
@ -11,12 +14,8 @@ type LockModalProps = {
currentUserId: string;
handleReload: () => void;
};
function isValidReason(reason: string) {
const trimmed = reason.trim();
if (trimmed.length > 0 && trimmed.length < 1000) return true;
return false;
}
/** «Взять консолидацию в работу?» [35]: свободная версия — подтверждение, чужая — перехват с причиной. */
export default function LockModal({ versionId, open, onOpenChange, currentUserId, handleReload }: LockModalProps) {
const [data, setData] = useState<LockOut | null>(null);
const [consolidationName, setConsolidationName] = useState<string | null>(null);
@ -27,11 +26,14 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
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]);
// введённая причина перехвата — несохранённая правка: браузер предупредит при закрытии вкладки
useBeforeUnload(open && reason.trim() !== '');
const load = useCallback(async () => {
if (!versionId) return;
const my = ++reqId.current;
@ -39,16 +41,14 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
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);
if (my !== reqId.current) return; // успел прийти ответ на более новый запрос
setData(currentCard.lock);
setConsolidationName(`${year} ${quarterShortLabel(quarter)} · ${company_group.name}`);
} catch (e) {
if (my !== reqId.current) return;
if (e instanceof ApiError && e.status === 404) {
message.warning('Версия консолидации не обнаржуена');
message.warning('Версия консолидации не обнаружена');
close();
return;
}
@ -58,46 +58,47 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
}
}, [versionId, close]);
// Reset + fetch on every open, and on target change while open.
// Сброс и загрузка при каждом открытии и при смене версии в открытой модалке (load меняется вместе с versionId).
useEffect(() => {
if (!open) return;
const requests = reqId; // счётчик запросов, а не DOM-узел: в cleanup нужен его актуальный .current
setReason('');
setConflict(false);
setData(null);
setLoadError(false);
load();
return () => {
reqId.current++; // invalidate in-flight response on close/switch
requests.current++; // ответ в полёте больше не нужен
};
}, [open, versionId, load]);
}, [open, load]);
const hasToReason = !loading && data !== null && data.locked_by !== currentUserId;
const handleOk = async () => {
if (!versionId || loading || submitting) return;
if (hasToReason && !reason.trim()) return;
if (hasToReason && !isValidReason(reason)) return;
setSubmitting(true);
try {
const result = await lockVersion(versionId, hasToReason ? reason.trim() : null);
await lockVersion(versionId, hasToReason ? reason.trim() : null);
close();
message.success('Консолидация взята в работу.');
handleReload();
} 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('');
}
if (e instanceof ApiError && e.code === 'VERSION_NOT_EDITABLE') {
message.error('Нельзя взять в работу отменённую версию.');
close();
handleReload();
} else if (e instanceof ApiError && e.code === 'LOCKED_BY_OTHER' && !conflict) {
// версию взяли, пока модалка была открыта, — режим перехвата
await load();
setConflict(true);
setReason('');
} else {
message.error('Что-то пошло не так. Попробуйте снова.');
// 403 без права, прочие 409/422 — текст сервера
message.error(e instanceof ApiError ? e.message : 'Не удалось взять консолидацию в работу');
}
} finally {
setSubmitting(false);
handleReload();
}
};
@ -110,6 +111,7 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
closable={!submitting}
okText='Взять в работу'
okButtonProps={{ disabled: loading || loadError || (hasToReason && !isValidReason(reason)) }}
confirmLoading={submitting}
cancelText='Отмена'
onOk={handleOk}
cancelButtonProps={{ disabled: submitting }}>
@ -119,10 +121,10 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
<Alert
type='error'
showIcon
title='Не получилось загрузить нформацию о текущей блокировке'
title='Не удалось загрузить информацию о текущей блокировке'
action={
<Button size='small' onClick={load}>
Попробовать перезагрузить
Повторить
</Button>
}
/>
@ -141,12 +143,13 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId
{hasToReason && data ? (
<>
<p style={{ marginTop: 0 }}>Консолидация взята в работу {data.locked_by_name}. Укажите причину замены.</p>
<p style={{ marginTop: 0 }}>Консолидация взята в работу {endSentence(data.locked_by_name)} Укажите причину замены.</p>
<Input.TextArea
autoFocus
placeholder='Type message'
placeholder='Укажите причину замены'
value={reason}
maxLength={1000}
maxLength={MAX_REASON_LENGTH}
showCount
rows={3}
onChange={(e) => setReason(e.target.value)}
/>

View File

@ -1,5 +1,6 @@
import { LeftOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Divider, Layout, Tooltip, message } from 'antd';
import { Archive, Building2, ScrollText, UserCog2, Users2 } from 'lucide-react';
import { type ReactNode, memo, useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import { USERS_ADMIN } from '../../auth/permissions';
@ -10,7 +11,6 @@ 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;
@ -205,7 +205,8 @@ export function Sidebar() {
onOk={async (values) => {
const card = await createConsolidation(values);
// перечитываем список: на странице группы он сам не обновится, и новая консолидация не станет текущей
await dispatch(getConsolidationsListThunk(values.company_group_id));
// in_work_only: false — переход ниже открывает список без ?in_work_only (флажок выключен)
await dispatch(getConsolidationsListThunk({ company_group_id: values.company_group_id, in_work_only: false }));
setModalOpen(false);
message.success(`Создана консолидация ${values.year}, ${quarterLabel(values.quarter)}`);
navigate(`/groups/${values.company_group_id}?version=${card.id}`);

View File

@ -7,7 +7,8 @@
.section {
margin-bottom: 0px;
/* padding: 12px; */
/* отступ сверху и снизу, как в макете [34]; по горизонтали пункты выровнены по кнопке «Новая консолидация» */
padding: 12px 0;
border-radius: 10px;
}