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 { 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<VersionCard | null>(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) ? (
<Tooltip title='Сначала возьмите версию в работу'>
<Button size='middle' style={{ fontSize: 12 }} type='default' disabled>
{/* активна при своей блокировке и наличии ручных переходов из текущего статуса */}
<Tooltip title={ownLock ? undefined : 'Возьмите консолидацию в работу'}>
<Button
size='middle'
style={{ fontSize: 12 }}
type='default'
disabled={!statusChangeAllowed}
onClick={() => setModalOpen(true)}>
Изменить статус
</Button>
</Tooltip>
) : (
<Button
size='middle'
onClick={() => {
setModalOpen(true);
}}
style={{ fontSize: 12 }}
type='default'>
Изменить статус
</Button>
)}
</Space>
)
}
/>
<OsvTabs />
<OsvTabs unmappedTotal={unmapped} />
<main
style={{
flex: '1 1 auto',
@ -134,7 +132,7 @@ function OsvContent({ versionId }: { versionId: string }) {
<div style={{ fontSize: 10, color: '#667085' }}>Знаков после запятой</div>
<Select
variant='underlined'
options={[0, 1, 2, 3].map((i) => ({ label: i, value: i }))}
options={PRECISIONS.map((i) => ({ label: i, value: i }))}
value={precision}
onChange={(v) => setPrecision(v ?? 2)}
style={{ width: 90 }}
@ -196,14 +194,7 @@ function OsvContent({ versionId }: { versionId: string }) {
onMapped={osv.refresh}
/>
)}
<ChangeStatusModal
open={statusModalOpen}
versionId={versionId}
onSuccess={setCard}
setClose={() => {
setModalOpen(false);
}}
/>
{card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}
</div>
);
}

View File

@ -4,20 +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';
/** Список консолидаций ГК; `in_work_only` — серверный фильтр «Только в работе» (по умолчанию false). */
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 createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body);
export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`);
export const getChecklist = (versionId: string) => api.get<ChecklistBackendShape>(`/consolidations/versions/${versionId}/checklist`);
/** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */
export const lockVersion = (versionId: string, reason?: string | null) => {
const body = {};
if (reason) {
body['takeover_reason'] = reason;
}
return api.post<LockOut>(`/consolidations/versions/${versionId}/lock`, body);
};
export const lockVersion = (versionId: string, reason?: string | null) =>
api.post<LockOut>(`/consolidations/versions/${versionId}/lock`, reason ? { takeover_reason: reason } : {});
/** Ручная смена статуса (гайд §5.7): нужна своя блокировка; ответ — карточка версии с новым статусом. */
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 и даты — строки.
import { ConsStatus } from './consolidations/common_types';
import type { ConsStatus } from './consolidations/common_types';
export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4';
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 OSV = 'ОСВ';
const TABS = ['Шахматка', 'Таблица корректировки', 'Гудвилл', 'КФО', 'КФО Общий', 'Подтверждение КФО клиента'];
/** Ряд вкладок шагов консолидации (макет [30]): активна только «ОСВ». */
export function OsvTabs() {
type Props = {
/** `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 (
<>
<Segmented
value='ОСВ'
value={OSV}
onChange={(v) => select(String(v))}
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto', overflowY: 'hidden' }}
options={[
{ value: 'ОСВ', label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>ОСВ</span> },
...TABS.map((t) => ({
{ value: OSV, label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>{OSV}</span> },
...TABS.map((t) => {
const enabled = enabledTabs.includes(t) && !!onNavigate;
return {
value: t,
disabled: true,
label: (
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 { 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 { Form, Input, Modal, Select, message } from 'antd';
import { useState } from 'react';
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 = {
open: boolean;
versionId: string;
setClose: () => void;
/** Карточка версии со страницы: из неё текущий статус и допустимые переходы. */
card: VersionCard;
onClose: () => void;
/** 200: карточка из ответа `POST …/status` — экран берёт её вместо повторного GET. */
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<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>();
type FormValues = { to_status?: ConsStatus; comment?: string };
/** «Изменить статус» [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 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 currentLabel = CONS_STATUS_LABEL[card.status];
const options = manualTransitions(card.status).map((s) => ({ value: s, label: CONS_STATUS_LABEL[s] }));
const canSubmit = canSubmitStatusChange(nextStatus, comment);
useBeforeUnload(open && (!!nextStatus || !!comment?.trim()));
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 {
const newCard = await updateStatus(versionId, { ...values, comment: values.comment.trim() });
const newCard = await updateStatus(card.id, body);
onSuccess(newCard);
setClose();
message.success(`Статус изменён: ${CONS_STATUS_LABEL[newCard.status]}`);
onClose();
} catch (e) {
if (e instanceof ApiError && e.status === 409 && e.code) {
if (known409Codes.includes(e.code)) {
message.error(errorCodeMap[e.code]);
}
} else {
message.error('Непредвиденная ошибка');
}
// 409 VERSION_NOT_EDITABLE / LOCK_REQUIRED / LOCKED_BY_OTHER / TRANSITION_NOT_ALLOWED, 422 COMMENT_REQUIRED —
// текст сервера; модалка открыта, статус на экране прежний
message.error(e instanceof ApiError ? e.message : 'Не удалось изменить статус');
} finally {
setSubmitting(false);
}
};
@ -81,28 +59,23 @@ export default function ChangeStatusModal({ open, versionId, setClose, onSuccess
title='Изменить статус'
okText='Изменить статус'
cancelText='Отмена'
okButtonProps={{ disabled: isSubmitDisabled || loading }}
loading={loading}
okButtonProps={{ disabled: !canSubmit }}
confirmLoading={submitting}
onOk={handleOk}
onCancel={setClose}
onCancel={() => !submitting && onClose()}
destroyOnHidden>
<div style={{ display: 'flex' }}>
Текущий этап: <TableTag text={russianStatus} {...badgePreset} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<span style={{ color: '#667085' }}>Текущий этап</span>
<TableTag text={currentLabel} {...statusColorMapping[currentLabel]} />
</div>
{nextStatusOptions && (
<Form form={form} layout='vertical' requiredMark={false} initialValues={{ comment: '', to_status: nextStatusOptions[0] }}>
<Form form={form} layout='vertical' requiredMark={false} preserve={false}>
<Form.Item name='to_status' label='Новый этап'>
<Select
options={nextStatusOptions.map((i) => ({ 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)}></Select>
<Select placeholder='Выберите этап' options={options} />
</Form.Item>
<Form.Item name='comment' rules={[{ required: true, message: 'Комментарий необходим' }]} label='Комментарий'>
<Input.TextArea maxLength={1000} placeholder='Укажите комментарий к изменению статуса'></Input.TextArea>
<Form.Item name='comment' label='Комментарий'>
<Input.TextArea rows={3} maxLength={MAX_REASON_LENGTH} placeholder='Укажите комментарий к изменению статуса' />
</Form.Item>
</Form>
)}
</Modal>
);
}