Склонять «согласование» → «исполнение», когда согласующих в маршруте нет

Три общих (не привязанных к одной форме) места хардкодили лексику
согласования независимо от того, есть ли реально фаза согласования:

- initialSubmitStatus (shared/approval) — статус заявки при ОТПРАВКЕ
  считался «На согласовании» безусловно, даже когда маршрут состоит
  только из исполнителей (0 согласующих). Теперь статус зависит от
  количества шагов каждой фазы: 0 согласующих + есть исполнители →
  сразу «Выполнение»; ни тех, ни других → «Согласовано».
- ApprovalMatrix — подпись «Доп. согласующий, добавил:» у переназначенного
  участника была захардкожена; теперь выводится из roleLabel, поэтому у
  блока «Исполнение» (roleLabel="Исполнитель") корректно пишет «Доп.
  исполнитель».
- DecisionPanel — сообщения после переназначения («Согласующий добавлен
  перед вами…», «сейчас рассматривает…») не различали фазу шага; теперь
  склоняются по isExecution.

В ReferralForm — единственной форме, где маршрут реально может быть без
согласующих (только исполнитель) — использованы initialSubmitStatus и
локальный noApprovalPhase (matrix.rows.length === 0, подтверждено загрузкой)
для подписи кнопки отправки и текста успеха. Уведомление активного шага
теперь срабатывает и при статусе «Выполнение», не только «На согласовании».

