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:
@@ -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). Так маршрут читается на телефоне без горизонтального скролла.
|
||||
|
||||
Reference in New Issue
Block a user