From 5ee54bd94f29061e1aee0ab90a62fabc1d4be5d6 Mon Sep 17 00:00:00 2001 From: maxim Date: Thu, 22 Oct 2026 14:47:11 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8?= =?UTF-8?q?,=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D0=B0=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D1=8B=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20'=D0=A2?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B5'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/package.json | 1 + .../GenericTable/ColumnSettings.tsx | 4 +- web/src/components/GenericTable/DataTable.tsx | 4 +- web/src/layouts/ConsolidationsList.tsx | 1 - web/src/services/api/consolidations.ts | 11 +- web/src/store/slices/consolidations/thunks.ts | 21 +-- web/src/widgets/ConsoleTable/ConsoleTable.tsx | 115 +++++++++++-- web/src/widgets/ConsoleTable/LockModal.tsx | 161 ++++++++++++++++++ web/src/widgets/ConsoleTable/config.tsx | 6 +- web/src/widgets/Sidebar/Sidebar.tsx | 27 +-- 10 files changed, 301 insertions(+), 50 deletions(-) create mode 100644 web/src/widgets/ConsoleTable/LockModal.tsx diff --git a/web/package.json b/web/package.json index 44f8405..962d724 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/GenericTable/ColumnSettings.tsx b/web/src/components/GenericTable/ColumnSettings.tsx index 34f9cce..de6b2d0 100644 --- a/web/src/components/GenericTable/ColumnSettings.tsx +++ b/web/src/components/GenericTable/ColumnSettings.tsx @@ -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({ defs, visibleKeys, onChange }: Props) { }); return ( - + ); } diff --git a/web/src/components/GenericTable/DataTable.tsx b/web/src/components/GenericTable/DataTable.tsx index b947091..34d34a9 100644 --- a/web/src/components/GenericTable/DataTable.tsx +++ b/web/src/components/GenericTable/DataTable.tsx @@ -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>({ color: active ? 'rgb(22, 101, 52)' : '#bfbfbf', transition: 'background 0.15s', }}> - + ); }, diff --git a/web/src/layouts/ConsolidationsList.tsx b/web/src/layouts/ConsolidationsList.tsx index d0b3060..42c0e70 100644 --- a/web/src/layouts/ConsolidationsList.tsx +++ b/web/src/layouts/ConsolidationsList.tsx @@ -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)); } diff --git a/web/src/services/api/consolidations.ts b/web/src/services/api/consolidations.ts index 28e1220..af48a51 100644 --- a/web/src/services/api/consolidations.ts +++ b/web/src/services/api/consolidations.ts @@ -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(`/company-groups/${groupId}/consolidations`); +export const getConsolidations = (groupId: string, in_work_only: boolean) => + api.get(`/company-groups/${groupId}/consolidations?in_work_only=${in_work_only}`); export const getVersionCard = (versionId: string) => api.get(`/consolidations/versions/${versionId}`); export const createConsolidation = (body: ConsolidationCreate) => api.post('/consolidations', body); export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`); export const getChecklist = (versionId: string) => api.get(`/consolidations/versions/${versionId}/checklist`); /** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */ -export const lockVersion = (versionId: string) => api.post(`/consolidations/versions/${versionId}/lock`); +export const lockVersion = (versionId: string, reason?: string | null) => { + const body = {}; + if (reason) { + body['takeover_reason'] = reason; + } + return api.post(`/consolidations/versions/${versionId}/lock`, body); +}; diff --git a/web/src/store/slices/consolidations/thunks.ts b/web/src/store/slices/consolidations/thunks.ts index 75b519d..6bb0593 100644 --- a/web/src/store/slices/consolidations/thunks.ts +++ b/web/src/store/slices/consolidations/thunks.ts @@ -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( - '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 : 'Ошибка'); + } +}); diff --git a/web/src/widgets/ConsoleTable/ConsoleTable.tsx b/web/src/widgets/ConsoleTable/ConsoleTable.tsx index ee14dd2..714e931 100644 --- a/web/src/widgets/ConsoleTable/ConsoleTable.tsx +++ b/web/src/widgets/ConsoleTable/ConsoleTable.tsx @@ -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: ( + + Добавить версию + + ), + 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; @@ -31,19 +64,29 @@ const RowActions = memo(function RowActions({ row, onAction }: { row: ConsRow; o onAction(key as ActionKey, row); }, }}> - + ) : ( + + )} ), }, ], - [navigate, group_id, handleDropdownAction], + [navigate, group_id, handleDropdownAction, currentUser], ); const onRow: TableProps['onRow'] = (row) => ({ @@ -171,6 +244,19 @@ export function ConsoleTableWidget() { Сбросить фильтры )} + { + setSearchParams( + (searchParams) => { + searchParams.set('in_work_only', String(e.target.checked)); + return searchParams; + }, + { replace: true }, + ); + }}> + Только в работе + @@ -187,6 +273,13 @@ export function ConsoleTableWidget() { extraColumns={extraColumns} onRow={onRow} /> + ); } diff --git a/web/src/widgets/ConsoleTable/LockModal.tsx b/web/src/widgets/ConsoleTable/LockModal.tsx new file mode 100644 index 0000000..06e7c05 --- /dev/null +++ b/web/src/widgets/ConsoleTable/LockModal.tsx @@ -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(null); + const [consolidationName, setConsolidationName] = useState(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 ( + !submitting && close()} + centered + closable={!submitting} + okText='Взять в работу' + okButtonProps={{ disabled: loading || loadError || (hasToReason && !isValidReason(reason)) }} + cancelText='Отмена' + onOk={handleOk} + cancelButtonProps={{ disabled: submitting }}> + {loading && } + + {!loading && loadError && ( + + Попробовать перезагрузить + + } + /> + )} + + {!loading && !loadError && ( + <> + {conflict && hasToReason && data && ( + + )} + + {hasToReason && data ? ( + <> +

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

+ setReason(e.target.value)} + /> + + ) : ( +

Консолидация {consolidationName} будет закреплена за вами для редактирования.

+ )} + + )} +
+ ); +} diff --git a/web/src/widgets/ConsoleTable/config.tsx b/web/src/widgets/ConsoleTable/config.tsx index 6c8b354..0e580e0 100644 --- a/web/src/widgets/ConsoleTable/config.tsx +++ b/web/src/widgets/ConsoleTable/config.tsx @@ -15,7 +15,7 @@ export const consConfigBase: TableConfig = { { 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 = { render: (v) => , }, // ГСК в версии не хранится (решение владельца П-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: {} }, diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index a57a7e2..e61104f 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -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: , label: 'Группы компаний', }, { link: '/groups/monozaemchiki', - icon: , label: 'Монозаемщики', }, ], @@ -85,12 +74,12 @@ const config: Group[] = [ children: [ { link: '/catalogue/groups', - icon: , + icon: , label: 'ГК и организации', }, { link: '/catalogue/jur_l', - icon: , + icon: , label: 'ЮЛ вне ГК', }, ], @@ -101,7 +90,7 @@ const config: Group[] = [ children: [ { link: '/docstorage', - icon: , + icon: , label: 'Единое хранилище', }, ], @@ -112,12 +101,12 @@ const config: Group[] = [ children: [ { link: '/logging', - icon: , + icon: , label: 'Журнал действий', }, { link: '/admin', - icon: , + icon: , label: 'Пользователи и роли', permission: USERS_ADMIN, },