fix(web): «Изменить статус» — путь /consolidations/…/status, модалка по спеке, [31-00] (AURORA-1589, PR #166)

404 был из-за опечатки в пути (/consolidation/ без s). Модалка берёт статус из карточки
страницы, а после 200 страница берёт карточку из ответа; в «Новом этапе» ничего не
выбрано; ошибки — текст сервера, модалка остаётся открытой. Кнопка в шапке активна при
своей блокировке и наличии ручных переходов, подсказка «Возьмите консолидацию в работу».
Пояснение под заголовком — текст статуса, а не название. Модалка «Остались неуказанные
ИНН» при уходе с «ОСВ» (вкладки шагов пока неактивны). Предупреждение браузера при
начатом вводе.

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

View File

@ -1,8 +1,9 @@
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; 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 { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { ApiError } from '../api/client'; import { ApiError } from '../api/client';
import { useCurrentUser } from '../auth/MeContext';
import PageTitle from '../components/PageTitle'; import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag'; import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets'; 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 { ConsolidatedRowOut } from '../types/api';
import type { ConsRussianStatus } from '../types/consolidations/common_types'; import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members'; 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 { exportFilename, saveBlob } from '../utils/download';
import { quarterShortLabel } from '../utils/labels'; 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 { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps'; import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable'; import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable';
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping'; import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv'; 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 ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
import { UNITS, useNumberDisplay } from '../widgets/ConsolidatedOsv/useNumberDisplay';
function OsvContent({ versionId }: { versionId: string }) { function OsvContent({ versionId }: { versionId: string }) {
const [card, setCard] = useState<VersionCard | null>(null); const [card, setCard] = useState<VersionCard | null>(null);
@ -63,9 +64,12 @@ function OsvContent({ versionId }: { versionId: string }) {
const s = osv.summary; const s = osv.summary;
const unmapped = s?.unmapped_total ?? 0; const unmapped = s?.unmapped_total ?? 0;
const onlyUnmapped = osv.filters.unmapped === true; const onlyUnmapped = osv.filters.unmapped === true;
const russianStatus = card ? (statusMapping[card.status] ?? card.status) : ''; const russianStatus = card ? (CONS_STATUS_LABEL[card.status] ?? card.status) : '';
const description = card ? (consStatusDescriptionMapping[card.status] ?? russianStatus) : ''; // под заголовком — пояснение статуса, не его название
const description = card ? consStatusDescription(card.status) : '';
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default; 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 groupName = card?.company_group.name ?? '';
const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : ''; const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : '';
return ( return (
@ -91,28 +95,22 @@ function OsvContent({ versionId }: { versionId: string }) {
text={russianStatus} text={russianStatus}
{...preset} {...preset}
/> />
{card.lock === null || (card.lock && card.lock.locked_by !== currentUser.id) ? ( {/* активна при своей блокировке и наличии ручных переходов из текущего статуса */}
<Tooltip title='Сначала возьмите версию в работу'> <Tooltip title={ownLock ? undefined : 'Возьмите консолидацию в работу'}>
<Button size='middle' style={{ fontSize: 12 }} type='default' disabled>
Изменить статус
</Button>
</Tooltip>
) : (
<Button <Button
size='middle' size='middle'
onClick={() => {
setModalOpen(true);
}}
style={{ fontSize: 12 }} style={{ fontSize: 12 }}
type='default'> type='default'
disabled={!statusChangeAllowed}
onClick={() => setModalOpen(true)}>
Изменить статус Изменить статус
</Button> </Button>
)} </Tooltip>
</Space> </Space>
) )
} }
/> />
<OsvTabs /> <OsvTabs unmappedTotal={unmapped} />
<main <main
style={{ style={{
flex: '1 1 auto', flex: '1 1 auto',
@ -134,7 +132,7 @@ function OsvContent({ versionId }: { versionId: string }) {
<div style={{ fontSize: 10, color: '#667085' }}>Знаков после запятой</div> <div style={{ fontSize: 10, color: '#667085' }}>Знаков после запятой</div>
<Select <Select
variant='underlined' variant='underlined'
options={[0, 1, 2, 3].map((i) => ({ label: i, value: i }))} options={PRECISIONS.map((i) => ({ label: i, value: i }))}
value={precision} value={precision}
onChange={(v) => setPrecision(v ?? 2)} onChange={(v) => setPrecision(v ?? 2)}
style={{ width: 90 }} style={{ width: 90 }}
@ -196,14 +194,7 @@ function OsvContent({ versionId }: { versionId: string }) {
onMapped={osv.refresh} onMapped={osv.refresh}
/> />
)} )}
<ChangeStatusModal {card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}
open={statusModalOpen}
versionId={versionId}
onSuccess={setCard}
setClose={() => {
setModalOpen(false);
}}
/>
</div> </div>
); );
} }

View File

@ -4,20 +4,17 @@ import type { LockOut } from '../../types/consolidations/common_types';
import type { IConsolidation } from '../../types/consolidations/consolidations'; import type { IConsolidation } from '../../types/consolidations/consolidations';
import type { ChecklistBackendShape, VersionCard } from '../../types/consolidations/version_members'; import type { ChecklistBackendShape, VersionCard } from '../../types/consolidations/version_members';
/** Список консолидаций ГК; `in_work_only` — серверный фильтр «Только в работе» (по умолчанию false). */
export const getConsolidations = (groupId: string, in_work_only: boolean) => export const getConsolidations = (groupId: string, in_work_only: boolean) =>
api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations?in_work_only=${in_work_only}`); api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations`, { in_work_only });
export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`); export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`);
export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body); export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body);
export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`); export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`);
export const getChecklist = (versionId: string) => api.get<ChecklistBackendShape>(`/consolidations/versions/${versionId}/checklist`); export const getChecklist = (versionId: string) => api.get<ChecklistBackendShape>(`/consolidations/versions/${versionId}/checklist`);
/** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */ /** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */
export const lockVersion = (versionId: string, reason?: string | null) => { export const lockVersion = (versionId: string, reason?: string | null) =>
const body = {}; api.post<LockOut>(`/consolidations/versions/${versionId}/lock`, reason ? { takeover_reason: reason } : {});
if (reason) {
body['takeover_reason'] = reason;
}
return api.post<LockOut>(`/consolidations/versions/${versionId}/lock`, body);
};
/** Ручная смена статуса (гайд §5.7): нужна своя блокировка; ответ — карточка версии с новым статусом. */
export const updateStatus = (versionId: string, body: VersionStatusUpdate) => export const updateStatus = (versionId: string, body: VersionStatusUpdate) =>
api.post<VersionCard>(`/consolidation/versions/${versionId}/status`, body); api.post<VersionCard>(`/consolidations/versions/${versionId}/status`, body);

