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>
@@ -208,6 +208,19 @@ export class ApprovalListService {
.update({ [FIELDS.status]: status, [FIELDS.date]: new Date().toISOString() });
}
// Замена человека в строке маршрута (см. ApproverService.replaceExecutionStep):
// меняются только «Согласующий» и должность (Title). Статус, порядок, фаза и
// дата активации остаются — шаг тот же, другой только исполнитель.
public async replaceApprover(rowId: number, approver: { email: string; position: string }): Promise<void> {
const approverId = await this.resolveUserId(approver.email);
await this.list()
.items.getById(rowId)
.update({
Title: approver.position || approver.email,
[`${FIELDS.approver}Id`]: approverId,
});
}
// Добавляет одну строку согласования с заданным Order (для делегата).
// delegatedById — Id пользователя, который выполнил переназначение (не строки
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
@@ -140,6 +140,77 @@ export class ApproverService {
emitRequestsChanged();
}
/**
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
* не трогаем (иначе можно «переоткрыть» закрытую заявку). Бросает с текстом
* для пользователя, если шаг не подходит.
*/
private editableExecutionRow(rows: IApprovalRow[], rowId: number): IApprovalRow {
const row = rows.filter((r) => r.id === rowId)[0];
if (!row || row.phase !== APPROVAL_PHASE.execution) {
throw new Error('Шаг не найден среди исполнителей — обновите заявку.');
}
if (row.status !== REQUEST_STATUS.queued && row.status !== REQUEST_STATUS.onExecution) {
throw new Error('Шаг уже выполнен — заменить или удалить его нельзя.');
}
return row;
}
/**
* Заменить исполнителя на шаге фазы «Исполнение» — в т.ч. активном. Шаг
* остаётся тем же (порядок, статус), меняется только человек. Подключено
* только в «Заявке на подбор» (см. RecruitingSelectionView), право — по
* routeEditRights.ts.
*
* Возвращает, был ли шаг активным: тогда заявка сразу у нового исполнителя,
* и ему нужно отправить оповещение (это делает вызывающая сторона).
*/
public async replaceExecutionStep(requestId: number, rowId: number, user: ISiteUser): Promise<boolean> {
const rows = await this.approval.getForRequest(requestId);
const row = this.editableExecutionRow(rows, rowId);
const position = await this.approval.getJobTitle(user.loginName).catch(() => '');
await this.approval.replaceApprover(rowId, { email: user.email, position });
emitRequestsChanged();
return row.status === REQUEST_STATUS.onExecution;
}
/**
* Удалить исполнителя с шага фазы «Исполнение» — в т.ч. активного. Если
* удалён активный, очередь переходит к следующему исполнителю.
*
* Последнего невыполненного исполнителя удалить нельзя — только заменить: без
* него все оставшиеся шаги окажутся выполненными (или их не останется вовсе),
* и computeRequestStatus закрыл бы заявку, хотя её никто не исполнил.
*
* Возвращает Id строк, ставших активными после удаления, — им нужно
* оповещение (отправляет вызывающая сторона).
*/
public async deleteExecutionStep(requestId: number, rowId: number): Promise<number[]> {
const rows = await this.approval.getForRequest(requestId);
this.editableExecutionRow(rows, rowId);
const othersPending = rows.filter(
(r) =>
r.id !== rowId &&
r.phase === APPROVAL_PHASE.execution &&
(r.status === REQUEST_STATUS.queued || r.status === REQUEST_STATUS.onExecution)
);
if (othersPending.length === 0) {
throw new Error('Это последний исполнитель заявки — его можно только заменить.');
}
const activeBefore = new Set(rows.filter((r) => r.status === REQUEST_STATUS.onExecution).map((r) => r.id));
await this.approval.deleteRow(rowId);
await this.approval.activateNextQueued(requestId);
const rowsAfter = await this.approval.getForRequest(requestId);
emitRequestsChanged();
return rowsAfter
.filter((r) => r.status === REQUEST_STATUS.onExecution && !activeBefore.has(r.id))
.map((r) => r.id);
}
// Применяет решение: обновляет строку, продвигает очередь (на следующего
// согласующего внутри фазы либо на первого исполнителя, если фаза согласования
// только что завершилась целиком) и пересчитывает статус заявки.
@@ -58,16 +58,23 @@ async function isNotifyAllowed(approval: ApprovalListService, approverId: number
// Вызывать после отправки заявки и после каждого положительного решения. Поток по
// переданным полям сам находит согласующего/исполнителя и шлёт уведомление.
// Уведомление некритично — ошибки глотаем, чтобы не ронять основной сценарий.
//
// rowId — уведомить именно эту активную строку, а не первую попавшуюся: при
// замене/удалении исполнителя на параллельном шаге активных строк несколько, и
// письмо должно уйти новому человеку, а не тому, кто уже его получил.
export async function notifyActiveStep(
context: WebPartContext,
approval: ApprovalListService,
requestId: number,
target: INotifyTarget
target: INotifyTarget,
rowId?: number
): Promise<void> {
try {
const rows = await approval.getForRequest(requestId);
const active = rows.filter(
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
(r) =>
(r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution) &&
(rowId === undefined || r.id === rowId)
)[0];
if (!active) {
return;
@@ -6,6 +6,7 @@ import { ISiteUser } from '../models/ISiteUser';
import { IMatrixRow } from '../models/IMatrixRow';
import { StatusBadge } from './StatusBadge';
import { UserPicker } from './UserPicker';
import { ReplaceRowPanel } from './ReplaceRowPanel';
import { useFlipRows } from './useFlipRows';
export interface IApprovalMatrixProps {
@@ -44,6 +45,15 @@ export interface IApprovalMatrixProps {
* сервер (см. RecruitingHireView.tsx и ApproverService.deleteQueuedStep).
*/
onRemoveRow?: (row: IMatrixRow, index: number) => void;
/**
* Может ли ТЕКУЩИЙ пользователь заменить человека в ЭТОЙ строке — как и
* canRemoveRow, независимо от editable. Не задан — кнопки «Заменить» нет
* (так у всех форм, кроме тех, что подключили замену сами, напр.
* RecruitingSelectionView → ApproverService.replaceExecutionStep).
*/
canReplaceRow?: (row: IMatrixRow, index: number) => boolean;
/** Выбран новый человек для строки (задаётся вместе с canReplaceRow). */
onReplaceRow?: (row: IMatrixRow, user: ISiteUser) => void;
}
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
@@ -58,8 +68,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
waitingText = '',
canRemoveRow,
onRemoveRow,
canReplaceRow,
onReplaceRow,
}) => {
const { rows, loading, error, editable, blocks, retry } = editor;
// Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт.
const [replacingKey, setReplacingKey] = React.useState<string | undefined>(undefined);
// Подпись «доп. согласующий»/«доп. исполнитель» — из roleLabel, а не
// хардкод: иначе блок «Исполнение» (roleLabel="Исполнитель") показывал бы
@@ -129,9 +143,18 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
// в useMatrixEditor (см. resolveApproverOrgInfo); остальные формы их не видят.
const hasDepartment = rows.some((r) => !!r.department);
const hasOrganization = rows.some((r) => !!r.organization);
// Колонка действий с кнопкой удаления нужна даже вне editable, если право
// удалять есть хотя бы у одной строки (см. canRemoveRow/routeEditRights.ts).
const hasRemovable = rows.some((r, i) => !!canRemoveRow?.(r, i));
// Колонка действий с кнопками удаления/замены нужна даже вне editable, если
// такое право есть хотя бы у одной строки (см. canRemoveRow/routeEditRights.ts).
const hasRemovable = rows.some((r, i) => !!canRemoveRow?.(r, i) || !!canReplaceRow?.(r, i));
// Строка, которую заменяем, — по ключу, а не по индексу: пока открыт выбор,
// матрица может перечитаться, и индекс указал бы уже на другого человека.
const replacingIndex = replacingKey === undefined ? -1 : rowKeys.indexOf(replacingKey);
const replacingRow = replacingIndex >= 0 ? rows[replacingIndex] : undefined;
const pickReplacement = (user: ISiteUser): void => {
setReplacingKey(undefined);
if (replacingRow && onReplaceRow) onReplaceRow(replacingRow, user);
};
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
@@ -273,6 +296,16 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<Icon name="user-check" size="sm" />
</button>
)}
{canReplaceRow?.(r, i) && (
<button
type="button"
className={`${styles.iconBtn} ${replacingKey === rowKeys[i] ? styles.iconBtnActive : ''}`}
onClick={() => setReplacingKey(replacingKey === rowKeys[i] ? undefined : rowKeys[i])}
title="Заменить"
>
<Icon name="swap" size="sm" />
</button>
)}
{canRemoveRow?.(r, i) && (
<button
type="button"
@@ -292,6 +325,15 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
</table>
</div>
)}
{replacingRow && !waiting && !loading && (
<ReplaceRowPanel
currentName={replacingRow.who}
roleLabel={roleLabel}
search={search}
onPick={pickReplacement}
onCancel={() => setReplacingKey(undefined)}
/>
)}
{editable && !waiting && !loading && (
<div className={styles.matrixPicker}>
<UserPicker search={search} onPick={addApprover} placeholder={pickerPlaceholder} />
@@ -0,0 +1,28 @@
import * as React from 'react';
import styles from './approval.module.scss';
import { ISiteUser } from '../models/ISiteUser';
import { UserPicker } from './UserPicker';
interface Props {
/** Кого заменяем — ФИО из строки маршрута. */
currentName: string;
roleLabel: string;
search: (query: string) => Promise<ISiteUser[]>;
onPick: (user: ISiteUser) => void;
onCancel: () => void;
}
// Выбор нового человека для строки маршрута (кнопка «Заменить» в ApprovalMatrix).
// Отдельной панелью под таблицей, а не внутри ячейки: выпадающий список пикера
// в узкой ячейке не помещается, а на телефоне таблица и так идёт карточками.
export const ReplaceRowPanel: React.FC<Props> = ({ currentName, roleLabel, search, onPick, onCancel }) => (
<div className={styles.replacePanel}>
<div className={styles.replaceTitle}>
{roleLabel}: заменить «{currentName || 'не назначен'}» на
<button type="button" className={styles.retryBtn} onClick={onCancel}>
Отмена
</button>
</div>
<UserPicker search={search} onPick={onPick} placeholder="Начните вводить ФИО…" />
</div>
);
@@ -316,6 +316,25 @@
margin-top: $sp-3;
}
// Замена человека в строке маршрута (ReplaceRowPanel) — под таблицей.
.replacePanel {
margin-top: $sp-3;
padding: $sp-3;
border: 1px solid $gold-300;
border-radius: $r-sm;
background: $gold-50;
}
.replaceTitle {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: $sp-3;
margin-bottom: $sp-2;
font-size: $fs-sm;
color: $gray-700;
}
// ---- Мобильные экраны: таблица маршрута → карточки ----
// Скрываем шапку, каждая строка — блок, ячейки в столбик с подписью поля
// (из data-label). Так маршрут читается на телефоне без горизонтального скролла.