feat: замена и удаление исполнителей в заявке на подбор

Начальник отдела подбора (routeEditRights) назначает исполнителей, но
сама в исполнении не стоит. В матрице «Исполнение» заявки на подбор у
шагов «Ждёт очереди» и «На исполнении» появились кнопки «Заменить» и
«Удалить»:
- замена меняет только человека в шаге, активный шаг сразу переходит
  к новому исполнителю, ему уходит оповещение;
- при удалении активного очередь переходит к следующему исполнителю;
- выполненные шаги не трогаются, последнего невыполненного исполнителя
  можно только заменить — иначе заявка закрылась бы без исполнения.

Остальные формы не затронуты: кнопки включаются пропсами ApprovalMatrix
(canReplaceRow/onReplaceRow) только в RecruitingSelectionView.
notifyActiveStep получил необязательный rowId, чтобы на параллельном
шаге письмо ушло именно новому исполнителю.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-28 16:29:15 +05:00
parent be8177e8ae
commit 247f614885
7 changed files with 242 additions and 14 deletions
@@ -31,6 +31,7 @@ import {
canEditAllFields,
REQUEST_STATUS,
IMatrixRow,
ISiteUser,
APPROVAL_PHASE,
ApproverDecision
} from '../../shared/approval';
@@ -192,6 +193,22 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
await saveAllFields();
};
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
const notify = (rowId?: number): void => {
if (!requestTypeId) return;
notifyActiveStep(
context,
approval,
itemId,
{
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
},
rowId
).catch(() => undefined);
};
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
const handleRemoveApprover = (row: IMatrixRow): void => {
if (!row.id) return;
@@ -202,6 +219,40 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
.catch((e) => setRouteError(friendlyErrorMessage(e)));
};
// Исполнителей тот же человек (routeEditRights.ts) заменяет и удаляет даже на
// АКТИВНОМ шаге — только в этой форме. Выполненный шаг не трогаем, а
// последнего невыполненного исполнителя можно только заменить (см.
// ApproverService.deleteExecutionStep).
const isPendingExecutor = (row: IMatrixRow): boolean =>
row.status === REQUEST_STATUS.queued || row.status === REQUEST_STATUS.onExecution;
const pendingExecutorsCount = executors.rows.filter(isPendingExecutor).length;
const handleReplaceExecutor = (row: IMatrixRow, user: ISiteUser): void => {
if (!row.id) return;
const rowId = row.id;
setRouteError(undefined);
approverService
.replaceExecutionStep(itemId, rowId, user)
.then((wasActive) => {
setDecisionSeq((n) => n + 1);
// Шаг уже был в работе — заявка сразу у нового исполнителя, сообщаем ему.
if (wasActive) notify(rowId);
})
.catch((e) => setRouteError(friendlyErrorMessage(e)));
};
const handleRemoveExecutor = (row: IMatrixRow): void => {
if (!row.id) return;
setRouteError(undefined);
approverService
.deleteExecutionStep(itemId, row.id)
.then((activatedRowIds) => {
setDecisionSeq((n) => n + 1);
activatedRowIds.forEach((id) => notify(id));
})
.catch((e) => setRouteError(friendlyErrorMessage(e)));
};
useEffect(() => {
let alive = true;
setLoading(true);
@@ -243,15 +294,6 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
const sectionProps = { data, set, context };
const notify = (): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
}).catch(() => undefined);
};
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
@@ -327,6 +369,12 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
canReplaceRow={canRemoveApprover ? isPendingExecutor : undefined}
onReplaceRow={handleReplaceExecutor}
canRemoveRow={
canRemoveApprover ? (row) => isPendingExecutor(row) && pendingExecutorsCount > 1 : undefined
}
onRemoveRow={handleRemoveExecutor}
/>
)}
</div>