From 247f614885fc5ed94525c929737a9715b3c35296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Mon, 28 Sep 2026 16:29:15 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=B2=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Начальник отдела подбора (routeEditRights) назначает исполнителей, но сама в исполнении не стоит. В матрице «Исполнение» заявки на подбор у шагов «Ждёт очереди» и «На исполнении» появились кнопки «Заменить» и «Удалить»: - замена меняет только человека в шаге, активный шаг сразу переходит к новому исполнителю, ему уходит оповещение; - при удалении активного очередь переходит к следующему исполнителю; - выполненные шаги не трогаются, последнего невыполненного исполнителя можно только заменить — иначе заявка закрылась бы без исполнения. Остальные формы не затронуты: кнопки включаются пропсами ApprovalMatrix (canReplaceRow/onReplaceRow) только в RecruitingSelectionView. notifyActiveStep получил необязательный rowId, чтобы на параллельном шаге письмо ушло именно новому исполнителю. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../RecruitingSelectionView.tsx | 66 ++++++++++++++--- .../shared/approval/approvalList.service.ts | 13 ++++ .../approval/approverDecision.service.ts | 71 +++++++++++++++++++ .../shared/approval/notify.service.ts | 11 ++- .../shared/approval/ui/ApprovalMatrix.tsx | 48 ++++++++++++- .../shared/approval/ui/ReplaceRowPanel.tsx | 28 ++++++++ .../shared/approval/ui/approval.module.scss | 19 +++++ 7 files changed, 242 insertions(+), 14 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/ui/ReplaceRowPanel.tsx diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index 3bb1d21..05107a4 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -31,6 +31,7 @@ import { canEditAllFields, REQUEST_STATUS, IMatrixRow, + ISiteUser, APPROVAL_PHASE, ApproverDecision } from '../../shared/approval'; @@ -192,6 +193,22 @@ const RecruitingSelectionView: React.FC = ({ 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 = ({ 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 = ({ 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 (
@@ -327,6 +369,12 @@ const RecruitingSelectionView: React.FC = ({ cont title="Исполнение" roleLabel="Исполнитель" emptyText="Исполнители не назначены." + canReplaceRow={canRemoveApprover ? isPendingExecutor : undefined} + onReplaceRow={handleReplaceExecutor} + canRemoveRow={ + canRemoveApprover ? (row) => isPendingExecutor(row) && pendingExecutorsCount > 1 : undefined + } + onRemoveRow={handleRemoveExecutor} /> )}
diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index fff64ef..537d236 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -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 { + 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 пользователя, который выполнил переназначение (не строки // из маршрута — у них делегата нет). Он сохраняет право переназначить эту diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index 25a987a..edf5210 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -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 { + 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 { + 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); + } + // Применяет решение: обновляет строку, продвигает очередь (на следующего // согласующего внутри фазы либо на первого исполнителя, если фаза согласования // только что завершилась целиком) и пересчитывает статус заявки. diff --git a/src/webparts/requestPortal/shared/approval/notify.service.ts b/src/webparts/requestPortal/shared/approval/notify.service.ts index 712ce6b..401a99c 100644 --- a/src/webparts/requestPortal/shared/approval/notify.service.ts +++ b/src/webparts/requestPortal/shared/approval/notify.service.ts @@ -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 { 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; diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index d817098..6d59d08 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -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 = ({ waitingText = '', canRemoveRow, onRemoveRow, + canReplaceRow, + onReplaceRow, }) => { const { rows, loading, error, editable, blocks, retry } = editor; + // Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт. + const [replacingKey, setReplacingKey] = React.useState(undefined); // Подпись «доп. согласующий»/«доп. исполнитель» — из roleLabel, а не // хардкод: иначе блок «Исполнение» (roleLabel="Исполнитель") показывал бы @@ -129,9 +143,18 @@ export const ApprovalMatrix: React.FC = ({ // в 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 = ({ )} + {canReplaceRow?.(r, i) && ( + + )} {canRemoveRow?.(r, i) && (
)} + {replacingRow && !waiting && !loading && ( + setReplacingKey(undefined)} + /> + )} {editable && !waiting && !loading && (
diff --git a/src/webparts/requestPortal/shared/approval/ui/ReplaceRowPanel.tsx b/src/webparts/requestPortal/shared/approval/ui/ReplaceRowPanel.tsx new file mode 100644 index 0000000..2601fe9 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/ReplaceRowPanel.tsx @@ -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; + onPick: (user: ISiteUser) => void; + onCancel: () => void; +} + +// Выбор нового человека для строки маршрута (кнопка «Заменить» в ApprovalMatrix). +// Отдельной панелью под таблицей, а не внутри ячейки: выпадающий список пикера +// в узкой ячейке не помещается, а на телефоне таблица и так идёт карточками. +export const ReplaceRowPanel: React.FC = ({ currentName, roleLabel, search, onPick, onCancel }) => ( +
+
+ {roleLabel}: заменить «{currentName || 'не назначен'}» на + +
+ +
+); diff --git a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss index ff0d849..e7a4727 100644 --- a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss @@ -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). Так маршрут читается на телефоне без горизонтального скролла.