Блок «Маршрут согласования» остаётся видимым и пустым, даже когда шагов
нет — матрица редактируема, заявитель может дописать согласующего вручную.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-21 17:00:05 +05:00
parent a0d2907cc0
commit 0cf8359a75
5 changed files with 73 additions and 19 deletions
@@ -23,7 +23,8 @@ import {
IApproverToWrite, IApproverToWrite,
unassignedStepMessage, unassignedStepMessage,
resolveWriteListId, resolveWriteListId,
APPROVAL_PHASE APPROVAL_PHASE,
initialSubmitStatus
} from '../../shared/approval'; } from '../../shared/approval';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
@@ -253,10 +254,11 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
if (onSaved) { if (onSaved) {
onSaved(); onSaved();
} }
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Отправка (на согласование ИЛИ, если согласующих нет, сразу на исполнение) —
// Имена списков — той же партиции, куда фактически записана заявка (writeListId), // уведомляем первый активный шаг (поток оповещения). Имена списков — той же
// не текущей: поток ищет строку маршрута именно в НЕЙ. // партиции, куда фактически записана заявка (writeListId), не текущей:
if (status === REQUEST_STATUS.onApproval) { // поток ищет строку маршрута именно в НЕЙ.
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
await notifyActiveStep(context, approval, id, { await notifyActiveStep(context, approval, id, {
requestTypeId: rtId, requestTypeId: rtId,
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
@@ -286,8 +288,21 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
return undefined; return undefined;
}; };
// Маршрута может не быть вообще (нет согласующих — только исполнитель, как у
// рефералки): initialSubmitStatus сразу пишет «Выполнение», минуя «На
// согласовании», которого в таком маршруте попросту не бывает.
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate); const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); const handleSubmit = (): Promise<void> =>
submit.run(
'submit',
save(initialSubmitStatus(matrix.rows.length, executors.rows.length)),
validateSubmit
);
// Согласующих в маршруте нет вообще (подтверждено — матрица загружена без
// ошибки и пуста), а не просто «ещё загружается»: до этого момента считаем,
// что фаза согласования есть — исторический дефолт, без мигания подписи.
const noApprovalPhase = !matrix.loading && !matrix.error && matrix.rows.length === 0;
const sectionProps = { data, set, context, onReferrerChange: handleReferrerChange }; const sectionProps = { data, set, context, onReferrerChange: handleReferrerChange };
@@ -318,7 +333,11 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
> >
{submit.savedKind && !submit.error && ( {submit.savedKind && !submit.error && (
<Alert variant="ok"> <Alert variant="ok">
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} {submit.savedKind === 'draft'
? 'Черновик сохранён.'
: noApprovalPhase
? 'Заявка отправлена на исполнение.'
: 'Заявка отправлена на согласование.'}
</Alert> </Alert>
)} )}
{submit.error && <Alert variant="err">{submit.error}</Alert>} {submit.error && <Alert variant="err">{submit.error}</Alert>}
@@ -331,13 +350,16 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
<AssessmentSection {...sectionProps} /> <AssessmentSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
{/* Блок остаётся видимым и пустым (не скрываем по noApprovalPhase) —
маршрут без согласующих не значит, что их нельзя добавить: матрица
редактируема, заявитель вправе вписать согласующего сам. */}
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} /> <ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
{!executors.loading && executors.rows.length > 0 && ( {!executors.loading && executors.rows.length > 0 && (
<ApprovalMatrix <ApprovalMatrix
editor={executors} editor={executors}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
title="Исполнение (после согласования)" title={noApprovalPhase ? 'Исполнение' : 'Исполнение (после согласования)'}
roleLabel="Исполнитель" roleLabel="Исполнитель"
emptyText="Исполнители не заданы по маршруту." emptyText="Исполнители не заданы по маршруту."
/> />
@@ -350,7 +372,7 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
onCancel={onCancel} onCancel={onCancel}
onSaveDraft={handleSaveDraft} onSaveDraft={handleSaveDraft}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitLabel="Отправить на согласование" submitLabel={noApprovalPhase ? 'Отправить на исполнение' : 'Отправить на согласование'}
/> />
</FormCard> </FormCard>
</div> </div>
@@ -7,6 +7,7 @@ export {
APPROVAL_PHASE_CHOICES, APPROVAL_PHASE_CHOICES,
EDITABLE_STATUSES, EDITABLE_STATUSES,
isEditableStatus, isEditableStatus,
initialSubmitStatus,
} from './models/statuses'; } from './models/statuses';
export type { ISiteUser } from './models/ISiteUser'; export type { ISiteUser } from './models/ISiteUser';
export type { IMatrixRow } from './models/IMatrixRow'; export type { IMatrixRow } from './models/IMatrixRow';
@@ -43,6 +43,24 @@ export function isEditableStatus(status: string | undefined): boolean {
return !status || EDITABLE_STATUSES.indexOf(status) !== -1; return !status || EDITABLE_STATUSES.indexOf(status) !== -1;
} }
/**
* Статус ЗАЯВКИ в момент ОТПРАВКИ — до записи строк маршрута (см. writeRoute),
* когда реальных строк ещё нет и решать по ним, как computeRequestStatus в
* approverDecision.service.ts, нельзя. Формы раньше писали «На согласовании»
* безусловно; это неверно для маршрута без согласующих (только исполнение,
* см. форму «Реферальная рекомендация») — там заявка должна сразу войти в
* фазу исполнения, минуя несуществующую фазу согласования.
*
* Порядок фаз — тот же, что у computeRequestStatus (согласование → исполнение
* → закрыта), только по КОЛИЧЕСТВУ шагов, а не по фактическим статусам строк
* (при отправке все строки ещё «Ждёт очереди»/не существуют).
*/
export function initialSubmitStatus(approversCount: number, executorsCount: number): string {
if (approversCount > 0) return REQUEST_STATUS.onApproval;
if (executorsCount > 0) return REQUEST_STATUS.execution;
return REQUEST_STATUS.approved;
}
// Наборов значений ДВА, и они не совпадают — это разные сущности: // Наборов значений ДВА, и они не совпадают — это разные сущности:
// //
// 1) статус ЗАЯВКИ — колонка RequestStatus в списке «… — данные». Пишется // 1) статус ЗАЯВКИ — колонка RequestStatus в списке «… — данные». Пишется
@@ -42,6 +42,11 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
}) => { }) => {
const { rows, loading, error, editable, blocks } = editor; const { rows, loading, error, editable, blocks } = editor;
// Подпись «доп. согласующий»/«доп. исполнитель» — из roleLabel, а не
// хардкод: иначе блок «Исполнение» (roleLabel="Исполнитель") показывал бы
// добавленному через переназначение исполнителю подпись «Доп. согласующий».
const addedByLabel = `Доп. ${roleLabel.charAt(0).toLowerCase()}${roleLabel.slice(1)}, добавил:`;
// Ключи строк и анимация их перестановки. Ключи обязаны быть устойчивыми // Ключи строк и анимация их перестановки. Ключи обязаны быть устойчивыми
// (не индекс) — иначе React переписывает строки на месте, и «переезжать» // (не индекс) — иначе React переписывает строки на месте, и «переезжать»
// при перестановке шага будет нечему (см. matrixRowKeys / useFlipRows). // при перестановке шага будет нечему (см. matrixRowKeys / useFlipRows).
@@ -155,13 +160,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
{!r.who && r.warning && ( {!r.who && r.warning && (
<span className={styles.warningInline}>{r.warning}</span> <span className={styles.warningInline}>{r.warning}</span>
)} )}
{/* Доп. согласующий, добавленный другим согласующим (с правом {/* Доп. участник шага, добавленный переназначением — помечаем,
переназначения) — помечаем, кто его добавил. Пусто у обычных кто его добавил. Пусто у обычных строк маршрута. Как и
строк маршрута. Как и комментарий — inline под именем, чтобы комментарий — inline под именем, чтобы не ломать rowSpan
не ломать rowSpan колонки «Шаг» у параллельных шагов. */} колонки «Шаг» у параллельных шагов. */}
{r.addedBy && ( {r.addedBy && (
<span className={styles.addedByInline}> <span className={styles.addedByInline}>
<span className={styles.addedByLabel}>Доп. согласующий, добавил:</span>{' '} <span className={styles.addedByLabel}>{addedByLabel}</span>{' '}
{r.addedBy} {r.addedBy}
</span> </span>
)} )}
@@ -142,12 +142,18 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
} }
if (delegated > 0) { if (delegated > 0) {
// Склоняем по фазе шага: у исполнения нет «согласующих» — тот, кого
// добавили, реально выполняет заявку, а не рассматривает её.
const who = isExecution ? 'Исполнитель' : 'Согласующий';
const whoMany = isExecution ? 'Исполнителей' : 'Согласующих';
const verb = isExecution ? 'выполнит' : 'рассмотрит';
const verbMany = isExecution ? 'выполнят' : 'рассмотрят';
return ( return (
<div className={styles.panel}> <div className={styles.panel}>
<Alert variant="ok"> <Alert variant="ok">
{delegated === 1 {delegated === 1
? 'Согласующий добавлен перед вами — он рассмотрит заявку раньше вас. Ваш шаг остаётся в маршруте.' ? `${who} добавлен перед вами — он ${verb} заявку раньше вас. Ваш шаг остаётся в маршруте.`
: `Согласующих добавлено: ${delegated}. Они рассмотрят заявку раньше вас, в порядке добавления. ` + : `${whoMany} добавлено: ${delegated}. Они ${verbMany} заявку раньше вас, в порядке добавления. ` +
'Ваш шаг остаётся в маршруте.'} 'Ваш шаг остаётся в маршруте.'}
</Alert> </Alert>
</div> </div>
@@ -234,9 +240,11 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
<div className={styles.panel}> <div className={styles.panel}>
<div className={styles.title}>{isExecution ? 'Исполнение' : 'Ваше решение'}</div> <div className={styles.title}>{isExecution ? 'Исполнение' : 'Ваше решение'}</div>
<Alert variant="warn"> <Alert variant="warn">
Заявку сейчас рассматривает сотрудник, которого вы добавили перед собой. Ваш шаг никуда {isExecution
не делся — он вернётся к вам, когда очередь дойдёт. Если добавленный сотрудник ? 'Заявку сейчас выполняет сотрудник, которого вы добавили перед собой.'
недоступен, можно добавить ещё. : 'Заявку сейчас рассматривает сотрудник, которого вы добавили перед собой.'}{' '}
Ваш шаг никуда не делся — он вернётся к вам, когда очередь дойдёт. Если добавленный
сотрудник недоступен, можно добавить ещё.
</Alert> </Alert>
{error && <Alert variant="err">{error}</Alert>} {error && <Alert variant="err">{error}</Alert>}
{delegateBlock} {delegateBlock}