View File

@ -1,6 +1,6 @@
// DTO API arm-core (/api/v1). Повторяют arm-core/api/src/schemas/; Decimal и даты — строки. // DTO API arm-core (/api/v1). Повторяют arm-core/api/src/schemas/; Decimal и даты — строки.
import { ConsStatus } from './consolidations/common_types'; import type { ConsStatus } from './consolidations/common_types';
export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4'; export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4';
export type DocumentType = 'BALANCE' | 'INCOME' | 'OSV' | 'DISCLOSURE' | 'OTHER'; export type DocumentType = 'BALANCE' | 'INCOME' | 'OSV' | 'DISCLOSURE' | 'OTHER';

View File

@ -1,26 +1,106 @@
import { Segmented, Tooltip } from 'antd'; import { WarningOutlined } from '@ant-design/icons';
import { Button, Modal, Segmented, Tooltip } from 'antd';
import { useState } from 'react';
import { needsUnmappedWarning } from '../../utils/consolidations';
const SOON = 'Будет в следующих срезах'; const SOON = 'Будет в следующих срезах';
const OSV = 'ОСВ';
const TABS = ['Шахматка', 'Таблица корректировки', 'Гудвилл', 'КФО', 'КФО Общий', 'Подтверждение КФО клиента']; const TABS = ['Шахматка', 'Таблица корректировки', 'Гудвилл', 'КФО', 'КФО Общий', 'Подтверждение КФО клиента'];
/** Ряд вкладок шагов консолидации (макет [30]): активна только «ОСВ». */ type Props = {
export function OsvTabs() { /** `unmapped_total` сводки: больше нуля — при уходе с «ОСВ» показывается модалка [31-00]. */
unmappedTotal: number;
/** Переход на вкладку шага. Пока шагов нет — вкладки неактивны, переход не задан (шахматка — PR #158). */
onNavigate?: (tab: string) => void;
/** Вкладки, по которым уже можно перейти (остальные — «Будет в следующих срезах»). */
enabledTabs?: readonly string[];
};
/** Ряд вкладок шагов консолидации (макет [30]): «ОСВ» — текущая; уход с неё при несопоставленных строках — через [31-00]. */
export function OsvTabs({ unmappedTotal, onNavigate, enabledTabs = [] }: Props) {
const [pending, setPending] = useState<string | null>(null);
const select = (tab: string) => {
if (tab === OSV || !onNavigate) return;
if (needsUnmappedWarning(unmappedTotal)) {
setPending(tab);
} else {
onNavigate(tab);
}
};
return ( return (
<Segmented <>
value='ОСВ' <Segmented
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto', overflowY: 'hidden' }} value={OSV}
options={[ onChange={(v) => select(String(v))}
{ value: 'ОСВ', label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>ОСВ</span> }, style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto', overflowY: 'hidden' }}
...TABS.map((t) => ({ options={[
value: t, { value: OSV, label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>{OSV}</span> },
disabled: true, ...TABS.map((t) => {
label: ( const enabled = enabledTabs.includes(t) && !!onNavigate;
<Tooltip title={SOON}> return {
<span>{t}</span> value: t,
</Tooltip> disabled: !enabled,
), label: enabled ? (
})), t
]} ) : (
/> <Tooltip title={SOON}>
<span>{t}</span>
</Tooltip>
),
};
}),
]}
/>
<UnmappedInnModal
open={pending !== null}
unmappedTotal={unmappedTotal}
onStay={() => setPending(null)}
onGo={() => {
const tab = pending;
setPending(null);
if (tab && onNavigate) onNavigate(tab);
}}
/>
</>
);
}
/** [31-00] «Остались неуказанные ИНН»: «Остаться» — без перехода, «Перейти» — переход. */
export function UnmappedInnModal({
open,
unmappedTotal,
onStay,
onGo,
}: {
open: boolean;
unmappedTotal: number;
onStay: () => void;
onGo: () => void;
}) {
return (
<Modal
open={open}
title='Остались неуказанные ИНН'
centered
onCancel={onStay}
footer={[
<Button key='stay' onClick={onStay}>
Остаться
</Button>,
<Button key='go' type='primary' onClick={onGo}>
Перейти
</Button>,
]}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{/* треугольник с «!», как в макете [31-00] */}
<WarningOutlined style={{ color: '#dc6803', fontSize: 20, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600 }}>В ОСВ остались неуказанные ИНН</div>
<div style={{ color: '#667085' }}>Не заполнено ИНН: {unmappedTotal}. Точно хотите перейти в другой раздел?</div>
</div>
</div>
</Modal>
); );
} }

