From a7600444e6a74ffee5b97e7644135e5c9d32c0fe Mon Sep 17 00:00:00 2001 From: maxim Date: Mon, 19 Oct 2026 20:38:41 +0300 Subject: [PATCH 01/13] =?UTF-8?q?fix:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=9E=D1=80=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConsoleLegalEntities.tsx | 56 +++++++++++++++++-- .../widgets/ConsoleLegalEntities/config.tsx | 2 +- .../widgets/ConsoleLegalEntities/consts.ts | 2 +- web/src/widgets/Sidebar/styles.module.css | 2 +- 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx b/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx index 44aa8fa..6fabf87 100644 --- a/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx +++ b/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx @@ -64,9 +64,8 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe onDrawerOpen(); }, }); - - return ( - <> + if (consolidationData === null) { + return (

Организации

- {rawRows.length} шт. + Выберите консолидацию +
+
+
+ Выберите консолидацию выше +
+
+ ); + } + + return ( + <> +
+ +
+
+

+ Организации +

+ {rawRows.length} шт. +
+ {consolidationData && ( // «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7 - Ответственный: {responsibleName(consolidationData)}   Утверждён в РФ: —   Верифицирован в ГО: — - +
)}
diff --git a/web/src/widgets/ConsoleLegalEntities/config.tsx b/web/src/widgets/ConsoleLegalEntities/config.tsx index b49ee8c..3660861 100644 --- a/web/src/widgets/ConsoleLegalEntities/config.tsx +++ b/web/src/widgets/ConsoleLegalEntities/config.tsx @@ -47,7 +47,7 @@ export const membersConfig: TableConfig = { key: 'uploaded_osvs', title: 'ОСВ', features: { multiselect: true }, - render: (v) => , + render: (v) => , }, { key: 'inn', title: 'ИНН', features: { search: true, multiselect: true } }, diff --git a/web/src/widgets/ConsoleLegalEntities/consts.ts b/web/src/widgets/ConsoleLegalEntities/consts.ts index e99f47c..bada87e 100644 --- a/web/src/widgets/ConsoleLegalEntities/consts.ts +++ b/web/src/widgets/ConsoleLegalEntities/consts.ts @@ -13,7 +13,7 @@ export const statusMapping: Record = { export const statusColorMapping: Record = { 'В обработке': TAG_PRESETS.geekblue, Загружен: TAG_PRESETS.green, - Отсутствует: TAG_PRESETS.red, + Отсутствует: TAG_PRESETS.default, Ошибка: TAG_PRESETS.red, Распознан: TAG_PRESETS.gold, 'Требуется проверка': TAG_PRESETS.orange, diff --git a/web/src/widgets/Sidebar/styles.module.css b/web/src/widgets/Sidebar/styles.module.css index 036dcd9..a407550 100644 --- a/web/src/widgets/Sidebar/styles.module.css +++ b/web/src/widgets/Sidebar/styles.module.css @@ -7,7 +7,7 @@ .section { margin-bottom: 0px; - padding: 12px; + /* padding: 12px; */ border-radius: 10px; } -- 2.47.2 From 2e75de33cc40a8982483ce8641b44073ab54cb25 Mon Sep 17 00:00:00 2001 From: maxim Date: Tue, 20 Oct 2026 13:14:23 +0300 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20=D0=B2=D1=8B=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=98=D0=9D=D0=9D?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8,?= =?UTF-8?q?=20+=20=D0=B8=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D1=8B=20=D0=B7?= =?UTF-8?q?=D0=B2=D1=91=D0=B7=D0=B4=D1=8B=20=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NewConsoleModal/CompanyOptionRow.tsx | 120 ++++++++++++++++++ .../NewConsoleModal/NewConsoleModal.tsx | 93 +------------- 2 files changed, 127 insertions(+), 86 deletions(-) create mode 100644 web/src/widgets/NewConsoleModal/CompanyOptionRow.tsx diff --git a/web/src/widgets/NewConsoleModal/CompanyOptionRow.tsx b/web/src/widgets/NewConsoleModal/CompanyOptionRow.tsx new file mode 100644 index 0000000..ab95355 --- /dev/null +++ b/web/src/widgets/NewConsoleModal/CompanyOptionRow.tsx @@ -0,0 +1,120 @@ +import { memo, useCallback, useMemo, useState } from 'react'; +import { LegalEntityBrief } from '../../types/api'; +import { Checkbox, Empty, Input, Spin } from 'antd'; + +type RowProps = { + entity: LegalEntityBrief; + checked: boolean; + onToggle: (id: string) => void; +}; +const CompanyOptionRow = memo(({ entity, checked, onToggle }: RowProps) => ( +
onToggle(entity.id)} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + onToggle(entity.id); + } + }} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + minWidth: 0, + gap: 12, + padding: '10px 8px', + borderRadius: 4, + borderBottom: '1px solid rgba(0, 0, 0, 0.05)', + cursor: 'pointer', + boxSizing: 'border-box', + }}> + + + {entity.name} + + + {entity.inn} +
+)); + +type CompanyMultiSelectProps = { + companies: LegalEntityBrief[]; + loading?: boolean; + // injected by Form.Item: + value?: string[]; + onChange?: (value: string[]) => void; +}; + +export const CompanyMultiSelect: React.FC = ({ companies, loading, value = [], onChange }) => { + const [search, setSearch] = useState(''); + + const toggle = useCallback( + (id: string) => { + const next = value.includes(id) ? value.filter((v) => v !== id) : [...value, id]; + onChange?.(next); + }, + [value, onChange], + ); + + const filtered = useMemo(() => { + if (!search) return companies; + const q = search.toLowerCase(); + return companies.filter((s) => s.name.toLowerCase().includes(q)); + }, [companies, search]); + + return ( +
+ setSearch(e.target.value)} + style={{ marginBottom: 12 }} + /> + + {loading ? ( +
+ +
+ ) : null} + {!loading && filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map((s) => ( + + ))} +
+ )} + + {!loading && companies.length > 0 && ( +
+ + Выбрано: {value.length} / {companies.length} + +
+ )} +
+ ); +}; diff --git a/web/src/widgets/NewConsoleModal/NewConsoleModal.tsx b/web/src/widgets/NewConsoleModal/NewConsoleModal.tsx index 843001a..e11c0b7 100644 --- a/web/src/widgets/NewConsoleModal/NewConsoleModal.tsx +++ b/web/src/widgets/NewConsoleModal/NewConsoleModal.tsx @@ -1,4 +1,4 @@ -import { Checkbox, Col, Empty, Form, Input, Modal, Row, Select, Spin, message } from 'antd'; +import { Col, Form, Modal, Row, Select, message } from 'antd'; import type React from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; @@ -7,6 +7,8 @@ import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thu import { useAppDispatch, useAppSelector } from '../../store/store'; import type { ConsolidationCreate, LegalEntityBrief, Quarter, RelatedGroupOut } from '../../types/api'; import { membersFirst, preselectMembers } from './preselect'; +import { PlusOutlined } from '@ant-design/icons'; +import { CompanyMultiSelect } from './CompanyOptionRow'; type Props = { open: boolean; @@ -30,13 +32,11 @@ export const NewConsolidationModal: React.FC = ({ open, onCancel, onOk }) const relatedReqRef = useRef(0); const [submitting, setSubmitting] = useState(false); - const [legEntSearch, setLegEntSearch] = useState(''); - const company_group_id = Form.useWatch('company_group_id', form); const related_group_id = Form.useWatch('related_group_id', form); const year = Form.useWatch('year', form); const semester = Form.useWatch('quarter', form); - const legEntIds = Form.useWatch('legal_entity_ids', form); + const legEntIds = Form.useWatch('legal_entity_ids', form) ?? []; const quarterOptions: { value: Quarter; label: string }[] = [ { value: 'Q1', label: '1 квартал' }, @@ -64,7 +64,6 @@ export const NewConsolidationModal: React.FC = ({ open, onCancel, onOk }) relatedReqRef.current++; // сменили ГК: ответ по старой ГСК больше не нужен setLegEntities([]); setLoadingEntities(false); - setLegEntSearch(''); legEntReqRef.current++; }; @@ -114,7 +113,6 @@ export const NewConsolidationModal: React.FC = ({ open, onCancel, onOk }) setLoadingSubgroups(false); subgroupReqRef.current++; clearLegEnts(); - setLegEntSearch(''); }; const handleOk = async () => { @@ -142,25 +140,19 @@ export const NewConsolidationModal: React.FC = ({ open, onCancel, onOk }) onCancel(); }; - const filteredLegEnts = useMemo(() => { - if (!legEntSearch) return legEntities; - const q = legEntSearch.toLowerCase(); - return legEntities.filter((s) => s.name.toLowerCase().includes(q)); - }, [legEntities, legEntSearch]); - return ( }} okText='Создать' cancelText='Отмена' confirmLoading={submitting} destroyOnHidden width={640}> -
+ @@ -205,78 +197,7 @@ export const NewConsolidationModal: React.FC = ({ open, onCancel, onOk }) {related_group_id && ( - -
- setLegEntSearch(e.target.value)} - style={{ marginBottom: 12 }} - /> - - {loadingEntities ? ( -
- -
- ) : null} - {!loadingEntities && filteredLegEnts.length === 0 && ( - - )} - {!loadingEntities && filteredLegEnts.length > 0 && ( -
- {filteredLegEnts.map((s) => ( - -
- {s.name} - - {s.inn} - -
-
- ))} -
- )} - - {!loadingEntities && legEntities.length > 0 && ( -
- - Выбрано: {legEntIds?.length ?? 0} / {legEntities.length} - -
- )} -
-
+
)} -- 2.47.2 From 5ee54bd94f29061e1aee0ab90a62fabc1d4be5d6 Mon Sep 17 00:00:00 2001 From: maxim Date: Thu, 22 Oct 2026 14:47:11 +0300 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B8,=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D0=B0=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=20'=D0=A2=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=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, }, -- 2.47.2 From 723c84cc720a7f676f1819b32e7fe4a26a528196 Mon Sep 17 00:00:00 2001 From: maxim Date: Thu, 22 Oct 2026 19:11:38 +0300 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/layouts/ConsolidatedOsvPage.tsx | 48 +++++++- web/src/services/api/consolidations.ts | 5 +- web/src/types/api.ts | 7 ++ web/src/types/consolidations/common_types.ts | 2 +- web/src/widgets/ConsoleTable/consts.ts | 4 +- .../StatusChangeModal/ChangeStatusModal.tsx | 111 ++++++++++++++++++ 6 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx diff --git a/web/src/layouts/ConsolidatedOsvPage.tsx b/web/src/layouts/ConsolidatedOsvPage.tsx index f9dcd0e..f76f74d 100644 --- a/web/src/layouts/ConsolidatedOsvPage.tsx +++ b/web/src/layouts/ConsolidatedOsvPage.tsx @@ -1,6 +1,6 @@ import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; -import { Alert, Button, Space, message } from 'antd'; -import { useEffect, useState } from 'react'; +import { Alert, Button, Space, Tooltip, message } from 'antd'; +import { useCallback, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../api/client'; import PageTitle from '../components/PageTitle'; @@ -20,13 +20,17 @@ import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable'; import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping'; import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv'; +import { useCurrentUser } from '../auth/MeContext'; +import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; function OsvContent({ versionId }: { versionId: string }) { const [card, setCard] = useState(null); + const [statusModalOpen, setModalOpen] = useState(false); const [exporting, setExporting] = useState(false); // строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ) const [mapping, setMapping] = useState(null); const osv = useConsolidatedOsv(versionId); + const currentUser = useCurrentUser(); useEffect(() => { let actual = true; @@ -70,9 +74,37 @@ function OsvContent({ versionId }: { versionId: string }) { description={russianStatus} extra={ card && ( - + Статус отчёта - + + {card.lock === null || (card.lock && card.lock.locked_by !== currentUser.id) ? ( + + + + ) : ( + + )} ) } @@ -144,6 +176,14 @@ function OsvContent({ versionId }: { versionId: string }) { onMapped={osv.refresh} /> )} + { + setModalOpen(false); + }} + /> ); } diff --git a/web/src/services/api/consolidations.ts b/web/src/services/api/consolidations.ts index af48a51..577f318 100644 --- a/web/src/services/api/consolidations.ts +++ b/web/src/services/api/consolidations.ts @@ -1,5 +1,5 @@ import { api, apiFetchBlob } from '../../api/client'; -import type { ConsolidationCreate } from '../../types/api'; +import type { ConsolidationCreate, VersionStatusUpdate } from '../../types/api'; import type { LockOut } from '../../types/consolidations/common_types'; import type { IConsolidation } from '../../types/consolidations/consolidations'; import type { ChecklistBackendShape, VersionCard } from '../../types/consolidations/version_members'; @@ -18,3 +18,6 @@ export const lockVersion = (versionId: string, reason?: string | null) => { } return api.post(`/consolidations/versions/${versionId}/lock`, body); }; + +export const updateStatus = (versionId: string, body: VersionStatusUpdate) => + api.post(`/consolidation/versions/${versionId}/status`, body); diff --git a/web/src/types/api.ts b/web/src/types/api.ts index c6e0850..7b410ee 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -1,5 +1,7 @@ // DTO API arm-core (/api/v1). Повторяют arm-core/api/src/schemas/; Decimal и даты — строки. +import { ConsStatus } from './consolidations/common_types'; + export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4'; export type DocumentType = 'BALANCE' | 'INCOME' | 'OSV' | 'DISCLOSURE' | 'OTHER'; export type DocumentStatus = 'RECOGNIZED' | 'NEEDS_CHECK' | 'CONFIRMED' | 'ERROR'; @@ -394,3 +396,8 @@ export interface LegalEntityBrief { name: string; inn: string; } + +export interface VersionStatusUpdate { + to_status: ConsStatus; + comment: string; +} diff --git a/web/src/types/consolidations/common_types.ts b/web/src/types/consolidations/common_types.ts index cb9c08d..7d7b443 100644 --- a/web/src/types/consolidations/common_types.ts +++ b/web/src/types/consolidations/common_types.ts @@ -18,7 +18,7 @@ export type ConsRussianStatus = | 'Верификация РФ' | 'Инициация' | 'Обработка документов' - | 'Коррекция данных' + | 'Корректировка данных' | 'Консолидация ГО' | 'Консолидация РФ' | 'Отменён' diff --git a/web/src/widgets/ConsoleTable/consts.ts b/web/src/widgets/ConsoleTable/consts.ts index 590ae1e..1cb0a59 100644 --- a/web/src/widgets/ConsoleTable/consts.ts +++ b/web/src/widgets/ConsoleTable/consts.ts @@ -7,7 +7,7 @@ export const statusMapping: Record = { VERIFICATION_RF: 'Верификация РФ', INITIATED: 'Инициация', DOCUMENTS_PROCESSING: 'Обработка документов', - DATA_CORRECTION: 'Коррекция данных', + DATA_CORRECTION: 'Корректировка данных', CONSOLIDATION_GO: 'Консолидация ГО', CONSOLIDATION_RF: 'Консолидация РФ', CANCELLED: 'Отменён', @@ -25,7 +25,7 @@ export const statusColorMapping: Record = { 'Консолидация ГО': TAG_PRESETS.purple, 'Консолидация РФ': TAG_PRESETS.purple, Корректировка: TAG_PRESETS.gold, - 'Коррекция данных': TAG_PRESETS.gold, + 'Корректировка данных': TAG_PRESETS.gold, 'Обработка документов': TAG_PRESETS.gold, Отклонён: TAG_PRESETS.red, Отменён: TAG_PRESETS.red, diff --git a/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx b/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx new file mode 100644 index 0000000..9ee315c --- /dev/null +++ b/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx @@ -0,0 +1,111 @@ +import { Form, Input, message, Modal, Select } from 'antd'; +import { useEffect, useState } from 'react'; +import { ConsStatus } from '../../types/consolidations/common_types'; +import { VersionStatusUpdate } from '../../types/api'; +import { getVersionCard, updateStatus } from '../../services/api/consolidations'; +import { statusColorMapping, statusMapping } from '../ConsoleTable/consts'; +import { TableTag } from '../../components/TableTag/TableTag'; +import { TAG_PRESETS } from '../../components/TableTag/presets'; +import { ApiError } from '../../api/client'; +import { VersionCard } from '../../types/consolidations/version_members'; + +type StatusModalProps = { + open: boolean; + versionId: string; + setClose: () => void; + onSuccess: (card: VersionCard) => void; +}; +const known409Codes = ['VERSION_NOT_EDITABLE', 'LOCK_REQUIRED', 'LOCKED_BY_OTHER', 'TRANSITION_NOT_ALLOWED']; +const errorCodeMap = { + VERSION_NOT_EDITABLE: 'Версию нельзя отредактировать', + LOCK_REQUIRED: 'Сначала возьмите версию в работу', + LOCKED_BY_OTHER: 'Версия находится в работе у другого пользователя', + TRANSITION_NOT_ALLOWED: 'Данный переход недоступен', +}; + +/* +"Инициация", "Обработка документов", "Корректировка данных" -> "Отменена" (комментарий обязателен); + из "Обработка документов", "Корректировка данных" -> "Инициация" (комментарий обязателен во фронте) +*/ + +async function mockStatusMappingFetch() { + const mapping: Record = { + INITIATED: ['CANCELLED'], + DOCUMENTS_PROCESSING: ['CANCELLED', 'INITIATED'], + DATA_CORRECTION: ['CANCELLED', 'INITIATED'], + }; + return mapping; +} + +export default function ChangeStatusModal({ open, versionId, setClose, onSuccess }: StatusModalProps) { + const [currentStatus, setCurrentStatus] = useState(null); + const [nextStatusOptions, setNextStatusOptions] = useState([]); + const [loading, setLoading] = useState(false); + const [form] = Form.useForm(); + + const nextStatus = Form.useWatch('to_status', form); + const comment = Form.useWatch('comment', form); + useEffect(() => { + async function getAllData() { + if (!versionId) return; + setLoading(true); + const versionCard = await getVersionCard(versionId); + const status = versionCard.status; + const allNextOptions = await mockStatusMappingFetch(); + setCurrentStatus(status); + setNextStatusOptions(allNextOptions[status]); + + setLoading(false); + } + getAllData(); + }, [versionId]); + const russianStatus = currentStatus ? statusMapping[currentStatus] : ''; + const badgePreset = russianStatus ? statusColorMapping[russianStatus] : TAG_PRESETS.default; + const isSubmitDisabled = !nextStatus || !comment.trim(); + + const handleOk = async () => { + const values = await form.validateFields(); + try { + const newCard = await updateStatus(versionId, { ...values, comment: values.comment.trim() }); + onSuccess(newCard); + setClose(); + } catch (e) { + if (e instanceof ApiError && e.status === 409 && e.code) { + if (known409Codes.includes(e.code)) { + message.error(errorCodeMap[e.code]); + } + } else { + message.error('Непредвиденная ошибка'); + } + } + }; + + return ( + +
+ Текущий этап: +
+ + + + + + + + + +
+ ); +} -- 2.47.2 From 71ef6333b53287c421fc920c2f204d0cc1efa782 Mon Sep 17 00:00:00 2001 From: maxim Date: Thu, 22 Oct 2026 23:14:21 +0300 Subject: [PATCH 05/13] =?UTF-8?q?fix(1539):=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=9E=D0=A1=D0=92,=20=D0=BF=D0=BE=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=BA=D1=80=D1=83=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/layouts/ConsolidatedOsvPage.tsx | 30 +++- .../ConsoleLegalEntities/checklist_utils.tsx | 4 +- .../widgets/ConsoleLegalEntities/config.tsx | 5 +- .../widgets/ConsoleLegalEntities/consts.ts | 4 +- .../widgets/ConsoleLegalEntities/normalize.ts | 6 +- web/src/widgets/ConsoleTable/ConsoleTable.tsx | 9 +- web/src/widgets/ConsoleTable/consts.ts | 9 + web/src/widgets/ConsolidatedOsv/OsvTable.tsx | 159 ++++++++++-------- web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 2 +- .../ConsolidatedOsv/useNumberDisplay.tsx | 42 +++++ .../StatusChangeModal/ChangeStatusModal.tsx | 29 ++-- 11 files changed, 195 insertions(+), 104 deletions(-) create mode 100644 web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx diff --git a/web/src/layouts/ConsolidatedOsvPage.tsx b/web/src/layouts/ConsolidatedOsvPage.tsx index f76f74d..485db65 100644 --- a/web/src/layouts/ConsolidatedOsvPage.tsx +++ b/web/src/layouts/ConsolidatedOsvPage.tsx @@ -1,6 +1,6 @@ import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; -import { Alert, Button, Space, Tooltip, message } from 'antd'; -import { useCallback, useEffect, useState } from 'react'; +import { Alert, Button, InputNumber, Select, Space, Tooltip, message } from 'antd'; +import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../api/client'; import PageTitle from '../components/PageTitle'; @@ -13,7 +13,7 @@ import type { ConsRussianStatus } from '../types/consolidations/common_types'; import type { VersionCard } from '../types/consolidations/version_members'; import { exportFilename, saveBlob } from '../utils/download'; import { quarterShortLabel } from '../utils/labels'; -import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts'; +import { consStatusDescriptionMapping, statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts'; import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal'; import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps'; import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable'; @@ -22,6 +22,7 @@ import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping'; import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv'; import { useCurrentUser } from '../auth/MeContext'; import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; +import { UNITS, useNumberDisplay } from '../widgets/ConsolidatedOsv/useNumberDisplay'; function OsvContent({ versionId }: { versionId: string }) { const [card, setCard] = useState(null); @@ -31,6 +32,7 @@ function OsvContent({ versionId }: { versionId: string }) { const [mapping, setMapping] = useState(null); const osv = useConsolidatedOsv(versionId); const currentUser = useCurrentUser(); + const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay(); useEffect(() => { let actual = true; @@ -62,6 +64,7 @@ function OsvContent({ versionId }: { versionId: string }) { const unmapped = s?.unmapped_total ?? 0; const onlyUnmapped = osv.filters.unmapped === true; const russianStatus = card ? (statusMapping[card.status] ?? card.status) : ''; + const description = card ? (consStatusDescriptionMapping[card.status] ?? russianStatus) : ''; const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default; const groupName = card?.company_group.name ?? ''; const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : ''; @@ -71,7 +74,7 @@ function OsvContent({ versionId }: { versionId: string }) { backable location={['Отчётность', groupName, period]} header={card ? `${groupName} — ${card.year} ${quarterShortLabel(card.quarter)}` : 'Сводная ОСВ'} - description={russianStatus} + description={description} extra={ card && ( @@ -121,7 +124,23 @@ function OsvContent({ versionId }: { versionId: string }) { flexDirection: 'column', gap: 12, }}> -
+
+
+
+
Единицы
+ ({ label: i, value: i }))} + value={precision} + onChange={(v) => setPrecision(v ?? 2)} + style={{ width: 90 }} + /> +
+
@@ -163,6 +182,7 @@ function OsvContent({ versionId }: { versionId: string }) { filters={osv.filters} members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))} onPage={osv.setPage} + numFormat={format} onFilter={osv.patchFilters} onMap={setMapping} /> diff --git a/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx b/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx index 606d72b..143e2e8 100644 --- a/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx +++ b/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx @@ -10,7 +10,7 @@ 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'; +import { fileStatusMapping } from './consts'; /** Строка таблицы чек-листа: ЮЛ и статусы ББ, ОФР и ОСВ по счетам. */ export type ChecklistTableRow = { @@ -60,7 +60,7 @@ export function getMarksItems() { fontSize: 11, }}> Условные обозначения - {(Object.entries(statusMapping) as [FileStatus, string][]).map(([status, label]) => ( + {(Object.entries(fileStatusMapping) as [FileStatus, string][]).map(([status, label]) => (
{mapStatus(status)} {label}
diff --git a/web/src/widgets/ConsoleLegalEntities/config.tsx b/web/src/widgets/ConsoleLegalEntities/config.tsx index 3660861..92ad066 100644 --- a/web/src/widgets/ConsoleLegalEntities/config.tsx +++ b/web/src/widgets/ConsoleLegalEntities/config.tsx @@ -2,13 +2,12 @@ import { FileTextOutlined } from '@ant-design/icons'; import { Button } from 'antd'; import type { TableConfig } from '../../components/GenericTable'; import { TableTag } from '../../components/TableTag/TableTag'; -import { TAG_PRESETS } from '../../components/TableTag/presets'; import type { FileRussianStatus, MemberRow } from '../../types/consolidations/version_members'; -import { statusColorMapping } from './consts'; +import { fileStatusColorMapping } from './consts'; function StatusTag({ status }: { status: FileRussianStatus | undefined }) { if (!status) return null; - return ; + return ; } export const membersConfig: TableConfig = { diff --git a/web/src/widgets/ConsoleLegalEntities/consts.ts b/web/src/widgets/ConsoleLegalEntities/consts.ts index bada87e..1965c84 100644 --- a/web/src/widgets/ConsoleLegalEntities/consts.ts +++ b/web/src/widgets/ConsoleLegalEntities/consts.ts @@ -1,7 +1,7 @@ import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; import type { FileRussianStatus, FileStatus } from '../../types/consolidations/version_members'; -export const statusMapping: Record = { +export const fileStatusMapping: Record = { CONFIRMED: 'Загружен', ERROR: 'Ошибка', MISSING: 'Отсутствует', @@ -10,7 +10,7 @@ export const statusMapping: Record = { RECOGNIZED: 'Распознан', }; -export const statusColorMapping: Record = { +export const fileStatusColorMapping: Record = { 'В обработке': TAG_PRESETS.geekblue, Загружен: TAG_PRESETS.green, Отсутствует: TAG_PRESETS.default, diff --git a/web/src/widgets/ConsoleLegalEntities/normalize.ts b/web/src/widgets/ConsoleLegalEntities/normalize.ts index 95ff1d5..e651aa6 100644 --- a/web/src/widgets/ConsoleLegalEntities/normalize.ts +++ b/web/src/widgets/ConsoleLegalEntities/normalize.ts @@ -1,5 +1,5 @@ import type { FileRussianStatus, IVersionCard, MemberRow } from '../../types/consolidations/version_members'; -import { statusMapping } from './consts'; +import { fileStatusMapping } from './consts'; export function normalizeData(cons_data: IVersionCard | null): MemberRow[] { if (cons_data === null) { @@ -10,9 +10,9 @@ export function normalizeData(cons_data: IVersionCard | null): MemberRow[] { let ofr_status: FileRussianStatus | undefined; for (const c of m.checklist) { if (c.document_type === 'BALANCE') { - bb_status = statusMapping[c.status]; + bb_status = fileStatusMapping[c.status]; } else if (c.document_type === 'INCOME') { - ofr_status = statusMapping[c.status]; + ofr_status = fileStatusMapping[c.status]; } } return { diff --git a/web/src/widgets/ConsoleTable/ConsoleTable.tsx b/web/src/widgets/ConsoleTable/ConsoleTable.tsx index 714e931..aa21583 100644 --- a/web/src/widgets/ConsoleTable/ConsoleTable.tsx +++ b/web/src/widgets/ConsoleTable/ConsoleTable.tsx @@ -208,7 +208,14 @@ export function ConsoleTableWidget() { const onRow: TableProps['onRow'] = (row) => ({ // выбранная версия живёт в URL (?version=): переживает уход на «К отчётности» и «Назад» - onClick: () => setSearchParams({ version: row.id }, { replace: true }), + onClick: () => + setSearchParams( + (searchParams) => { + searchParams.set('version', row.id); + return searchParams; + }, + { replace: true }, + ), // выбранная версия подсвечена, как в макете [34] style: row.id === searchParams.get('version') ? { background: '#f0f7ee' } : undefined, }); diff --git a/web/src/widgets/ConsoleTable/consts.ts b/web/src/widgets/ConsoleTable/consts.ts index 1cb0a59..dba1fc2 100644 --- a/web/src/widgets/ConsoleTable/consts.ts +++ b/web/src/widgets/ConsoleTable/consts.ts @@ -30,3 +30,12 @@ export const statusColorMapping: Record = { Отклонён: TAG_PRESETS.red, Отменён: TAG_PRESETS.red, }; + +export const consStatusDescriptionMapping: Partial> = { + CONSOLIDATION_RF: 'Формирование и проверка КФО, внесение корректировок на уровне РФ', + CONSOLIDATION_GO: 'Формирование и проверка КФО, внесение корректировок на уровне ГО', + VERIFICATION_GO: 'Проверка и подтверждение КФО на уровне ГО', + VERIFICATION_RF: 'Проверка и подтверждение КФО на уровне РФ', + CANCELLED: 'Формирование КФО отменено', + VERIFIED: 'КФО верифицирована, внесены номер и дата решения УОБ', +}; diff --git a/web/src/widgets/ConsolidatedOsv/OsvTable.tsx b/web/src/widgets/ConsolidatedOsv/OsvTable.tsx index 1ee69cc..6d4fe5a 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTable.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTable.tsx @@ -1,24 +1,23 @@ import { FilterFilled } from '@ant-design/icons'; import { Button, Checkbox, Input, Select, Space, Table } from 'antd'; import type { TableColumnsType } from 'antd'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { TableTag } from '../../components/TableTag/TableTag'; import type { ConsolidatedRowOut, CounterpartyType, OsvFilters } from '../../types/api'; import { counterpartyTypeLabel, osvPeriodLabel } from '../../utils/labels'; -import { formatAmount } from '../../utils/money'; import { innCell, needsMapping } from './counterpartyMapping'; import { COUNTERPARTY_TYPES, counterpartyTypeBadge } from './counterpartyTypeBadge'; import type { OsvFilterDraft } from './osvFilterState'; type MoneyKey = 'opening_debit' | 'opening_credit' | 'turnover_debit' | 'turnover_credit' | 'closing_debit' | 'closing_credit'; -const money = (key: MoneyKey, title: string) => ({ +const money = (key: MoneyKey, title: string, format: (raw: string | null) => string) => ({ key, title, dataIndex: key, align: 'right' as const, width: 140, - render: (v: string | null) => formatAmount(v, { zeroDash: true }), + render: (v: string | null) => format(v), }); const group = (title: string, children: TableColumnsType): TableColumnsType[number] => ({ @@ -195,6 +194,7 @@ type Props = { filters: OsvFilters; members: Member[]; onPage: (p: number) => void; + numFormat: (raw: string | null) => string; onFilter: OnChange; /** «Указать» или ИНН сопоставленного ЮЛ — открыть выбор организации [32-osv-map-counterparty]. */ onMap: (row: ConsolidatedRowOut) => void; @@ -237,74 +237,91 @@ function InnCell({ row, onMap }: { row: ConsolidatedRowOut; onMap: (row: Consoli ); } -export function OsvTable({ rows, total, page, pageSize, loading, filters, members, onPage, onFilter, onMap }: Props) { +export function OsvTable({ rows, total, page, pageSize, loading, filters, members, numFormat, onPage, onFilter, onMap }: Props) { const filtered = Object.keys(filters).length > 0; - const columns: TableColumnsType = [ - { - key: 'legal_entity_name', - title: 'Компания ГК', - dataIndex: 'legal_entity_name', - width: 230, - fixed: 'left', - filterIcon: filterIcon(!!filters.legal_entity_id), - // ключ в key выбирается, чтобы dropdown пересоздавался при внешнем сбросе фильтра - filterDropdown: ({ close }) => ( - - ), - }, - { - key: 'account_code', - title: 'Счёт', - dataIndex: 'account_code', - width: 110, - filterIcon: filterIcon(!!filters.account_root), - filterDropdown: ({ close }) => ( - - ), - }, - { key: 'period', title: 'Период', dataIndex: 'period', width: 140, render: (p: string) => osvPeriodLabel(p) }, - { - key: 'counterparty', - title: 'Контрагенты', - width: 260, - render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—', - filterIcon: filterIcon(!!filters.q), - filterDropdown: ({ close }) => , - }, - { - key: 'counterparty_inn', - title: 'ИНН', - align: 'center', - // шире макетных 128: «таблетка» с 12-значным ИНН ИП - width: 140, - onCell: (r) => ({ style: needsMapping(r) ? REQUIRED_CELL : INN_CELL }), - render: (_: unknown, r) => , - filterIcon: filterIcon(!!filters.unmapped), - filterDropdown: ({ close }) => ( - - ), - }, - group('Сальдо на начало периода', [money('opening_debit', 'Дебет'), money('opening_credit', 'Кредит')]), - group('Обороты за период', [money('turnover_debit', 'Дебет'), money('turnover_credit', 'Кредит')]), - group('Сальдо на конец периода', [money('closing_debit', 'Дебет'), money('closing_credit', 'Кредит')]), - { - key: 'counterparty_type', - title: 'Тип контрагента', - dataIndex: 'counterparty_type', - width: 130, - render: (t: CounterpartyType) => , - filterIcon: filterIcon(!!filters.counterparty_type), - filterDropdown: ({ close }) => ( - - ), - }, - ]; + const columns: TableColumnsType = useMemo(() => { + return [ + { + key: 'legal_entity_name', + title: 'Компания ГК', + dataIndex: 'legal_entity_name', + width: 230, + fixed: 'left', + filterIcon: filterIcon(!!filters.legal_entity_id), + // ключ в key выбирается, чтобы dropdown пересоздавался при внешнем сбросе фильтра + filterDropdown: ({ close }) => ( + + ), + }, + { + key: 'account_code', + title: 'Счёт', + dataIndex: 'account_code', + width: 110, + filterIcon: filterIcon(!!filters.account_root), + filterDropdown: ({ close }) => ( + + ), + }, + { key: 'period', title: 'Период', dataIndex: 'period', width: 140, render: (p: string) => osvPeriodLabel(p) }, + { + key: 'counterparty', + title: 'Контрагенты', + width: 260, + render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—', + filterIcon: filterIcon(!!filters.q), + filterDropdown: ({ close }) => , + }, + { + key: 'counterparty_inn', + title: 'ИНН', + align: 'center', + // шире макетных 128: «таблетка» с 12-значным ИНН ИП + width: 140, + onCell: (r) => ({ style: needsMapping(r) ? REQUIRED_CELL : INN_CELL }), + render: (_: unknown, r) => , + filterIcon: filterIcon(!!filters.unmapped), + filterDropdown: ({ close }) => ( + + ), + }, + group('Сальдо на начало периода', [money('opening_debit', 'Дебет', numFormat), money('opening_credit', 'Кредит', numFormat)]), + group('Обороты за период', [money('turnover_debit', 'Дебет', numFormat), money('turnover_credit', 'Кредит', numFormat)]), + group('Сальдо на конец периода', [money('closing_debit', 'Дебет', numFormat), money('closing_credit', 'Кредит', numFormat)]), + { + key: 'counterparty_type', + title: 'Тип контрагента', + dataIndex: 'counterparty_type', + width: 130, + render: (t: CounterpartyType) => , + filterIcon: filterIcon(!!filters.counterparty_type), + filterDropdown: ({ close }) => ( + + ), + }, + ]; + }, [ + filters.account_root, + filters.counterparty_type, + filters.legal_entity_id, + filters.q, + filters.unmapped, + members, + numFormat, + onFilter, + onMap, + ]); return ( bordered diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 9597458..512d0a9 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -8,7 +8,7 @@ export function OsvTabs() { return ( ОСВ }, ...TABS.map((t) => ({ diff --git a/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx b/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx new file mode 100644 index 0000000..80a1552 --- /dev/null +++ b/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx @@ -0,0 +1,42 @@ +import { useState, useMemo, useCallback } from 'react'; + +export type Unit = 'руб.' | 'тыс. руб.' | 'млн. руб.' | 'млрд. руб.'; + +export const UNITS: { value: Unit; label: string }[] = [ + { value: 'руб.', label: 'руб.' }, + { value: 'тыс. руб.', label: 'тыс. руб.' }, + { value: 'млн. руб.', label: 'млн. руб.' }, + { value: 'млрд. руб.', label: 'млрд. руб.' }, +]; + +const DIVISORS: Record = { + 'руб.': 1, + 'тыс. руб.': 1e3, + 'млн. руб.': 1e6, + 'млрд. руб.': 1e9, +}; + +export function createFormatter(precision: number): Intl.NumberFormat { + return new Intl.NumberFormat('ru-RU', { + minimumFractionDigits: precision, + maximumFractionDigits: precision, + }); +} + +export function formatNumeric(raw: string | null, unit: Unit, nf: Intl.NumberFormat): string { + if (raw == null || raw.trim() === '') return '—'; + const n = Number(raw); + if (!Number.isFinite(n)) return raw; + return nf.format(n / DIVISORS[unit]); +} + +export function useNumberDisplay(initial?: { unit?: Unit; precision?: number }) { + const [unit, setUnit] = useState(initial?.unit ?? 'руб.'); + const [precision, setPrecision] = useState(initial?.precision ?? 2); + + const nf = useMemo(() => createFormatter(precision), [precision]); + + const format = useCallback((raw: string | null) => formatNumeric(raw, unit, nf), [unit, nf]); + + return { unit, setUnit, precision, setPrecision, format }; +} diff --git a/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx b/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx index 9ee315c..1bfb3d6 100644 --- a/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx +++ b/web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx @@ -23,11 +23,6 @@ const errorCodeMap = { TRANSITION_NOT_ALLOWED: 'Данный переход недоступен', }; -/* -"Инициация", "Обработка документов", "Корректировка данных" -> "Отменена" (комментарий обязателен); - из "Обработка документов", "Корректировка данных" -> "Инициация" (комментарий обязателен во фронте) -*/ - async function mockStatusMappingFetch() { const mapping: Record = { INITIATED: ['CANCELLED'], @@ -95,17 +90,19 @@ export default function ChangeStatusModal({ open, versionId, setClose, onSuccess Текущий этап:
-
- - - - - - -
+ {nextStatusOptions && ( +
+ + + + + + +
+ )} ); } -- 2.47.2 From 4f32d74ba089180e1c4e84bd82ad203bc64af3b6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 00:50:58 +0400 Subject: [PATCH 06/13] =?UTF-8?q?docs(openspec):=20change=20consolidations?= =?UTF-8?q?-ui=20=E2=80=94=20=D1=86=D0=B5=D0=BB=D0=B5=D0=B2=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D0=BD=D0=BE=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=81?= =?UTF-8?q?=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B9=20(PR=20#166,?= =?UTF-8?q?=20AURORA-1589)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спека consolidations-ui: список консолидаций [34] (колонки, «Версия», «Только в работе»), «Взять в работу» и перехват [35], меню «⋯», панель «Организации», подписи статусов, вид модалки [42], «Изменить статус» [33-01] с зеркалом MANUAL_TRANSITIONS, пояснение статуса под заголовком, модалка [31-00]. design.md: решения, Known issues ветки 71ef633, решения без обсуждения; tasks.md: ревьюер / автор / тесты. Co-Authored-By: Claude Opus 5.5 --- .../changes/consolidations-ui/.openspec.yaml | 2 + openspec/changes/consolidations-ui/design.md | 117 +++++++++ .../changes/consolidations-ui/proposal.md | 62 +++++ .../specs/consolidations-ui/spec.md | 226 ++++++++++++++++++ openspec/changes/consolidations-ui/tasks.md | 35 +++ 5 files changed, 442 insertions(+) create mode 100644 openspec/changes/consolidations-ui/.openspec.yaml create mode 100644 openspec/changes/consolidations-ui/design.md create mode 100644 openspec/changes/consolidations-ui/proposal.md create mode 100644 openspec/changes/consolidations-ui/specs/consolidations-ui/spec.md create mode 100644 openspec/changes/consolidations-ui/tasks.md diff --git a/openspec/changes/consolidations-ui/.openspec.yaml b/openspec/changes/consolidations-ui/.openspec.yaml new file mode 100644 index 0000000..f56fe13 --- /dev/null +++ b/openspec/changes/consolidations-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-23 diff --git a/openspec/changes/consolidations-ui/design.md b/openspec/changes/consolidations-ui/design.md new file mode 100644 index 0000000..e3f3f0f --- /dev/null +++ b/openspec/changes/consolidations-ui/design.md @@ -0,0 +1,117 @@ +## Context + +PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов, задача AURORA-1589) доводит до макета экраны консолидаций: +список консолидаций ГК [34] (`widgets/ConsoleTable/`), модалку «Взять в работу» и перехват [35] +(`widgets/ConsoleTable/LockModal.tsx`), панель «Организации» (`widgets/ConsoleLegalEntities/`), модалку «Новая +консолидация» [42] (`widgets/NewConsoleModal/`, строка ЮЛ — `CompanyOptionRow.tsx`), кнопку «Изменить статус» в шапке +карточки версии [30] (`layouts/ConsolidatedOsvPage.tsx`) и модалку [33-01] (`widgets/StatusChangeModal/ChangeStatusModal.tsx`). +Бэкенд — спека `consolidations-api` (список с `in_work_only`, «Взять в работу, перехват, снятие», «Статусы Среза 4»); API не +меняется. В ветку добавлена вторая библиотека иконок `lucide-react` (иконки макета: меню «⋯», «Взять в работу», «К +отчётности», боковое меню). + +Спеку, документацию и тесты ведёт ревьюер коммитами в ветку автора (правило владельца для чужих PR); продуктовый код +правит автор. Спека описывает **целевое** поведение; расхождения кода ветки (коммит 71ef633) — раздел «Known issues». +Источники: макет [34], [35], [42], [30], [33-01], [33], [31-00] (`docs/mockup/INDEX.md`), описание AURORA-1589, ТЗ +«Статусная модель» (`docs/source/tz/…08_4. Статусная модель.md`), гайд фронта по API §5.7. + +## Decisions + +- **D-1. Ручные переходы — зеркало `MANUAL_TRANSITIONS` во фронте.** API не отдаёт допустимые переходы (в `VersionCard` + нет `allowed_transitions`, заметка AURORA-1589 от 12.10.2026). Фронт держит одну константу «статус → допустимые ручные + переходы», совпадающую с `api/src/services/consolidation_status.py`: `INITIATED → {CANCELLED}`, + `DOCUMENTS_PROCESSING → {CANCELLED, INITIATED}`, `DATA_CORRECTION → {CANCELLED, INITIATED}`. С маршрутом согласования + (Срез 7) константа заменяется ответом API без переделки модалки. Расхождение константы с бэком сервер ловит 409 + `TRANSITION_NOT_ALLOWED`; заметка о зеркале — в гайде фронта §5.7 (PR в RSHB/arm-ka-docs). +- **D-2. Комментарий обязателен для обоих ручных переходов** (AURORA-1589): для `CANCELLED` его требует и сервер (422 + `COMMENT_REQUIRED`), для возврата в `INITIATED` — только фронт. +- **D-3. «Изменить статус» доступна по `lock` карточки и `/me`**: активна, если `lock.locked_by` = текущий пользователь и в + D-1 есть переходы из текущего статуса. Подсказка «Возьмите консолидацию в работу» (текст задачи AURORA-1589). +- **D-4. После смены статуса экран берёт карточку из ответа** `POST …/status` (`VersionCard`), без повторного + `GET`: бейдж, пояснение и кнопка обновляются сразу; после `CANCELLED` сервер снимает блокировку, кнопка становится + неактивной. +- **D-5. По умолчанию в «Новом этапе» ничего не выбрано** (ревью раунда 1): в макете [33-01] предложен следующий этап, но + сейчас один из двух вариантов — необратимая «Отменена», и случайная отправка по умолчанию недопустима. +- **D-6. «Только в работе» — серверный фильтр `in_work_only`**, по умолчанию выключен (макет [34], значение API по + умолчанию `false`). Фронт не фильтрует по статусу сам: множество «в работе» для списка определяет сервер. +- **D-7. Модалка [35] читает свежую карточку версии** перед показом: данные `lock` строки списка могут устареть. Перехват — + в той же модалке: при известной чужой блокировке сразу поле причины, при 409 `LOCKED_BY_OTHER` (гонка) — предупреждение + и поле причины. +- **D-8. ГСК в API нет (решение владельца П-3)**: колонка «ГСК» — «—», в тексте модалки [35] вместо ГСК макета — ГК + (`company_group.name` карточки). +- **D-9. Подпись `CANCELLED` — «Отменена»** (AURORA-1589, ревью): в ТЗ «Статусная модель» — «Отменен», в словаре UI + `10-user-journey.md` — «Отмена КФО»; выбран вариант задачи (согласование с «консолидацией»). «Корректировка данных» — + как в ТЗ (было «Коррекция данных»). +- **D-10. Кнопку «Взять в работу» блокирует код статуса `CANCELLED`, а не подпись** — подпись меняется (D-9), условие по + подписи ломается молча. + +## Known issues + +Расхождения кода ветки `AURORA-1589` (71ef633) с целевой спекой — по пунктам ревью раунда 1; исправляет автор PR. + +1. **404 при смене статуса.** `services/api/consolidations.ts` `updateStatus` шлёт `POST /consolidation/versions/{id}/status` + (нет «s»); нужен `/consolidations/versions/{id}/status`. +2. **Переходы — заглушка `mockStatusMappingFetch()` внутри модалки** (`ChangeStatusModal.tsx`). Нужна одна константа (D-1) + рядом с подписями статусов. Для статусов без переходов сейчас `nextStatusOptions = undefined`: форма скрыта, кнопка + «Изменить статус» в шапке при этом активна. +3. **«Новый этап» предвыбран** (`initialValues.to_status = nextStatusOptions[0]`, на стенде — «Отменён»); нужно пусто (D-5). +4. **Кнопка «Изменить статус»**: подсказка «Сначала возьмите версию в работу» вместо «Возьмите консолидацию в работу»; + отсутствие ручных переходов не учитывается (при своей блокировке `VERIFIED` кнопка активна, модалка пустая). +5. **Колонка «Версия» инвертирована** (`ConsoleTable/config.tsx`): показывает «1» и скрывает 2+; нужно наоборот. +6. **«Только в работе» включён по умолчанию** (`ConsoleTable.tsx`: `searchParams.get('in_work_only') || true`, флажок + отмечен, пока параметр не `'false'`); нужно выключен (D-6). +7. **Пункты «⋯» не `disabled`**: у них только класс `ant-dropdown-menu-item-disabled`, клик обрабатывается + («История изменений» ведёт в пустой «Журнал действий», остальные — `message.info('Будет позже')`); в `ITEMS` лишний + пустой элемент `{}`. +8. **Подпись `CANCELLED` — «Отменён»** (нужно «Отменена», D-9); «Взять в работу» блокируется сравнением + `row.status === 'Отменён'` — после переименования условие перестанет срабатывать (D-10). +9. **Перехват [35]**: плейсхолдер поля причины «Type message»; проверка длины `< 1000` вместо `≤ 1000`; квартал в тексте — + `Q3` вместо «К3»; опечатки «обнаржуена», «нформацию». +10. **Пояснение под заголовком**: для `INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION` повторяется название статуса; + тексты `VERIFIED` и `CONSOLIDATION_RF` в `consStatusDescriptionMapping` расходятся с макетом [30], [33-01]. +11. **Модалка [31-00] «Остались неуказанные ИНН»** не реализована. Другие вкладки отчёта в ветке пока неактивны + (`OsvTabs`), поэтому сценарий проверяется после появления вкладки «Шахматка» (PR #158) или в нём же. +12. **«+ Создать» активна при одном отмеченном ЮЛ** (`NewConsoleModal.tsx`, `!legEntIds?.length`); нужно не меньше двух + (сервер отвечает 422 `PERIMETER_TOO_SMALL`). +13. **Модалка «Изменить статус», замечено при написании спеки** (в комментарий ревью раунда 1 не вошло): ошибка + `getVersionCard` не обрабатывается — модалка остаётся в загрузке; `comment.trim()` вызывается на значении + `Form.useWatch`, которое до монтирования формы `undefined`. +14. **Тестов нет.** Чистые правила (подпись версии, переходы, активность кнопок, параметр `in_work_only`) зашиты в + компоненты; тесты vitest — после их выноса в чистые функции (`tasks.md`). +15. **`lucide-react@0.263.1` в зеркале npm банка не проверен.** Пакет в `dependencies`, снимок контура его оставляет + (`docs/dev/contour-transfer-guide.md` §3.2); если версии нет в Nexus, сборка в контуре упадёт. +16. **Предупреждение браузера о несохранённых правках** (пункт AURORA-1589) не реализовано; в спеку не входит (решение (е)). + +## Decisions made without discussion + +- **2026-10-23 (а). Новая capability `consolidations-ui`; `consolidation-setup` и `consolidated-osv-ui` не правятся.** Вид + модалки [42], шапка карточки версии и модалка [31-00] описаны в новой спеке; состав модалки [42], создание и «К + отчётности» остаются в `consolidation-setup`, сводная ОСВ — в `consolidated-osv-ui`. Обоснование: не смешивать дельты + трёх спек в одном PR фронта. Цена ошибки: при архивации модалку [42] и страницу отчёта описывают по две спеки; сведение — + MODIFIED-дельтой позже. +- **2026-10-23 (б). Временные пояснения статусов первой очереди и `CANCELLED` — дословные фрагменты ТЗ «Статусная + модель»** (колонка «Описание»), пока аналитик не дал тексты (AURORA-1589: «тексты для статусов первой очереди — у + аналитика»); для `VERIFIED` и `CONSOLIDATION_RF` — тексты макета вместо текстов автора. Обоснование: подзаголовок не + должен повторять название статуса, а придумывать формулировки нельзя. Цена ошибки: замена строк в одном месте. +- **2026-10-23 (в). Порядок версий в дереве — как в ответе API** (сверху последняя версия периода, под ней предыдущие), как + в `main`. В макете [34] версия 1 стоит сверху, версия 2 — под ней. Цена ошибки: правка `flatten.ts` / `groupAdjacent.ts` + и MODIFIED требования «Список консолидаций ГК». +- **2026-10-23 (г). Минимум два ЮЛ для «+ Создать»** — из описания [42] в `docs/mockup/INDEX.md` («минимум 2») и 422 + `PERIMETER_TOO_SMALL`; фронт блокирует раньше сервера. Цена ошибки: одно условие. +- **2026-10-23 (д). Модалка [31-00] — только при переходе на другую вкладку отчёта**, не при «Назад» и переходах по меню. + Открытые вопросы владельцу: (1) нужно ли предупреждать и при уходе со страницы отчёта; (2) AURORA-1589: «на реальных + данных в счётчик попадают строки, которые не являются организациями… до нуля он не доводится» — поведение согласовать с + задачей «Блокировка шага до сопоставления контрагентов без ИНН»; до решения модалка не блокирует переход. Цена ошибки: + расширение триггера — MODIFIED требования. +- **2026-10-23 (е). Предупреждение браузера о несохранённых правках не включено в спеку**: пункт задачи касается всех форм + и модалок приложения, а не экранов консолидаций; остаётся задачей автора в `tasks.md`. Цена ошибки: требование появится + отдельной спекой (или дельтой) позже. +- **2026-10-23 (ж). Подпись `DOCUMENTS_PROCESSING` оставлена «Обработка документов»** (как в `main`); в ТЗ — «Загрузка, + распознавание и корректировка документов», в словаре UI — «Загрузка и распознавание документов». Вопрос аналитику. + Цена ошибки: замена одной подписи. +- **2026-10-23 (з). Тексты сообщений об ошибках 409/422 в модалке «Изменить статус» не фиксируются дословно**: спека + требует сообщение и открытую модалку. Цена ошибки: низкая. +- **2026-10-23 (и). В ветку влит `origin/main`** (шахматка «пара без встречной стороны» PR #165 и архивы OpenSpec + PR #160, #167, #168) — без конфликтов; файлы `web/` ветки слиянием не затронуты. Цена ошибки: низкая, PR и так + сливается в этот `main`. +- **2026-10-23 (к). «Единицы» и «Знаков после запятой» сводной ОСВ (коммит 71ef633, AURORA-1539) в спеку не включены** — + автору предложено вынести их в отдельный PR. Цена ошибки: если коммит останется в PR, его поведение будет без спеки. diff --git a/openspec/changes/consolidations-ui/proposal.md b/openspec/changes/consolidations-ui/proposal.md new file mode 100644 index 0000000..e79e868 --- /dev/null +++ b/openspec/changes/consolidations-ui/proposal.md @@ -0,0 +1,62 @@ +## Why + +В ветке `AURORA-1589` (PR RSHB/arm-core#166, автор — Степанов, задача AURORA-1589) фронт доводит экраны консолидаций до +макета: список консолидаций ГК [34] с «Взять в работу» и перехватом [35], модалку «Новая консолидация» [42] и кнопку +«Изменить статус» в карточке версии [30] с модалкой [33-01]. Бэкенд этих действий уже в main (`consolidations-api`: +список с `in_work_only`, блокировка и перехват, ручная смена статуса). У экранов нет UI-спеки. Спеку, документацию и тесты +ведёт ревьюер коммитами в ветку автора (правило владельца): спека задаёт целевое поведение, расхождения кода ветки с ней — +`design.md`, «Known issues»; продуктовый код правит автор. + +## What Changes + +Репозиторий — только arm-core (`web/`). Эндпоинты arm-core (без изменений): `GET /company-groups/{id}/consolidations` +(`in_work_only`), `GET /consolidations/versions/{id}`, `POST /consolidations/versions/{id}/lock` (`takeover_reason`), +`POST /consolidations/versions/{id}/status`, `POST /consolidations`. + +- Список консолидаций [34]: колонки, дерево версий с номером только у версий 2+, фильтр «Только в работе» (выключен по + умолчанию), «Взять в работу» с подтверждением [35] и перехватом с причиной, неактивные пункты меню «⋯». +- Панель «Организации» без выбранной консолидации и серый статус «Отсутствует». +- Подписи статусов версии («Корректировка данных», «Отменена») и пояснение статуса под заголовком карточки. +- Модалка «Новая консолидация» [42]: ИНН отдельной колонкой, «+ Создать», без звёздочек обязательности. +- «Изменить статус» [33-01]: допустимые переходы — зеркало `MANUAL_TRANSITIONS` бэка, по умолчанию ничего не выбрано, + кнопка доступна только владельцу блокировки и при наличии ручных переходов. +- Модалка [31-00] «Остались неуказанные ИНН» при уходе со «Сводной ОСВ» на другую вкладку отчёта. +- Новая спека `consolidations-ui` (ADDED Requirements). +- Документация фронта: `web/CLAUDE.md` (вторая библиотека иконок `lucide-react`), `web/ARCHITECTURE.md` (новые виджеты). + +## Non-goals + +- Исправление продуктового кода по «Known issues» (`design.md`) — за автором PR. +- «Единицы» и «Знаков после запятой» в сводной ОСВ (коммит 71ef633, AURORA-1539) — автору предложено вынести в отдельный + PR; в эту спеку не входят. +- Полный маршрут согласования (переходы Среза 7), «Добавить версию», «Отключить подтверждение КФО», «История изменений», + «Редактировать состав», «Импорт», «Дата верификации», «Утверждён в РФ» / «Верифицирован в ГО» — зонтичная задача + AURORA-1416. +- ГСК консолидации в API списка и карточки (колонка «ГСК») — отдельная задача ядра; до неё колонка пустая. +- Список допустимых переходов из API (`allowed_transitions`) — с маршрутом согласования; до него фронт держит зеркало. +- Предупреждение браузера о несохранённых правках (пункт задачи AURORA-1589) — касается всех форм приложения, в эту спеку + не входит (`design.md`, решение без обсуждения (е)). +- Изменения бэкенда и DBML. + +## Capabilities + +### New Capabilities +- `consolidations-ui`: список консолидаций ГК, взятие в работу и перехват, модалка новой консолидации (вид), смена статуса + версии и шапка карточки версии. + +### Modified Capabilities + +Нет: `consolidation-setup` (состав модалки [42], создание, «К отчётности») и `consolidated-osv-ui` (сводная ОСВ) не +правятся — добавленное поведение описано в новой спеке (`design.md`, решение без обсуждения (а)). + +## Impact + +- `web/src/widgets/ConsoleTable/` (`ConsoleTable.tsx`, `LockModal.tsx`, `config.tsx`, `consts.ts`), + `web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx`, `web/src/widgets/NewConsoleModal/` + (`NewConsoleModal.tsx`, `CompanyOptionRow.tsx`), `web/src/widgets/ConsoleLegalEntities/`, + `web/src/layouts/ConsolidatedOsvPage.tsx`, `web/src/layouts/ConsolidationsList.tsx`, + `web/src/services/api/consolidations.ts`, `web/src/store/slices/consolidations/thunks.ts`, `web/src/types/`, + `web/src/widgets/Sidebar/` (иконки), `web/package.json` (`lucide-react`). +- Права: действия требуют `CONSOLIDATION_EDIT` на сервере; меню по правам не скрывается. +- Гайд фронта по API (`docs/dev/frontend-api-guide.md` §5.7): заметка о зеркале ручных переходов — PR в RSHB/arm-ka-docs, + сливать вместе с этим PR. diff --git a/openspec/changes/consolidations-ui/specs/consolidations-ui/spec.md b/openspec/changes/consolidations-ui/specs/consolidations-ui/spec.md new file mode 100644 index 0000000..4d9ce36 --- /dev/null +++ b/openspec/changes/consolidations-ui/specs/consolidations-ui/spec.md @@ -0,0 +1,226 @@ +## ADDED Requirements + +### Requirement: Список консолидаций ГК +Экран консолидаций ГК [34] SHALL показывать версии из `GET /company-groups/{id}/consolidations` таблицей с колонками +Год, Квартал (К1–К4), Версия, Статус (бейдж, подписи — «Подписи статусов версии»), ГСК, Дата создания, Дата верификации, +Организаций (`members_count`) и колонкой действий: «Взять в работу» (или «В работе»), «К отчётности», меню «⋯». Версии +одного периода SHALL показываться деревом: верхняя строка — первая версия периода из ответа API (по убыванию +`version_no`), остальные — раскрываемыми строками под ней. Колонка «ГСК» SHALL иметь фильтр в заголовке, «Дата создания» — +без фильтра. Пока API не отдаёт ГСК консолидации и дату верификации, колонки «ГСК» и «Дата верификации» SHALL показывать +«—». Рядом с заголовком «Консолидации» — число строк («N шт.»). Клик по строке SHALL выбирать версию для панели +«Организации» и подсвечивать строку. + +#### Scenario: Колонки списка +- **WHEN** открыт список консолидаций ГК «Русский бисквит» +- **THEN** у таблицы колонки Год, Квартал, Версия, Статус, ГСК, Дата создания, Дата верификации, Организаций и колонка + действий; у «ГСК» есть фильтр, у «Дата создания» — нет; в «ГСК» и «Дата верификации» — «—» + +#### Scenario: Выбор версии +- **WHEN** аналитик кликает по строке версии 2024 К3 +- **THEN** строка подсвечена, панель «Организации» показывает состав этой версии + +### Requirement: Колонка «Версия» +Колонка «Версия» SHALL быть пустой у версии с `version_no = 1` и показывать номер у версий с `version_no ≥ 2`. + +#### Scenario: Единственная версия периода +- **WHEN** у консолидации 2025 К1 одна версия (`version_no = 1`) +- **THEN** ячейка «Версия» пустая + +#### Scenario: Вторая версия периода +- **WHEN** у консолидации 2024 К1 версии 2 и 1 +- **THEN** у строки версии 2 в колонке «Версия» — «2», у строки версии 1 — пусто + +### Requirement: Фильтр «Только в работе» +Над таблицей SHALL быть флажок «Только в работе», по умолчанию **выключенный**. Включённый флажок SHALL перечитывать список +запросом `GET /company-groups/{id}/consolidations?in_work_only=true` (отбор делает сервер: остаются версии со статусом не +из `VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED`); выключенный — запросом с `in_work_only=false` (или без параметра). +Фронт MUST NOT фильтровать версии по статусу сам. + +#### Scenario: Открытие экрана +- **WHEN** аналитик впервые открывает список консолидаций ГК +- **THEN** флажок «Только в работе» снят, запрос — с `in_work_only=false`, в таблице есть верифицированные и отменённые + версии + +#### Scenario: Включение фильтра +- **WHEN** аналитик отмечает «Только в работе» +- **THEN** список перечитан с `in_work_only=true`, отменённой версии в таблице нет + +### Requirement: Взять в работу +Кнопка «Взять в работу» в строке SHALL открывать модалку «Взять консолидацию в работу?» [35]. Состояние блокировки +модалка SHALL брать из свежей карточки `GET /consolidations/versions/{id}` (`lock`): +- блокировки нет — текст «Консолидация <год> К · <ГК> будет закреплена за вами для редактирования.», кнопки + «Отмена» / «Взять в работу»; «Взять в работу» отправляет `POST /consolidations/versions/{id}/lock` без + `takeover_reason`; +- версию держит другой пользователь — текст «Консолидация взята в работу <ФИО>. Укажите причину замены.» и поле причины + (обязательно, ≤ 1000 символов, строка из пробелов считается пустой); «Взять в работу» неактивна, пока причина пуста, и + отправляет `POST …/lock {takeover_reason}` (перехват). + +После 200/201 модалка SHALL закрыться с сообщением «Консолидация взята в работу.» и перечитать список. Ответ 409 +`LOCKED_BY_OTHER` на запрос без причины (версию успели взять) SHALL переводить модалку в режим перехвата с предупреждением +«<ФИО> только что взял(-а) консолидацию в работу.» и пустым полем причины. Ответ 409 `VERSION_NOT_EDITABLE` SHALL +закрывать модалку с сообщением «Нельзя взять в работу отменённую версию.». У версии со статусом `CANCELLED` кнопка +«Взять в работу» SHALL быть неактивной (условие — по коду статуса, а не по подписи); у версии, которую держит текущий +пользователь, вместо неё SHALL показываться неактивная кнопка «В работе». + +#### Scenario: Свободная версия +- **WHEN** аналитик нажимает «Взять в работу» у версии без блокировки и подтверждает +- **THEN** отправлен `POST …/lock` без тела причины, модалка закрыта, в строке — неактивная «В работе» + +#### Scenario: Перехват с причиной +- **WHEN** версию держит «Соколов А. В.», аналитик нажимает «Взять в работу» +- **THEN** модалка показывает «Консолидация взята в работу Соколов А. В. Укажите причину замены.» и поле причины; + «Взять в работу» неактивна, пока причина пуста; после ввода «Отпуск» отправлен `POST …/lock {takeover_reason: "Отпуск"}` + +#### Scenario: Версию взяли, пока модалка открыта +- **WHEN** в модалке свободной версии аналитик нажимает «Взять в работу», а сервер отвечает 409 `LOCKED_BY_OTHER` +- **THEN** модалка остаётся открытой в режиме перехвата с предупреждением «<ФИО> только что взял(-а) консолидацию в + работу.» и пустым полем причины + +#### Scenario: Отменённая версия +- **WHEN** версия в статусе «Отменена» +- **THEN** «Взять в работу» в её строке неактивна + +### Requirement: Меню «⋯» строки консолидации +Меню «⋯» строки SHALL содержать пункты «Добавить версию», «Отключить подтверждение КФО», «История изменений» с иконками +[43]. Пока функции нет, пункты SHALL быть неактивными (`disabled`) с подсказкой «Будет позже»; нажатие на неактивный пункт +MUST NOT выполнять действий (ни перехода, ни всплывающего сообщения). + +#### Scenario: Неактивный пункт +- **WHEN** аналитик открывает «⋯» и нажимает «История изменений» +- **THEN** пункт неактивен, видна подсказка «Будет позже», переход в «Журнал действий» не происходит + +### Requirement: Панель «Организации» +Пока в списке не выбрана версия, панель «Организации» SHALL показывать заголовок «Организации» с подписью «Выберите +консолидацию» и текст «Выберите консолидацию выше» вместо таблицы. В колонках «ОФР» и «Бух. баланс» статус «Отсутствует» +SHALL быть нейтральным серым бейджем (как в легенде чек-листа [37]); красный — только у «Ошибка». + +#### Scenario: Версия не выбрана +- **WHEN** открыт список консолидаций, строка не выбрана +- **THEN** панель «Организации» без таблицы и без «0 шт.», в ней текст «Выберите консолидацию выше» + +#### Scenario: Документ отсутствует +- **WHEN** у ЮЛ выбранной версии нет ББ +- **THEN** в колонке «Бух. баланс» — серый бейдж «Отсутствует» + +### Requirement: Подписи статусов версии +Фронт SHALL показывать статусы версии одними подписями в списке, фильтре колонки «Статус», шапке карточки и модалке +«Изменить статус»: `INITIATED` — «Инициация», `DOCUMENTS_PROCESSING` — «Обработка документов», `DATA_CORRECTION` — +«Корректировка данных», `CONSOLIDATION_RF` — «Консолидация РФ», `VERIFICATION_RF` — «Верификация РФ», `CONSOLIDATION_GO` — +«Консолидация ГО», `VERIFICATION_GO` — «Верификация ГО», `VERIFIED` — «Верифицирован», `CANCELLED` — «Отменена», +`ADJUSTMENT` — «Корректировка», `ARCHIVED` — «В архиве», `REJECTED` — «Отклонён». + +#### Scenario: Корректировка данных +- **WHEN** версия в статусе `DATA_CORRECTION` +- **THEN** в списке и в карточке — бейдж «Корректировка данных» + +#### Scenario: Отменённая версия +- **WHEN** версия в статусе `CANCELLED` +- **THEN** в списке и в карточке — бейдж «Отменена» + +### Requirement: Вид модалки «Новая консолидация» +Модалка [42] (состав и создание — спека `consolidation-setup`) SHALL показывать поля без звёздочек обязательности и кнопку +«+ Создать» (иконка «плюс»), неактивную, пока не выбраны ГК, ГСК, год, квартал и не отмечено не меньше двух ЮЛ. В блоке +«ЮЛ консолидации» строка ЮЛ SHALL содержать флажок, наименование (длинное — с многоточием) и ИНН отдельной колонкой, +выровненной по правому краю независимо от длины наименования; над списком — поиск по наименованию, под списком — +«Выбрано: N / M». + +#### Scenario: Выравнивание ИНН +- **WHEN** в блоке «ЮЛ консолидации» есть ЮЛ «ВМГ, ООО» и «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО» +- **THEN** их ИНН стоят в одной колонке на одной вертикали + +#### Scenario: Одно ЮЛ +- **WHEN** выбраны ГК, ГСК, год, квартал и отмечено одно ЮЛ +- **THEN** «+ Создать» неактивна + +### Requirement: Кнопка «Изменить статус» в карточке версии +Шапка карточки версии [30] SHALL показывать справа «Статус отчёта» (бейдж) и кнопку «Изменить статус». Кнопка SHALL быть +активной, только если версия взята в работу текущим пользователем (`lock.locked_by` карточки = `id` из `/me`) и для +текущего статуса есть хотя бы один ручной переход (требование «Модалка «Изменить статус»»). Без своей блокировки кнопка +SHALL быть неактивной с подсказкой «Возьмите консолидацию в работу»; при своей блокировке и отсутствии ручных переходов — +неактивной. + +#### Scenario: Версия не взята в работу +- **WHEN** аналитик открыл карточку версии `DATA_CORRECTION`, блокировки нет +- **THEN** «Изменить статус» неактивна, подсказка «Возьмите консолидацию в работу» + +#### Scenario: Версия у текущего пользователя +- **WHEN** аналитик взял версию `DATA_CORRECTION` в работу и открыл карточку +- **THEN** «Изменить статус» активна + +#### Scenario: Нет ручных переходов +- **WHEN** аналитик держит блокировку версии `VERIFIED` +- **THEN** «Изменить статус» неактивна + +### Requirement: Модалка «Изменить статус» +Модалка [33-01] SHALL показывать «Текущий этап» (бейдж статуса), «Новый этап» (выпадающий список) и «Комментарий» +(≤ 1000 символов), кнопки «Отмена» / «Изменить статус». Варианты «Нового этапа» SHALL совпадать с ручными переходами бэка +(`MANUAL_TRANSITIONS` в `api/src/services/consolidation_status.py`; API их не отдаёт, фронт держит зеркало в одном месте): +- `INITIATED` → `CANCELLED`; +- `DOCUMENTS_PROCESSING` → `CANCELLED`, `INITIATED`; +- `DATA_CORRECTION` → `CANCELLED`, `INITIATED`; +- из остальных статусов ручных переходов нет. + +По умолчанию в «Новом этапе» ничего не выбрано. «Изменить статус» SHALL быть активной, только когда выбран новый этап и +комментарий непуст после обрезки пробелов (комментарий обязателен для обоих переходов). Нажатие SHALL отправлять +`POST /consolidations/versions/{id}/status {to_status, comment}` (комментарий обрезан); при 200 модалка закрывается, а +карточка версии на экране заменяется карточкой из ответа: бейдж, пояснение под заголовком и состояние кнопки «Изменить +статус» берутся из неё (после «Отменена» блокировка снята — кнопка неактивна). Ответы 409 (`VERSION_NOT_EDITABLE`, +`LOCK_REQUIRED`, `LOCKED_BY_OTHER`, `TRANSITION_NOT_ALLOWED`) и 422 (`COMMENT_REQUIRED`) SHALL показываться сообщением об +ошибке, модалка остаётся открытой, статус на экране не меняется. + +#### Scenario: Варианты из «Корректировки данных» +- **WHEN** аналитик держит блокировку версии `DATA_CORRECTION` и открывает «Изменить статус» +- **THEN** «Текущий этап» — «Корректировка данных», в «Новом этапе» ничего не выбрано, варианты — «Отменена» и + «Инициация»; «Изменить статус» неактивна + +#### Scenario: Отмена версии +- **WHEN** аналитик выбирает «Отменена», пишет комментарий «Дубль» и нажимает «Изменить статус» +- **THEN** отправлен `POST /consolidations/versions/{id}/status {to_status: "CANCELLED", comment: "Дубль"}`; модалка + закрыта; бейдж — «Отменена», «Изменить статус» неактивна; в списке консолидаций у версии статус «Отменена» + +#### Scenario: Возврат в «Инициацию» +- **WHEN** аналитик держит блокировку версии `DOCUMENTS_PROCESSING`, выбирает «Инициация» и пишет комментарий +- **THEN** после ответа 200 бейдж — «Инициация», кнопка «Изменить статус» остаётся активной (переход в «Отменена» доступен) + +#### Scenario: Комментарий из пробелов +- **WHEN** выбран новый этап, комментарий состоит из пробелов +- **THEN** «Изменить статус» неактивна + +#### Scenario: Переход отклонён сервером +- **WHEN** пока модалка открыта, статус версии изменился, и сервер отвечает 409 `TRANSITION_NOT_ALLOWED` +- **THEN** показано сообщение об ошибке, модалка открыта, бейдж на экране прежний + +### Requirement: Пояснение статуса под заголовком карточки +Под заголовком карточки версии «<ГК> — <год> <квартал>» SHALL показываться пояснение текущего статуса, а не его +название: `CONSOLIDATION_RF` — «Автоматическая консолидация, ручные проверки и сравнение КФО клиента и банка», `VERIFIED` — +«Отчет верифицирован ГО, КА ГО внес в систему номер и дату решения УОБ» (макет [30], [33-01]); `INITIATED`, +`DOCUMENTS_PROCESSING`, `DATA_CORRECTION`, `CANCELLED` — тексты аналитика, до их получения — временные из ТЗ «Статусная +модель» (`design.md`, решение без обсуждения (б)): `INITIATED` — «Система зафиксировала состав ГСК на отчетную дату и +инициатора (КА РФ или КА ГО)», `DOCUMENTS_PROCESSING` — «КА загружает документы; система отправляет pdf файлы в ИС +Сканирование, производит парсинг ОСВ, бухгалтерского баланса и ОФР», `DATA_CORRECTION` — «Выявлены некорректные данные в +ИС Сканирование, проводится ручная корректировка. Пользователь валидирует распарсенные данные и вносит правки», +`CANCELLED` — «Заявка на формирование/подтверждение КФО аннулирована по инициативе пользователя». Тексты держатся в одном +месте рядом с подписями статусов. + +#### Scenario: Пояснение «Корректировки данных» +- **WHEN** открыта карточка версии `DATA_CORRECTION` +- **THEN** под заголовком — пояснение статуса, а не слова «Корректировка данных» + +#### Scenario: Пояснение после смены статуса +- **WHEN** версия переведена в «Отменена» из модалки «Изменить статус» +- **THEN** пояснение под заголовком сменилось на пояснение статуса `CANCELLED` без перезагрузки страницы + +### Requirement: Предупреждение «Остались неуказанные ИНН» +При уходе со вкладки «ОСВ» на другую вкладку отчёта версии, если в сводке `unmapped_total > 0`, фронт SHALL показывать +модалку [31-00] «Остались неуказанные ИНН»: «В ОСВ остались неуказанные ИНН», «Не заполнено ИНН: N. Точно хотите перейти +в другой раздел?», кнопки «Остаться» / «Перейти». «Остаться» SHALL закрывать модалку без перехода, «Перейти» — выполнять +переход. При `unmapped_total = 0` переход SHALL выполняться без модалки. + +#### Scenario: Есть несопоставленные строки +- **WHEN** в сводке `unmapped_total = 2` и аналитик выбирает другую вкладку отчёта +- **THEN** показана модалка «Не заполнено ИНН: 2. Точно хотите перейти в другой раздел?»; «Остаться» оставляет вкладку + «ОСВ», «Перейти» открывает выбранную вкладку + +#### Scenario: Все строки сопоставлены +- **WHEN** `unmapped_total = 0` и аналитик выбирает другую вкладку отчёта +- **THEN** вкладка открывается без модалки diff --git a/openspec/changes/consolidations-ui/tasks.md b/openspec/changes/consolidations-ui/tasks.md new file mode 100644 index 0000000..1d45493 --- /dev/null +++ b/openspec/changes/consolidations-ui/tasks.md @@ -0,0 +1,35 @@ +## 1. Ревьюер (RSHB/arm-core#166, AURORA-1589) + +- [x] 1.1 Влить `origin/main` в ветку `AURORA-1589` (без конфликтов). +- [x] 1.2 Change `consolidations-ui`: proposal, design (Decisions, Known issues, Decisions made without discussion), спека + `consolidations-ui` — целевое поведение. +- [x] 1.3 `web/CLAUDE.md` — `lucide-react` в «Стек»; `web/ARCHITECTURE.md` — новые виджеты экранов консолидаций. +- [x] 1.4 Гайд фронта по API §5.7 — заметка о зеркале ручных переходов (PR в RSHB/arm-ka-docs, сливать вместе с #166). + +## 2. Автор PR + +- [ ] 2.1 Исправить «Known issues» 1–13 из `design.md`. +- [ ] 2.2 Вынести коммит 71ef633 («Единицы» сводной ОСВ, AURORA-1539) в отдельный PR. +- [ ] 2.3 Вынести чистые правила из компонентов в функции без React: подпись колонки «Версия», константа ручных переходов + (D-1), активность кнопки «Изменить статус» (D-3), активность «Изменить статус» в модалке, параметр `in_work_only`, + подписи и пояснения статусов. +- [ ] 2.4 Предупреждение браузера о несохранённых правках (пункт AURORA-1589; вне спеки, решение (е) в `design.md`). +- [ ] 2.5 Проверить наличие `lucide-react@0.263.1` в зеркале npm банка (Known issues 15) или заменить иконки на + `@ant-design/icons`. +- [ ] 2.6 Перед слиянием: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR. +- [ ] 2.7 Сверка экранов с макетом [34], [35], [42], [30], [33-01], [31-00] на стенде; сценарий AURORA-1589: взять версию + в работу → «Изменить статус» → «Отменена» с комментарием → новый статус в списке и запись в истории; то же для + возврата в «Инициация». + +## 3. Тесты (ревьюер, после 2.3) + +- [ ] 3.1 vitest на вынесенные функции: «Версия» (1 → пусто, 2 → «2»); переходы — совпадение с `MANUAL_TRANSITIONS` + и пустой список для `VERIFIED`, `CANCELLED`, статусов Среза 7; кнопка «Изменить статус» (своя / чужая / нет + блокировки × есть / нет переходов); модалка — нет выбора, комментарий из пробелов; `in_work_only` по умолчанию `false`; + подписи «Корректировка данных», «Отменена»; пояснение не равно подписи для статусов из спеки. + +## 4. После слияния + +- [ ] 4.1 Тексты пояснений статусов первой очереди от аналитика вместо временных (решение (б)). +- [ ] 4.2 С маршрутом согласования (Срез 7): допустимые переходы из API вместо зеркала (D-1), MODIFIED-дельта спеки. +- [ ] 4.3 ГСК консолидации в API — колонка «ГСК» и текст модалки [35] (D-8). -- 2.47.2 From 2ef4c6ae86b66118465010227809466cd9cf256c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 00:50:58 +0400 Subject: [PATCH 07/13] =?UTF-8?q?docs(web):=20lucide-react=20=D0=B2=20?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BA=D0=B5,=20=D0=B2=D0=B8=D0=B4=D0=B6?= =?UTF-8?q?=D0=B5=D1=82=D1=8B=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BE=D0=B2?= =?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=B9=20=D0=B2=20ARCHITECTURE=20(PR=20#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/ARCHITECTURE.md | 16 +++++++++++++++- web/CLAUDE.md | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index e73ada3..fb4b4fd 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -15,9 +15,23 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне | `src/utils/` | чистые функции: деньги, подписи, скачивание | `types/` | | `src/types/` | DTO API (повторяют `../api/src/schemas/`) | — | | `src/components/` | примитивы UI (таблица, теги, заголовок) | antd | -| `src/widgets/` | блоки экранов (`DocumentReview` — проверка документа со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer`) | `components/`, `store/`, `services/api/`, `utils/` | +| `src/widgets/` | блоки экранов (`DocumentReview` — проверка документа со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer`; консолидации — см. ниже) | `components/`, `store/`, `services/api/`, `utils/` | | `src/layouts/` | страницы-маршруты | `widgets/` | +### Виджеты экранов консолидаций + +Требования — `../openspec/changes/consolidations-ui/` (после архивации — `../openspec/specs/consolidations-ui/`), +`consolidation-setup`, `consolidated-osv-ui`. + +| Виджет | Экран | Что делает | +|---|---|---| +| `ConsoleTable/ConsoleTable.tsx` | [34] | список консолидаций ГК: дерево версий, «Только в работе» (`in_work_only`), действия строки, меню «⋯» | +| `ConsoleTable/LockModal.tsx` | [35] | «Взять в работу» (`POST …/lock`) и перехват с причиной (`takeover_reason`) | +| `ConsoleTable/consts.ts` | — | подписи, цвета и пояснения статусов версии — одно место для списка, карточки и модалки | +| `ConsoleLegalEntities/` | [34] | панель «Организации» выбранной версии и чек-лист | +| `NewConsoleModal/` (`CompanyOptionRow.tsx`) | [42] | «Новая консолидация»; строка ЮЛ с ИНН отдельной колонкой | +| `StatusChangeModal/ChangeStatusModal.tsx` | [33-01] | «Изменить статус» (`POST …/status`); открывается из шапки `layouts/ConsolidatedOsvPage.tsx` | + ## Границы - Сетевые запросы — только через `src/api/client.ts`; `fetch` напрямую не вызывается. - `components/` не знают про store и API; `types/` ни от чего не зависят. diff --git a/web/CLAUDE.md b/web/CLAUDE.md index dc42da7..fd5edcd 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -27,6 +27,9 @@ arm-web перенесены в arm-core #150–#157 (arm-web#7 → #151), сп ## Стек и ограничения - React 18 + TypeScript strict, rsbuild, antd 6 (основной UI-кит; daisyui/tailwind — из шаблона), react-router 7, Redux Toolkit, pdfjs-dist (legacy-сборка — для Яндекс Браузера, см. `src/widgets/DocumentReview/pdfjs.ts`). +- Иконки: основная библиотека — `@ant-design/icons`; вторая — `lucide-react` (зафиксированная версия), только для иконок + макета, которых в antd нет (боковое меню, «Взять в работу», «К отчётности», меню «⋯» консолидации). Новую иконку сначала + ищем в `@ant-design/icons`. - Пакеты `raisa-*` — `optionalDependencies` (вне банка недоступны); `package-lock.json` в git не хранится (в банке зависимости ставятся через Nexus). В контуре `package.json` урезается до сборки (`../../docs/dev/contour-transfer-guide.md` §3.2): новые пакеты, нужные сборке, добавлять и в разрешённый список там. @@ -46,7 +49,8 @@ arm-web перенесены в arm-core #150–#157 (arm-web#7 → #151), сп - `src/store/` — Redux Toolkit: `store.ts`, слайсы `companyGroupsSlice` и `consolidations` (+ `thunks.ts`). - `src/layouts/` — страницы (маршруты в `src/App.tsx`, в т.ч. `/documents/:document_id` и `/groups/:group_id/versions/:version_id/osv`), `src/widgets/` — блоки экранов (`DocumentReview` — проверка ББ/ОФР/ОСВ - со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer` — файлы ЮЛ), `src/components/` — примитивы + со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer` — файлы ЮЛ, `ConsoleTable` — список консолидаций ГК и + «Взять в работу», `StatusChangeModal` — «Изменить статус» версии), `src/components/` — примитивы (`GenericTable`, `TableTag`, `PageTitle`), `src/types/` — DTO API. ## Команды (из `web/`) -- 2.47.2 From 46d553b074f77b8a6ad0ab75b236f5c4f0f2caf3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 01:15:55 +0400 Subject: [PATCH 08/13] =?UTF-8?q?fix(web):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BE=D0=B2?= =?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=B9=20=E2=80=94=20=D0=B2=20utils/consolidations.ts=20?= =?UTF-8?q?=D1=81=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D0=BC=D0=B8=20(AURORA-1?= =?UTF-8?q?589,=20PR=20#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подписи и пояснения статусов версии, ручные переходы MANUAL_TRANSITIONS (зеркало api/src/services/consolidation_status.py, синхронная константа вместо мока), своя блокировка, активность «Изменить статус», причина ≤ 1000 после обрезки, «Версия» (1 — пусто, 2+ — номер), разбор ?in_work_only (по умолчанию выключен), «Взять в работу» по коду статуса CANCELLED. Подпись CANCELLED — «Отменена»; у строки списка — код статуса (status_code). Хук useBeforeUnload. Тесты vitest. Co-Authored-By: Claude Opus 5.5 --- web/src/hooks/useBeforeUnload.ts | 18 +++ web/src/types/consolidations/common_types.ts | 2 +- .../types/consolidations/consolidations.ts | 3 + web/src/utils/consolidations.test.ts | 135 ++++++++++++++++++ web/src/utils/consolidations.ts | 93 ++++++++++++ web/src/widgets/ConsoleTable/config.tsx | 3 +- web/src/widgets/ConsoleTable/consts.ts | 29 +--- web/src/widgets/ConsoleTable/flatten.ts | 5 +- 8 files changed, 258 insertions(+), 30 deletions(-) create mode 100644 web/src/hooks/useBeforeUnload.ts create mode 100644 web/src/utils/consolidations.test.ts create mode 100644 web/src/utils/consolidations.ts diff --git a/web/src/hooks/useBeforeUnload.ts b/web/src/hooks/useBeforeUnload.ts new file mode 100644 index 0000000..7415832 --- /dev/null +++ b/web/src/hooks/useBeforeUnload.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; + +/** + * Пока `dirty`, браузер спрашивает подтверждение при закрытии или перезагрузке вкладки (стандартный диалог браузера, + * текст задаёт браузер). Переход по меню приложения при открытой модалке закрыт её маской. + */ +export function useBeforeUnload(dirty: boolean) { + useEffect(() => { + if (!dirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + // старые Chrome/Яндекс Браузер показывают диалог только при непустом returnValue + e.returnValue = ''; + }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [dirty]); +} diff --git a/web/src/types/consolidations/common_types.ts b/web/src/types/consolidations/common_types.ts index 7d7b443..0e81edc 100644 --- a/web/src/types/consolidations/common_types.ts +++ b/web/src/types/consolidations/common_types.ts @@ -21,7 +21,7 @@ export type ConsRussianStatus = | 'Корректировка данных' | 'Консолидация ГО' | 'Консолидация РФ' - | 'Отменён' + | 'Отменена' | 'Корректировка' | 'В архиве' | 'Отклонён'; diff --git a/web/src/types/consolidations/consolidations.ts b/web/src/types/consolidations/consolidations.ts index f546b70..9742e71 100644 --- a/web/src/types/consolidations/consolidations.ts +++ b/web/src/types/consolidations/consolidations.ts @@ -33,7 +33,10 @@ export interface ConsRow { reporting_date: Date | null; version_no: number; + /** Подпись статуса — для колонки и её фильтра. */ status: ConsRussianStatus; + /** Код статуса — для правил (подпись меняется, код — нет). */ + status_code: ConsStatus; scenario: Scenario; approach: Approach; lock: LockOut | null; diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts new file mode 100644 index 0000000..2b38b92 --- /dev/null +++ b/web/src/utils/consolidations.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import type { ConsStatus, LockOut } from '../types/consolidations/common_types'; +import { + CONS_STATUS_LABEL, + MANUAL_TRANSITIONS, + canChangeStatus, + canSubmitStatusChange, + canTakeInWork, + consStatusDescription, + endSentence, + isLockedBy, + isValidReason, + manualTransitions, + needsUnmappedWarning, + parseInWorkOnly, + versionLabel, +} from './consolidations'; + +const lock = (userId: string): LockOut => ({ + locked_by: userId, + locked_by_name: 'Соколов А. В.', + locked_at: '2026-10-23T10:00:00Z', + taken_over_from: null, + takeover_reason: null, +}); + +const ALL: ConsStatus[] = Object.keys(CONS_STATUS_LABEL) as ConsStatus[]; + +describe('versionLabel', () => { + it('первая версия — пусто, последующие — номер', () => { + expect(versionLabel(1)).toBe(''); + expect(versionLabel(2)).toBe('2'); + expect(versionLabel(10)).toBe('10'); + }); +}); + +describe('parseInWorkOnly', () => { + it('по умолчанию выключен, включает только явное true', () => { + expect(parseInWorkOnly(null)).toBe(false); + expect(parseInWorkOnly('')).toBe(false); + expect(parseInWorkOnly('false')).toBe(false); + expect(parseInWorkOnly('true')).toBe(true); + }); +}); + +describe('подписи и пояснения статусов', () => { + it('подписи из спеки', () => { + expect(CONS_STATUS_LABEL.DATA_CORRECTION).toBe('Корректировка данных'); + expect(CONS_STATUS_LABEL.CANCELLED).toBe('Отменена'); + }); + it('пояснение не повторяет подпись', () => { + for (const s of ALL) { + expect(consStatusDescription(s)).not.toBe(CONS_STATUS_LABEL[s]); + } + }); + it('пояснения статусов первой очереди и макета заданы', () => { + for (const s of ['INITIATED', 'DOCUMENTS_PROCESSING', 'DATA_CORRECTION', 'CANCELLED', 'CONSOLIDATION_RF', 'VERIFIED'] as const) { + expect(consStatusDescription(s)).not.toBe(''); + } + expect(consStatusDescription('CONSOLIDATION_RF')).toBe('Автоматическая консолидация, ручные проверки и сравнение КФО клиента и банка'); + expect(consStatusDescription('VERIFIED')).toBe('Отчет верифицирован ГО, КА ГО внес в систему номер и дату решения УОБ'); + }); +}); + +describe('ручные переходы (зеркало MANUAL_TRANSITIONS бэка)', () => { + it('совпадают с api/src/services/consolidation_status.py', () => { + expect(MANUAL_TRANSITIONS).toEqual({ + INITIATED: ['CANCELLED'], + DOCUMENTS_PROCESSING: ['CANCELLED', 'INITIATED'], + DATA_CORRECTION: ['CANCELLED', 'INITIATED'], + }); + }); + it('из остальных статусов переходов нет', () => { + for (const s of ['VERIFIED', 'CANCELLED', 'CONSOLIDATION_RF', 'VERIFICATION_RF', 'CONSOLIDATION_GO', 'VERIFICATION_GO'] as const) { + expect(manualTransitions(s)).toEqual([]); + } + }); +}); + +describe('блокировка и «Изменить статус»', () => { + it('своя блокировка', () => { + expect(isLockedBy(lock('me'), 'me')).toBe(true); + expect(isLockedBy(lock('other'), 'me')).toBe(false); + expect(isLockedBy(null, 'me')).toBe(false); + expect(isLockedBy(undefined, 'me')).toBe(false); + }); + it('кнопка: своя / чужая / нет блокировки × есть / нет переходов', () => { + expect(canChangeStatus('DATA_CORRECTION', lock('me'), 'me')).toBe(true); + expect(canChangeStatus('DATA_CORRECTION', lock('other'), 'me')).toBe(false); + expect(canChangeStatus('DATA_CORRECTION', null, 'me')).toBe(false); + expect(canChangeStatus('VERIFIED', lock('me'), 'me')).toBe(false); + expect(canChangeStatus('VERIFIED', lock('other'), 'me')).toBe(false); + expect(canChangeStatus('VERIFIED', null, 'me')).toBe(false); + }); + it('«Взять в работу» недоступна только у отменённой версии', () => { + expect(canTakeInWork('CANCELLED')).toBe(false); + expect(canTakeInWork('INITIATED')).toBe(true); + expect(canTakeInWork('VERIFIED')).toBe(true); + }); +}); + +describe('причина и комментарий', () => { + it('пустая и из пробелов — неверна', () => { + expect(isValidReason('')).toBe(false); + expect(isValidReason(' ')).toBe(false); + expect(isValidReason(undefined)).toBe(false); + expect(isValidReason(null)).toBe(false); + }); + it('ровно 1000 символов можно, 1001 — нет', () => { + expect(isValidReason('a'.repeat(1000))).toBe(true); + expect(isValidReason('a'.repeat(1001))).toBe(false); + expect(isValidReason(` ${'a'.repeat(1000)} `)).toBe(true); + }); + it('модалка: нужен выбранный этап и непустой комментарий', () => { + expect(canSubmitStatusChange(undefined, 'Дубль')).toBe(false); + expect(canSubmitStatusChange('CANCELLED', undefined)).toBe(false); + expect(canSubmitStatusChange('CANCELLED', ' ')).toBe(false); + expect(canSubmitStatusChange('CANCELLED', 'Дубль')).toBe(true); + }); +}); + +describe('endSentence', () => { + it('не ставит вторую точку после инициалов', () => { + expect(endSentence('Соколов А. В.')).toBe('Соколов А. В.'); + expect(endSentence('Dev: Аналитик ГО')).toBe('Dev: Аналитик ГО.'); + }); +}); + +describe('модалка [31-00]', () => { + it('только при несопоставленных строках', () => { + expect(needsUnmappedWarning(2)).toBe(true); + expect(needsUnmappedWarning(0)).toBe(false); + expect(needsUnmappedWarning(undefined)).toBe(false); + }); +}); diff --git a/web/src/utils/consolidations.ts b/web/src/utils/consolidations.ts new file mode 100644 index 0000000..caad8c5 --- /dev/null +++ b/web/src/utils/consolidations.ts @@ -0,0 +1,93 @@ +// Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные +// переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API. +import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types'; + +/** Подписи статусов версии — одни для списка, фильтра «Статус», шапки карточки и модалки «Изменить статус». */ +export const CONS_STATUS_LABEL: Record = { + INITIATED: 'Инициация', + DOCUMENTS_PROCESSING: 'Обработка документов', + DATA_CORRECTION: 'Корректировка данных', + CONSOLIDATION_RF: 'Консолидация РФ', + VERIFICATION_RF: 'Верификация РФ', + CONSOLIDATION_GO: 'Консолидация ГО', + VERIFICATION_GO: 'Верификация ГО', + VERIFIED: 'Верифицирован', + CANCELLED: 'Отменена', + ADJUSTMENT: 'Корректировка', + ARCHIVED: 'В архиве', + REJECTED: 'Отклонён', +}; + +/** + * Пояснение статуса под заголовком карточки версии. `CONSOLIDATION_RF`, `VERIFIED` — тексты макета [30], [33-01]; + * `INITIATED`, `DOCUMENTS_PROCESSING`, `DATA_CORRECTION`, `CANCELLED` — временные из ТЗ «Статусная модель», пока нет + * текстов аналитика (design.md change consolidations-ui, решение (б)). + */ +export const CONS_STATUS_DESCRIPTION: Partial> = { + INITIATED: 'Система зафиксировала состав ГСК на отчетную дату и инициатора (КА РФ или КА ГО)', + DOCUMENTS_PROCESSING: + 'КА загружает документы; система отправляет pdf файлы в ИС Сканирование, производит парсинг ОСВ, бухгалтерского баланса и ОФР', + DATA_CORRECTION: + 'Выявлены некорректные данные в ИС Сканирование, проводится ручная корректировка. Пользователь валидирует распарсенные данные и вносит правки', + CONSOLIDATION_RF: 'Автоматическая консолидация, ручные проверки и сравнение КФО клиента и банка', + CONSOLIDATION_GO: 'Формирование и проверка КФО, внесение корректировок на уровне ГО', + VERIFICATION_RF: 'Проверка и подтверждение КФО на уровне РФ', + VERIFICATION_GO: 'Проверка и подтверждение КФО на уровне ГО', + VERIFIED: 'Отчет верифицирован ГО, КА ГО внес в систему номер и дату решения УОБ', + CANCELLED: 'Заявка на формирование/подтверждение КФО аннулирована по инициативе пользователя', +}; + +/** Пояснение статуса; нет текста — пусто (название статуса под заголовком не повторяем). */ +export const consStatusDescription = (status: ConsStatus): string => CONS_STATUS_DESCRIPTION[status] ?? ''; + +/** + * Ручные переходы статуса версии — зеркало `MANUAL_TRANSITIONS` из `api/src/services/consolidation_status.py` + * (API допустимые переходы не отдаёт). Расхождение с бэком сервер ловит 409 `TRANSITION_NOT_ALLOWED`. С маршрутом + * согласования (Срез 7) заменяется ответом API. + */ +export const MANUAL_TRANSITIONS: Partial> = { + INITIATED: ['CANCELLED'], + DOCUMENTS_PROCESSING: ['CANCELLED', 'INITIATED'], + DATA_CORRECTION: ['CANCELLED', 'INITIATED'], +}; + +/** Допустимые ручные переходы из статуса; нет — пустой список. */ +export const manualTransitions = (status: ConsStatus): readonly ConsStatus[] => MANUAL_TRANSITIONS[status] ?? []; + +/** Версию держит текущий пользователь. */ +export const isLockedBy = (lock: LockOut | null | undefined, userId: string): boolean => !!lock && lock.locked_by === userId; + +/** Кнопка «Изменить статус» в шапке карточки: своя блокировка и есть ручные переходы. */ +export const canChangeStatus = (status: ConsStatus, lock: LockOut | null | undefined, userId: string): boolean => + isLockedBy(lock, userId) && manualTransitions(status).length > 0; + +/** Лимит причины перехвата и комментария к смене статуса (`MAX_TAKEOVER_REASON`, `MAX_STATUS_COMMENT` бэка). */ +export const MAX_REASON_LENGTH = 1000; + +/** Причина перехвата / комментарий: непусто после обрезки пробелов и не длиннее лимита. */ +export const isValidReason = (text: string | null | undefined): boolean => { + const trimmed = (text ?? '').trim(); + return trimmed.length > 0 && trimmed.length <= MAX_REASON_LENGTH; +}; + +/** «Изменить статус» в модалке: выбран новый этап и комментарий непуст. */ +export const canSubmitStatusChange = (toStatus: ConsStatus | null | undefined, comment: string | null | undefined): boolean => + !!toStatus && isValidReason(comment); + +/** Колонка «Версия»: у первой версии пусто, у последующих — номер. */ +export const versionLabel = (versionNo: number): string => (versionNo === 1 ? '' : String(versionNo)); + +/** Флажок «Только в работе» из `?in_work_only=`: включён только явным `true` (по умолчанию выключен, как в API). */ +export const parseInWorkOnly = (param: string | null): boolean => param === 'true'; + +/** «Взять в работу» недоступна у отменённой версии — по коду статуса, а не по подписи. */ +export const canTakeInWork = (status: ConsStatus): boolean => status !== 'CANCELLED'; + +/** Точка в конце фразы после ФИО: «Соколов А. В.» уже заканчивается точкой — вторую не ставим. */ +export const endSentence = (text: string): string => (text.endsWith('.') ? text : `${text}.`); + +/** «+ Создать» в модалке «Новая консолидация»: в консолидации не меньше двух ЮЛ (иначе 422 `PERIMETER_TOO_SMALL`). */ +export const MIN_PERIMETER = 2; + +/** Модалка [31-00] нужна при уходе со вкладки «ОСВ», пока есть несопоставленные строки. */ +export const needsUnmappedWarning = (unmappedTotal: number | null | undefined): boolean => (unmappedTotal ?? 0) > 0; diff --git a/web/src/widgets/ConsoleTable/config.tsx b/web/src/widgets/ConsoleTable/config.tsx index 0e580e0..e14c50b 100644 --- a/web/src/widgets/ConsoleTable/config.tsx +++ b/web/src/widgets/ConsoleTable/config.tsx @@ -2,6 +2,7 @@ import type { TableConfig } from '../../components/GenericTable'; import { TableTag } from '../../components/TableTag/TableTag'; import type { ConsRussianStatus, QuarterEnum } from '../../types/consolidations/common_types'; import type { ConsRow } from '../../types/consolidations/consolidations'; +import { versionLabel } from '../../utils/consolidations'; import { formatMoscowDate } from '../../utils/dates'; import { quarterShortLabel } from '../../utils/labels'; import { statusColorMapping } from './consts'; @@ -15,7 +16,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 }, render: (v) => (String(v).trim() !== '1' ? '' : v) }, + { key: 'version_no', title: 'Версия', features: { sort: true }, render: (v) => versionLabel(Number(v)) }, { key: 'status', title: 'Статус', diff --git a/web/src/widgets/ConsoleTable/consts.ts b/web/src/widgets/ConsoleTable/consts.ts index dba1fc2..aff5926 100644 --- a/web/src/widgets/ConsoleTable/consts.ts +++ b/web/src/widgets/ConsoleTable/consts.ts @@ -1,21 +1,7 @@ import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; -import type { ConsRussianStatus, ConsStatus } from '../../types/consolidations/common_types'; - -export const statusMapping: Record = { - VERIFIED: 'Верифицирован', - VERIFICATION_GO: 'Верификация ГО', - VERIFICATION_RF: 'Верификация РФ', - INITIATED: 'Инициация', - DOCUMENTS_PROCESSING: 'Обработка документов', - DATA_CORRECTION: 'Корректировка данных', - CONSOLIDATION_GO: 'Консолидация ГО', - CONSOLIDATION_RF: 'Консолидация РФ', - CANCELLED: 'Отменён', - ADJUSTMENT: 'Корректировка', - ARCHIVED: 'В архиве', - REJECTED: 'Отклонён', -}; +import type { ConsRussianStatus } from '../../types/consolidations/common_types'; +// Подписи и пояснения статусов — `utils/consolidations.ts` (CONS_STATUS_LABEL, CONS_STATUS_DESCRIPTION); здесь — цвета бейджей. export const statusColorMapping: Record = { 'В архиве': TAG_PRESETS.default, 'Верификация ГО': TAG_PRESETS.geekblue, @@ -28,14 +14,5 @@ export const statusColorMapping: Record = { 'Корректировка данных': TAG_PRESETS.gold, 'Обработка документов': TAG_PRESETS.gold, Отклонён: TAG_PRESETS.red, - Отменён: TAG_PRESETS.red, -}; - -export const consStatusDescriptionMapping: Partial> = { - CONSOLIDATION_RF: 'Формирование и проверка КФО, внесение корректировок на уровне РФ', - CONSOLIDATION_GO: 'Формирование и проверка КФО, внесение корректировок на уровне ГО', - VERIFICATION_GO: 'Проверка и подтверждение КФО на уровне ГО', - VERIFICATION_RF: 'Проверка и подтверждение КФО на уровне РФ', - CANCELLED: 'Формирование КФО отменено', - VERIFIED: 'КФО верифицирована, внесены номер и дата решения УОБ', + Отменена: TAG_PRESETS.red, }; diff --git a/web/src/widgets/ConsoleTable/flatten.ts b/web/src/widgets/ConsoleTable/flatten.ts index 8ab2e76..4ca8762 100644 --- a/web/src/widgets/ConsoleTable/flatten.ts +++ b/web/src/widgets/ConsoleTable/flatten.ts @@ -1,5 +1,5 @@ import type { ConsRow, IConsolidation } from '../../types/consolidations/consolidations'; -import { statusMapping } from './consts'; +import { CONS_STATUS_LABEL } from '../../utils/consolidations'; export function flattenCons(cons: IConsolidation[]): ConsRow[] { const out: ConsRow[] = []; @@ -18,7 +18,8 @@ export function flattenCons(cons: IConsolidation[]): ConsRow[] { quarter: con.quarter, reporting_date: rep_date, version_no: v.version_no, - status: statusMapping[v.status], + status: CONS_STATUS_LABEL[v.status], + status_code: v.status, scenario: v.scenario, approach: v.approach, lock: v.lock, -- 2.47.2 From 54de39727be6a893434ba6c58e067d147e3e56aa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 01:15:55 +0400 Subject: [PATCH 09/13] =?UTF-8?q?fix(web):=20=C2=AB=D0=95=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D1=8B=C2=BB=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=9E=D0=A1=D0=92=20=E2=80=94=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=81=D1=88=D1=82=D0=B0=D0=B1=20=D0=BD=D0=B0=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B0=D1=85=20=D0=B1=D0=B5=D0=B7=20float,=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=BB=D1=8C=20=E2=80=94=20=C2=AB=E2=80=94=C2=BB=20?= =?UTF-8?q?(AURORA-1589,=20PR=20#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatScaledAmount в utils/money.ts: запятая сдвигается на 3/6/9 разрядов в строке, округление половины от нуля на BigInt; нулевая сумма — «—» (zeroDash, как до 71ef633). Подписи «млн руб.», «млрд руб.» без точки. Исправляет коммит 71ef633. Co-Authored-By: Claude Opus 5.5 --- web/src/utils/money.test.ts | 42 +++++++++++++++- web/src/utils/money.ts | 48 +++++++++++++++++++ .../ConsolidatedOsv/useNumberDisplay.tsx | 45 +++++++---------- 3 files changed, 105 insertions(+), 30 deletions(-) diff --git a/web/src/utils/money.test.ts b/web/src/utils/money.test.ts index 274647c..88a6ff0 100644 --- a/web/src/utils/money.test.ts +++ b/web/src/utils/money.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatAmount, parseAmountInput, sumAmounts } from './money'; +import { formatAmount, formatScaledAmount, parseAmountInput, sumAmounts } from './money'; const NBSP = ' '; @@ -75,3 +75,43 @@ describe('sumAmounts', () => { expect(sumAmounts(['1.234'])).toBeNull(); }); }); + +describe('formatScaledAmount', () => { + const rub = (precision: number) => ({ scale: 0, precision, zeroDash: true }); + const thousands = (precision: number) => ({ scale: 3, precision, zeroDash: true }); + it('рубли с двумя знаками — как formatAmount', () => { + expect(formatScaledAmount('4454151.93', rub(2))).toBe(`4${NBSP}454${NBSP}151,93`); + expect(formatScaledAmount('-8213.3', rub(2))).toBe(`-8${NBSP}213,30`); + }); + it('сдвиг запятой без float', () => { + expect(formatScaledAmount('4454151.93', thousands(2))).toBe(`4${NBSP}454,15`); + expect(formatScaledAmount('128058143.65', { scale: 6, precision: 3 })).toBe('128,058'); + expect(formatScaledAmount('2555438965.10', { scale: 9, precision: 2 })).toBe('2,56'); + // float: 9007199254740993.01 / 1000 теряет разряды, строка — нет + expect(formatScaledAmount('9007199254740993.01', thousands(2))).toBe(`9${NBSP}007${NBSP}199${NBSP}254${NBSP}740,99`); + }); + it('округление — половина от нуля', () => { + expect(formatScaledAmount('1234.5', thousands(0))).toBe('1'); + expect(formatScaledAmount('1500', thousands(0))).toBe('2'); + expect(formatScaledAmount('-1500', thousands(0))).toBe('-2'); + expect(formatScaledAmount('0.05', rub(1))).toBe('0,1'); + expect(formatScaledAmount('2.345', rub(2))).toBe('2,35'); + }); + it('больше знаков, чем в сумме, — дополняется нулями', () => { + expect(formatScaledAmount('12.5', rub(3))).toBe('12,500'); + expect(formatScaledAmount('100', rub(0))).toBe('100'); + }); + it('ноль — прочерк по исходной сумме; округлённый до нуля — без минуса', () => { + expect(formatScaledAmount('0.00', thousands(2))).toBe('—'); + expect(formatScaledAmount('0', rub(2))).toBe('—'); + expect(formatScaledAmount('0.00', { scale: 0, precision: 2 })).toBe('0,00'); + expect(formatScaledAmount('0.40', { scale: 6, precision: 2, zeroDash: true })).toBe('0,00'); + expect(formatScaledAmount('-0.40', { scale: 6, precision: 2, zeroDash: true })).toBe('0,00'); + }); + it('пустое — прочерк, нераспознанное — как есть', () => { + expect(formatScaledAmount(null, rub(2))).toBe('—'); + expect(formatScaledAmount('', rub(2))).toBe('—'); + expect(formatScaledAmount('abc', rub(2))).toBe('abc'); + expect(formatScaledAmount('1e5', rub(2))).toBe('1e5'); + }); +}); diff --git a/web/src/utils/money.ts b/web/src/utils/money.ts index 9d4150e..f1e6082 100644 --- a/web/src/utils/money.ts +++ b/web/src/utils/money.ts @@ -38,6 +38,54 @@ export function formatAmount(value: string | null | undefined, format: AmountFor return `${isZero ? '' : sign}${grouped},${cents}`; } +export type ScaledFormat = { + /** Степень десяти единиц показа: 0 — руб., 3 — тыс. руб., 6 — млн руб., 9 — млрд руб. */ + scale: number; + /** Знаков после запятой (0…3). */ + precision: number; + /** Ноль показывать прочерком (по исходной сумме: 0,40 руб. в млн — «0,00», а не «—»). */ + zeroDash?: boolean; +}; + +/** + * Сумма из API (строка Decimal) в единицах 10^scale с `precision` знаками → «1 234,57». Считается на строке и BigInt: + * запятая сдвигается на `scale` разрядов, округление — половина от нуля; float не используется. Пустое — «—», + * нераспознанное — как есть. + */ +export function formatScaledAmount(value: string | null | undefined, format: ScaledFormat): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + const match = PLAIN.exec(value); + if (!match) { + return value; + } + const [, sign, intPart, frac = ''] = match; + const isZero = /^0+$/.test(intPart) && /^0*$/.test(frac); + if (isZero && format.zeroDash) { + return '—'; + } + const precision = Math.max(0, Math.trunc(format.precision)); + // число = digits / 10^places + const digits = BigInt(intPart + frac); + const places = frac.length + Math.max(0, Math.trunc(format.scale)); + let scaled: bigint; + if (places > precision) { + const divisor = 10n ** BigInt(places - precision); + scaled = digits / divisor; + if ((digits % divisor) * 2n >= divisor) scaled += 1n; + } else { + scaled = digits * 10n ** BigInt(precision - places); + } + const unit = 10n ** BigInt(precision); + const grouped = (scaled / unit).toString().replace(/\B(?=(\d{3})+(?!\d))/g, NBSP); + const minus = sign && scaled !== 0n ? '-' : ''; + if (precision === 0) { + return `${minus}${grouped}`; + } + return `${minus}${grouped},${(scaled % unit).toString().padStart(precision, '0')}`; +} + /** Ввод аналитика → строка суммы для API; пустое — null; неверный формат — ok: false. */ export function parseAmountInput(input: string): { ok: true; value: string | null } | { ok: false } { const normalized = input.replace(/[\s ]/g, '').replace(',', '.'); diff --git a/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx b/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx index 80a1552..141dfd9 100644 --- a/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx +++ b/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx @@ -1,42 +1,29 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useCallback, useState } from 'react'; +import { formatScaledAmount } from '../../utils/money'; -export type Unit = 'руб.' | 'тыс. руб.' | 'млн. руб.' | 'млрд. руб.'; +export type Unit = 'руб.' | 'тыс. руб.' | 'млн руб.' | 'млрд руб.'; -export const UNITS: { value: Unit; label: string }[] = [ - { value: 'руб.', label: 'руб.' }, - { value: 'тыс. руб.', label: 'тыс. руб.' }, - { value: 'млн. руб.', label: 'млн. руб.' }, - { value: 'млрд. руб.', label: 'млрд. руб.' }, -]; - -const DIVISORS: Record = { - 'руб.': 1, - 'тыс. руб.': 1e3, - 'млн. руб.': 1e6, - 'млрд. руб.': 1e9, +/** Единицы показа сумм сводной ОСВ и степень десяти (суммы API — в рублях). */ +export const UNIT_SCALE: Record = { + 'руб.': 0, + 'тыс. руб.': 3, + 'млн руб.': 6, + 'млрд руб.': 9, }; -export function createFormatter(precision: number): Intl.NumberFormat { - return new Intl.NumberFormat('ru-RU', { - minimumFractionDigits: precision, - maximumFractionDigits: precision, - }); -} +export const UNITS: { value: Unit; label: string }[] = (Object.keys(UNIT_SCALE) as Unit[]).map((u) => ({ value: u, label: u })); -export function formatNumeric(raw: string | null, unit: Unit, nf: Intl.NumberFormat): string { - if (raw == null || raw.trim() === '') return '—'; - const n = Number(raw); - if (!Number.isFinite(n)) return raw; - return nf.format(n / DIVISORS[unit]); -} +export const PRECISIONS = [0, 1, 2, 3] as const; +/** «Единицы» и «Знаков после запятой» сводной ОСВ [30]: только показ, суммы считаются на строках (`utils/money.ts`). */ export function useNumberDisplay(initial?: { unit?: Unit; precision?: number }) { const [unit, setUnit] = useState(initial?.unit ?? 'руб.'); const [precision, setPrecision] = useState(initial?.precision ?? 2); - const nf = useMemo(() => createFormatter(precision), [precision]); - - const format = useCallback((raw: string | null) => formatNumeric(raw, unit, nf), [unit, nf]); + const format = useCallback( + (raw: string | null) => formatScaledAmount(raw, { scale: UNIT_SCALE[unit], precision, zeroDash: true }), + [unit, precision], + ); return { unit, setUnit, precision, setPrecision, format }; } -- 2.47.2 From ccca6dfa136b1c15f0cc5a3890f0805aa1c42b9c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 01:15:55 +0400 Subject: [PATCH 10/13] =?UTF-8?q?fix(web):=20=C2=AB=D0=98=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=C2=BB=20=E2=80=94=20=D0=BF=D1=83=D1=82=D1=8C=20/consolid?= =?UTF-8?q?ations/=E2=80=A6/status,=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B5,=20[31-0?= =?UTF-8?q?0]=20(AURORA-1589,=20PR=20#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 404 был из-за опечатки в пути (/consolidation/ без s). Модалка берёт статус из карточки страницы, а после 200 страница берёт карточку из ответа; в «Новом этапе» ничего не выбрано; ошибки — текст сервера, модалка остаётся открытой. Кнопка в шапке активна при своей блокировке и наличии ручных переходов, подсказка «Возьмите консолидацию в работу». Пояснение под заголовком — текст статуса, а не название. Модалка «Остались неуказанные ИНН» при уходе с «ОСВ» (вкладки шагов пока неактивны). Предупреждение браузера при начатом вводе. Co-Authored-By: Claude Opus 5.5 --- web/src/layouts/ConsolidatedOsvPage.tsx | 47 +++---- web/src/services/api/consolidations.ts | 15 +-- web/src/types/api.ts | 2 +- web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 118 +++++++++++++--- .../StatusChangeModal/ChangeStatusModal.tsx | 127 +++++++----------- 5 files changed, 175 insertions(+), 134 deletions(-) diff --git a/web/src/layouts/ConsolidatedOsvPage.tsx b/web/src/layouts/ConsolidatedOsvPage.tsx index 485db65..7aaf552 100644 --- a/web/src/layouts/ConsolidatedOsvPage.tsx +++ b/web/src/layouts/ConsolidatedOsvPage.tsx @@ -1,8 +1,9 @@ import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; -import { Alert, Button, InputNumber, Select, Space, Tooltip, message } from 'antd'; +import { Alert, Button, Select, Space, Tooltip, message } from 'antd'; import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../api/client'; +import { useCurrentUser } from '../auth/MeContext'; import PageTitle from '../components/PageTitle'; import { TableTag } from '../components/TableTag/TableTag'; import { TAG_PRESETS } from '../components/TableTag/presets'; @@ -11,18 +12,18 @@ import { exportOsv } from '../services/api/osv'; import type { ConsolidatedRowOut } from '../types/api'; import type { ConsRussianStatus } from '../types/consolidations/common_types'; import type { VersionCard } from '../types/consolidations/version_members'; +import { CONS_STATUS_LABEL, canChangeStatus, consStatusDescription, isLockedBy } from '../utils/consolidations'; import { exportFilename, saveBlob } from '../utils/download'; import { quarterShortLabel } from '../utils/labels'; -import { consStatusDescriptionMapping, statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts'; +import { statusColorMapping } from '../widgets/ConsoleTable/consts'; import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal'; import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps'; import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable'; import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping'; import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv'; -import { useCurrentUser } from '../auth/MeContext'; +import { PRECISIONS, UNITS, useNumberDisplay } from '../widgets/ConsolidatedOsv/useNumberDisplay'; import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; -import { UNITS, useNumberDisplay } from '../widgets/ConsolidatedOsv/useNumberDisplay'; function OsvContent({ versionId }: { versionId: string }) { const [card, setCard] = useState(null); @@ -63,9 +64,12 @@ function OsvContent({ versionId }: { versionId: string }) { const s = osv.summary; const unmapped = s?.unmapped_total ?? 0; const onlyUnmapped = osv.filters.unmapped === true; - const russianStatus = card ? (statusMapping[card.status] ?? card.status) : ''; - const description = card ? (consStatusDescriptionMapping[card.status] ?? russianStatus) : ''; + const russianStatus = card ? (CONS_STATUS_LABEL[card.status] ?? card.status) : ''; + // под заголовком — пояснение статуса, не его название + const description = card ? consStatusDescription(card.status) : ''; const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default; + const statusChangeAllowed = card ? canChangeStatus(card.status, card.lock, currentUser.id) : false; + const ownLock = card ? isLockedBy(card.lock, currentUser.id) : false; const groupName = card?.company_group.name ?? ''; const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : ''; return ( @@ -91,28 +95,22 @@ function OsvContent({ versionId }: { versionId: string }) { text={russianStatus} {...preset} /> - {card.lock === null || (card.lock && card.lock.locked_by !== currentUser.id) ? ( - - - - ) : ( + {/* активна при своей блокировке и наличии ручных переходов из текущего статуса */} + - )} + ) } /> - +
Знаков после запятой
({ label: i, value: i }))} - labelRender={(v) => (v.value ? statusMapping[v.value as ConsStatus] : v.value)} - optionRender={(v) => (v.value ? statusMapping[v.value as ConsStatus] : v.value)}> - - - - - - )} +
+ +