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:
parent
54de39727b
commit
ccca6dfa13
@ -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
|
||||||
|
size='middle'
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
type='default'
|
||||||
|
disabled={!statusChangeAllowed}
|
||||||
|
onClick={() => setModalOpen(true)}>
|
||||||
Изменить статус
|
Изменить статус
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size='middle'
|
|
||||||
onClick={() => {
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
style={{ fontSize: 12 }}
|
|
||||||
type='default'>
|
|
||||||
Изменить статус
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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
|
<Segmented
|
||||||
value='ОСВ'
|
value={OSV}
|
||||||
|
onChange={(v) => select(String(v))}
|
||||||
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto', overflowY: 'hidden' }}
|
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto', overflowY: 'hidden' }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'ОСВ', label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>ОСВ</span> },
|
{ value: OSV, label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>{OSV}</span> },
|
||||||
...TABS.map((t) => ({
|
...TABS.map((t) => {
|
||||||
|
const enabled = enabledTabs.includes(t) && !!onNavigate;
|
||||||
|
return {
|
||||||
value: t,
|
value: t,
|
||||||
disabled: true,
|
disabled: !enabled,
|
||||||
label: (
|
label: enabled ? (
|
||||||
|
t
|
||||||
|
) : (
|
||||||
<Tooltip title={SOON}>
|
<Tooltip title={SOON}>
|
||||||
<span>{t}</span>
|
<span>{t}</span>
|
||||||
</Tooltip>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 form={form} layout='vertical' requiredMark={false} initialValues={{ comment: '', to_status: nextStatusOptions[0] }}>
|
|
||||||
<Form.Item name='to_status' label='Новый этап'>
|
<Form.Item name='to_status' label='Новый этап'>
|
||||||
<Select
|
<Select placeholder='Выберите этап' options={options} />
|
||||||
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>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name='comment' rules={[{ required: true, message: 'Комментарий необходим' }]} label='Комментарий'>
|
<Form.Item name='comment' label='Комментарий'>
|
||||||
<Input.TextArea maxLength={1000} placeholder='Укажите комментарий к изменению статуса'></Input.TextArea>
|
<Input.TextArea rows={3} maxLength={MAX_REASON_LENGTH} placeholder='Укажите комментарий к изменению статуса' />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user