From 5c10c8f127f58a4686be73642be1e3e413536098 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: Wed, 7 Oct 2026 15:46:36 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D0=B8=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=201=D0=A1=20=D1=81=D0=B0=D0=BC=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=8F=D1=8E=D1=82=20=D0=B8=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D1=8F=D1=8E=D1=82=20=D0=B4=D1=80=D1=83=D0=B3=20?= =?UTF-8?q?=D0=B4=D1=80=D1=83=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отдел 1С передаёт заявки внутри себя: исполнитель с невыполненным шагом может в поданной заявке заменить или убрать невыполненного исполнителя, в т.ч. активного и себя. Выполненные шаги не трогаются, последнего невыполненного — только заменить. Механизм тот же, что в подборе, но право по роли в маршруте, а не по списку email. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/permissions.md | 3 +- .../requestPortal/forms/Ticket1C/README.md | 6 + .../forms/Ticket1C/Ticket1CView.tsx | 41 +++++-- .../forms/Ticket1C/useExecutorEditing.ts | 104 ++++++++++++++++++ 4 files changed, 144 insertions(+), 10 deletions(-) create mode 100644 src/webparts/requestPortal/forms/Ticket1C/useExecutorEditing.ts diff --git a/docs/permissions.md b/docs/permissions.md index 1f3e8e3..d9933a5 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -20,6 +20,7 @@ | Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` | | Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` | | Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` | +| То же в поданной заявке: заменять и удалять невыполненных исполнителей, **в том числе активного и себя** | Исполнители заявки, у которых свой шаг исполнения ещё не выполнен | роль в маршруте (фаза «Исполнение»), без списка email | **Только** Заявка в отдел 1С | `forms/Ticket1C/useExecutorEditing.ts` | | Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | | То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` | | Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` | @@ -57,7 +58,7 @@ ## Известные ограничения -- **Замена активного исполнителя (подбор).** Если прежний исполнитель держит +- **Замена активного исполнителя (подбор, 1С).** Если прежний исполнитель держит заявку открытой и, не обновив страницу, нажмёт «Выполнено», решение запишется в шаг, который уже принадлежит новому исполнителю. При решении пока не перепроверяется, что шаг всё ещё за этим человеком diff --git a/src/webparts/requestPortal/forms/Ticket1C/README.md b/src/webparts/requestPortal/forms/Ticket1C/README.md index 548b44c..c43b0cd 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/README.md +++ b/src/webparts/requestPortal/forms/Ticket1C/README.md @@ -37,6 +37,12 @@ шаг. Работает только для НОВОЙ заявки (`positionCode` есть только у планируемого маршрута, не у уже записанных строк списка согласования). +- **Исполнители сами заменяют и удаляют друг друга** в поданной заявке + (`useExecutorEditing.ts`): любой исполнитель с невыполненным шагом может заменить + или убрать невыполненного исполнителя, в т.ч. активного и себя. Выполненные шаги + не трогаются, последнего невыполненного — только заменить. Карточка Planner на + людей не назначается, поэтому замена её не затрагивает. + ## Особое поведение - **Контакты заявителя предзаполняются через Microsoft Graph** (`/me`): ФИО, email, телефон, отдел, должность — единственная форма портала, которая это делает diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx index d215a91..57ce9fa 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx @@ -42,6 +42,7 @@ import { handoffToPlanner, isPlannerHandoffStatus } from './plannerHandoff'; import { RequestSection } from './sections/RequestSection'; import { ContactSection } from './sections/ContactSection'; import { useStableCallback } from '../../shared/useStableCallback'; +import { useExecutorEditing } from './useExecutorEditing'; interface ITicket1CViewProps { context: WebPartContext; @@ -101,6 +102,32 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, approval.getJobTitle(l) ); + // 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); + }; + + const executorEditing = useExecutorEditing({ + approval, + approverService, + itemId, + executorRows: executors.rows, + reloadKey: decisionSeq, + onChanged: () => setDecisionSeq((n) => n + 1), + notifyRow: notify + }); + useEffect(() => { let alive = true; setLoading(true); @@ -140,15 +167,6 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, 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 (
@@ -196,6 +214,7 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, emptyText="Согласование не требовалось для этого вида работ." /> + {executorEditing.error && {executorEditing.error}} {!executors.loading && executors.rows.length > 0 && ( = ({ context, itemId, formKey, title="Исполнение" roleLabel="Исполнитель" emptyText="Исполнители не назначены." + canReplaceRow={executorEditing.canReplaceRow} + onReplaceRow={executorEditing.onReplaceRow} + canRemoveRow={executorEditing.canRemoveRow} + onRemoveRow={executorEditing.onRemoveRow} /> )}
diff --git a/src/webparts/requestPortal/forms/Ticket1C/useExecutorEditing.ts b/src/webparts/requestPortal/forms/Ticket1C/useExecutorEditing.ts new file mode 100644 index 0000000..0e817e7 --- /dev/null +++ b/src/webparts/requestPortal/forms/Ticket1C/useExecutorEditing.ts @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react'; +import { + ApprovalListService, + ApproverService, + APPROVAL_PHASE, + REQUEST_STATUS, + IMatrixRow, + ISiteUser +} from '../../shared/approval'; +import { friendlyErrorMessage } from '../../shared/diagnostics'; + +/** Шаг исполнения ещё не выполнен — его можно заменить или удалить. */ +function isPendingExecutor(row: { status: string }): boolean { + return row.status === REQUEST_STATUS.queued || row.status === REQUEST_STATUS.onExecution; +} + +export interface IExecutorEditing { + canReplaceRow?: (row: IMatrixRow) => boolean; + canRemoveRow?: (row: IMatrixRow) => boolean; + onReplaceRow: (row: IMatrixRow, user: ISiteUser) => void; + onRemoveRow: (row: IMatrixRow) => void; + error?: string; +} + +interface IExecutorEditingParams { + approval: ApprovalListService; + approverService: ApproverService; + itemId: number; + executorRows: IMatrixRow[]; + reloadKey: number; + /** Маршрут изменился на сервере — перечитать матрицы. */ + onChanged: () => void; + /** Оповестить строку, ставшую активной (заявка теперь у этого исполнителя). */ + notifyRow: (rowId: number) => void; +} + +/** + * Замена/удаление исполнителей в поданной заявке 1С. Право — у самих + * исполнителей отдела 1С: кто стоит в фазе «Исполнение» с невыполненным шагом, + * может заменить или убрать любого невыполненного исполнителя (в т.ч. + * активного и себя) — так отдел передаёт заявку внутри себя. Выполненные шаги + * не трогаются, последнего невыполненного исполнителя можно только заменить + * (см. ApproverService.deleteExecutionStep). + */ +export function useExecutorEditing(params: IExecutorEditingParams): IExecutorEditing { + const { approval, approverService, itemId, executorRows, reloadKey, onChanged, notifyRow } = params; + const [isExecutor, setIsExecutor] = useState(false); + const [error, setError] = useState(undefined); + + // В загруженных строках матрицы нет email, поэтому «я ли исполнитель» + // проверяем по Id пользователя в строках списка согласования. + useEffect(() => { + let alive = true; + Promise.all([approval.getForRequest(itemId), approval.getCurrentUserId()]) + .then(([rows, userId]) => { + if (!alive) return; + setIsExecutor( + rows.some((r) => r.phase === APPROVAL_PHASE.execution && r.approverId === userId && isPendingExecutor(r)) + ); + }) + .catch(() => { + if (alive) setIsExecutor(false); + }); + return () => { + alive = false; + }; + }, [approval, itemId, reloadKey]); + + const pendingCount = executorRows.filter(isPendingExecutor).length; + + const onReplaceRow = (row: IMatrixRow, user: ISiteUser): void => { + if (!row.id) return; + const rowId = row.id; + setError(undefined); + approverService + .replaceExecutionStep(itemId, rowId, user) + .then((wasActive) => { + onChanged(); + // Шаг уже был в работе — заявка сразу у нового исполнителя, сообщаем ему. + if (wasActive) notifyRow(rowId); + }) + .catch((e) => setError(friendlyErrorMessage(e))); + }; + + const onRemoveRow = (row: IMatrixRow): void => { + if (!row.id) return; + setError(undefined); + approverService + .deleteExecutionStep(itemId, row.id) + .then((activatedRowIds) => { + onChanged(); + activatedRowIds.forEach(notifyRow); + }) + .catch((e) => setError(friendlyErrorMessage(e))); + }; + + return { + canReplaceRow: isExecutor ? isPendingExecutor : undefined, + canRemoveRow: isExecutor ? (row) => isPendingExecutor(row) && pendingCount > 1 : undefined, + onReplaceRow, + onRemoveRow, + error + }; +}