From d71c46e8465838b8f8d2d3ca4e0f7fefa6d5c004 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 01:15:55 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B9=20=D0=B8=20=C2=AB=D0=92=D0=B7=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B2=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=83=C2=BB=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B5=20(AURORA-1589,?= =?UTF-8?q?=20PR=20#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «Только в работе» выключен по умолчанию, параметр — boolean; ошибка загрузки списка показывается; пункты «⋯» disabled с подсказкой «Будет позже», клик не выбирает строку; «Взять в работу» неактивна по коду CANCELLED. Модалка [35]: опечатки, плейсхолдер, ≤ 1000 символов, «К3», ошибки (403 и прочие) — текст сервера, ФИО без второй точки; после взятия в работу перечитываются список и панель «Организации» («Ответственный»). Боковое меню: thunk списка с объектом, отступ секций. Порядок импортов (biome). Co-Authored-By: Claude Opus 5.5 --- .../GenericTable/ColumnSettings.tsx | 2 +- web/src/components/GenericTable/DataTable.tsx | 2 +- web/src/layouts/ConsolidationsList.tsx | 9 +- web/src/widgets/ConsoleTable/ConsoleTable.tsx | 135 +++++++----------- web/src/widgets/ConsoleTable/LockModal.tsx | 75 +++++----- web/src/widgets/Sidebar/Sidebar.tsx | 5 +- web/src/widgets/Sidebar/styles.module.css | 3 +- 7 files changed, 104 insertions(+), 127 deletions(-) diff --git a/web/src/components/GenericTable/ColumnSettings.tsx b/web/src/components/GenericTable/ColumnSettings.tsx index de6b2d0..524f497 100644 --- a/web/src/components/GenericTable/ColumnSettings.tsx +++ b/web/src/components/GenericTable/ColumnSettings.tsx @@ -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 { diff --git a/web/src/components/GenericTable/DataTable.tsx b/web/src/components/GenericTable/DataTable.tsx index 34d34a9..a845548 100644 --- a/web/src/components/GenericTable/DataTable.tsx +++ b/web/src/components/GenericTable/DataTable.tsx @@ -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'; diff --git a/web/src/layouts/ConsolidationsList.tsx b/web/src/layouts/ConsolidationsList.tsx index 42c0e70..52a952b 100644 --- a/web/src/layouts/ConsolidationsList.tsx +++ b/web/src/layouts/ConsolidationsList.tsx @@ -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, }}> - + {/* после «Взять в работу» перечитываем и панель «Организации»: в ней «Ответственный» */} + setReloadKey((k) => k + 1)} />
- Добавить версию - - ), - className: 'ant-dropdown-menu-item-disabled', - icon: , - }, - { - key: 'disable_confirm', - label: ( - - Отключить подтверждение КФО - - ), - className: 'ant-dropdown-menu-item-disabled', - icon: , - }, - { - key: 'history', - label: ( - - История изменений - - ), - className: 'ant-dropdown-menu-item-disabled', - icon: , - }, - {}, -] 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: ( + + {label} + + ), +}); + +// функций ещё нет: «История изменений» — после журнала с фильтрами [43-02], остальное — зонтичная задача экранов консолидаций +const ITEMS: MenuProps['items'] = [ + soonItem('copy', 'Добавить версию', ), + soonItem('disable_confirm', 'Отключить подтверждение КФО', ), + soonItem('history', 'История изменений', ), +]; + +const RowActions = memo(function RowActions() { return ( - { - domEvent.stopPropagation(); - onAction(key as ActionKey, row); - }, - }}> - @@ -179,7 +148,7 @@ export function ConsoleTableWidget() { iconPlacement='start' icon={} type='default' - disabled={row.status === 'Отменён'} + disabled={!canTakeInWork(row.status_code)} onClick={(e) => { e.stopPropagation(); setLockVersionId(row.id); @@ -198,13 +167,17 @@ export function ConsoleTableWidget() { }}> К отчётности - +
), }, ], - [navigate, group_id, handleDropdownAction, currentUser], + [navigate, group_id, currentUser], ); + const afterLock = useCallback(() => { + loadConsolidations(); + onVersionLocked?.(); + }, [loadConsolidations, onVersionLocked]); const onRow: TableProps['onRow'] = (row) => ({ // выбранная версия живёт в URL (?version=): переживает уход на «К отчётности» и «Назад» @@ -252,7 +225,7 @@ export function ConsoleTableWidget() { )} { setSearchParams( (searchParams) => { @@ -284,7 +257,7 @@ export function ConsoleTableWidget() { open={lockModalOpen} versionId={lockVersionId} onOpenChange={setLockModalOpen} - handleReload={loadConsolidations} + handleReload={afterLock} currentUserId={currentUser.id} /> diff --git a/web/src/widgets/ConsoleTable/LockModal.tsx b/web/src/widgets/ConsoleTable/LockModal.tsx index 06e7c05..eb87156 100644 --- a/web/src/widgets/ConsoleTable/LockModal.tsx +++ b/web/src/widgets/ConsoleTable/LockModal.tsx @@ -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(null); const [consolidationName, setConsolidationName] = useState(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 - Попробовать перезагрузить + Повторить } /> @@ -141,12 +143,13 @@ export default function LockModal({ versionId, open, onOpenChange, currentUserId {hasToReason && data ? ( <> -

Консолидация взята в работу {data.locked_by_name}. Укажите причину замены.

+

Консолидация взята в работу {endSentence(data.locked_by_name)} Укажите причину замены.

setReason(e.target.value)} /> diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index e61104f..5f8a7f0 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -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}`); diff --git a/web/src/widgets/Sidebar/styles.module.css b/web/src/widgets/Sidebar/styles.module.css index a407550..4082aa1 100644 --- a/web/src/widgets/Sidebar/styles.module.css +++ b/web/src/widgets/Sidebar/styles.module.css @@ -7,7 +7,8 @@ .section { margin-bottom: 0px; - /* padding: 12px; */ + /* отступ сверху и снизу, как в макете [34]; по горизонтали пункты выровнены по кнопке «Новая консолидация» */ + padding: 12px 0; border-radius: 10px; }