View File

@ -1,77 +1,55 @@
import { Form, Input, message, Modal, Select } from 'antd'; import { Form, Input, Modal, Select, message } from 'antd';
import { useEffect, useState } from 'react'; import { 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 { ApiError } from '../../api/client';
import { VersionCard } from '../../types/consolidations/version_members'; import { TableTag } from '../../components/TableTag/TableTag';
import { useBeforeUnload } from '../../hooks/useBeforeUnload';
import { updateStatus } from '../../services/api/consolidations';
import type { VersionStatusUpdate } from '../../types/api';
import type { ConsStatus } from '../../types/consolidations/common_types';
import type { VersionCard } from '../../types/consolidations/version_members';
import { CONS_STATUS_LABEL, MAX_REASON_LENGTH, canSubmitStatusChange, manualTransitions } from '../../utils/consolidations';
import { statusColorMapping } from '../ConsoleTable/consts';
type StatusModalProps = { type StatusModalProps = {
open: boolean; open: boolean;
versionId: string; /** Карточка версии со страницы: из неё текущий статус и допустимые переходы. */
setClose: () => void; card: VersionCard;
onClose: () => void;
/** 200: карточка из ответа `POST …/status` — экран берёт её вместо повторного GET. */
onSuccess: (card: VersionCard) => 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() { type FormValues = { to_status?: ConsStatus; comment?: string };
const mapping: Record<ConsStatus, ConsStatus[]> = {
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<ConsStatus | null>(null);
const [nextStatusOptions, setNextStatusOptions] = useState<ConsStatus[]>([]);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm<VersionStatusUpdate>();
/** «Изменить статус» [33-01]: варианты — ручные переходы (`MANUAL_TRANSITIONS`), по умолчанию ничего не выбрано. */
export default function ChangeStatusModal({ open, card, onClose, onSuccess }: StatusModalProps) {
const [form] = Form.useForm<FormValues>();
const [submitting, setSubmitting] = useState(false);
// до монтирования формы useWatch отдаёт undefined — правила это учитывают
const nextStatus = Form.useWatch('to_status', form); const nextStatus = Form.useWatch('to_status', form);
const comment = Form.useWatch('comment', 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); const currentLabel = CONS_STATUS_LABEL[card.status];
} const options = manualTransitions(card.status).map((s) => ({ value: s, label: CONS_STATUS_LABEL[s] }));
getAllData(); const canSubmit = canSubmitStatusChange(nextStatus, comment);
}, [versionId]);
const russianStatus = currentStatus ? statusMapping[currentStatus] : ''; useBeforeUnload(open && (!!nextStatus || !!comment?.trim()));
const badgePreset = russianStatus ? statusColorMapping[russianStatus] : TAG_PRESETS.default;
const isSubmitDisabled = !nextStatus || !comment.trim();
const handleOk = async () => { const handleOk = async () => {
const values = await form.validateFields(); if (!nextStatus || !canSubmit || submitting) return;
const body: VersionStatusUpdate = { to_status: nextStatus, comment: (comment ?? '').trim() };
setSubmitting(true);
try { try {
const newCard = await updateStatus(versionId, { ...values, comment: values.comment.trim() }); const newCard = await updateStatus(card.id, body);
onSuccess(newCard); onSuccess(newCard);
setClose(); message.success(`Статус изменён: ${CONS_STATUS_LABEL[newCard.status]}`);
onClose();
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 409 && e.code) { // 409 VERSION_NOT_EDITABLE / LOCK_REQUIRED / LOCKED_BY_OTHER / TRANSITION_NOT_ALLOWED, 422 COMMENT_REQUIRED —
if (known409Codes.includes(e.code)) { // текст сервера; модалка открыта, статус на экране прежний
message.error(errorCodeMap[e.code]); message.error(e instanceof ApiError ? e.message : 'Не удалось изменить статус');
} } finally {
} else { setSubmitting(false);
message.error('Непредвиденная ошибка');
}
} }
}; };
@ -81,28 +59,23 @@ export default function ChangeStatusModal({ open, versionId, setClose, onSuccess
title='Изменить статус' title='Изменить статус'
okText='Изменить статус' okText='Изменить статус'
cancelText='Отмена' cancelText='Отмена'
okButtonProps={{ disabled: isSubmitDisabled || loading }} okButtonProps={{ disabled: !canSubmit }}
loading={loading} confirmLoading={submitting}
onOk={handleOk} onOk={handleOk}
onCancel={setClose} onCancel={() => !submitting && onClose()}
destroyOnHidden> destroyOnHidden>
<div style={{ display: 'flex' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
Текущий этап: <TableTag text={russianStatus} {...badgePreset} /> <span style={{ color: '#667085' }}>Текущий этап</span>
<TableTag text={currentLabel} {...statusColorMapping[currentLabel]} />
</div> </div>
<Form form={form} layout='vertical' requiredMark={false} preserve={false}>
{nextStatusOptions && ( <Form.Item name='to_status' label='Новый этап'>
<Form form={form} layout='vertical' requiredMark={false} initialValues={{ comment: '', to_status: nextStatusOptions[0] }}> <Select placeholder='Выберите этап' options={options} />
<Form.Item name='to_status' label='Новый этап'> </Form.Item>
<Select <Form.Item name='comment' label='Комментарий'>
options={nextStatusOptions.map((i) => ({ label: i, value: i }))} <Input.TextArea rows={3} maxLength={MAX_REASON_LENGTH} placeholder='Укажите комментарий к изменению статуса' />
labelRender={(v) => (v.value ? statusMapping[v.value as ConsStatus] : v.value)} </Form.Item>
optionRender={(v) => (v.value ? statusMapping[v.value as ConsStatus] : v.value)}></Select> </Form>
</Form.Item>
<Form.Item name='comment' rules={[{ required: true, message: 'Комментарий необходим' }]} label='Комментарий'>
<Input.TextArea maxLength={1000} placeholder='Укажите комментарий к изменению статуса'></Input.TextArea>
</Form.Item>
</Form>
)}
</Modal> </Modal>
); );
} }