From 8cfb364f9c379dbcb67c1ad258e57f6bd6df8acc 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: Thu, 24 Sep 2026 17:02:57 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=90=D0=BB=D1=82=D1=8B=D0=BD=D0=B0?= =?UTF-8?q?=D0=B9=20=D0=91=D1=83=D0=BB=D1=82=D1=83=D1=80=D1=83=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=20=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=B7=D0=B0=D1=8F?= =?UTF-8?q?=D0=B2=D0=BE=D0=BA=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D0=B8=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D0=B2=D0=BE=D1=91=D0=BC=20=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitingHire/RecruitingHireView.tsx | 68 +++++++++----- .../forms/RecruitingHire/submit.ts | 19 +++- .../RecruitingSelectionView.module.scss | 33 +++++++ .../RecruitingSelectionView.tsx | 93 +++++++++++++++++-- .../forms/RecruitingSelection/submit.ts | 13 ++- .../shared/approval/fullEditRights.ts | 20 ++++ .../requestPortal/shared/approval/index.ts | 1 + .../shared/approval/ui/DecisionPanel.tsx | 8 +- 8 files changed, 215 insertions(+), 40 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/fullEditRights.ts diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 082c18b..c334f84 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -15,8 +15,10 @@ import { isApprovedStatus, resolveApproverOrgInfo, canEditRoute, + canEditAllFields, REQUEST_STATUS, - IMatrixRow + IMatrixRow, + ApproverDecision } from '../../shared/approval'; import { navigate } from '../../shared/routing'; import { effectiveUserEmail } from '../../shared/impersonation'; @@ -32,7 +34,8 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadHireRequest } from './load'; import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories'; import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; -import { hireApprovalService, updateApproverFields, IApproverEditableFields } from './submit'; +import { hireApprovalService, updateApproverFields, updateAllHireFields, IApproverEditableFields } from './submit'; +import { missingRequiredFields } from './validation'; import { canEditPayrollFields } from './approverRights'; import { ApplicationSection } from './sections/ApplicationSection'; import { CandidateSection } from './sections/CandidateSection'; @@ -61,12 +64,14 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error'; const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.2) — без неё заявку нельзя отметить выполненной.'; const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).'; const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».'; +const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля заявки (кроме вложений).'; // Заявка «на прием» только для просмотра (режим согласующего). Переиспользует // те же секции, что и форма подачи; редактирование выключено через // , КРОМЕ окладов (4.2/4.3) и отдела/участка (2.4) — // их на своём шаге правит начальник расчётной группы (см. approverRights.ts), — -// и даты приёма (2.2), которую проставляет активный исполнитель. +// и даты приёма (2.2), которую проставляет активный исполнитель. Согласующий +// из fullEditRights.ts на своём шаге правит все поля (вложения — нет). const RecruitingHireView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки @@ -83,6 +88,9 @@ const RecruitingHireView: React.FC = ({ context, itemI // Дату приёма (2.2) проставляет АКТИВНЫЙ исполнитель — по роли в маршруте, а // не по должности: кто сейчас на шаге исполнения, тот и заполняет. const [hireDateEditable, setHireDateEditable] = useState(false); + // Все поля заявки правит на своём активном шаге согласующий из + // shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова). + const [fullEditable, setFullEditable] = useState(false); const [saveState, setSaveState] = useState('idle'); const [saveErrorMessage, setSaveErrorMessage] = useState(''); // Право удалять согласующих из уже поданного маршрута — см. @@ -139,7 +147,8 @@ const RecruitingHireView: React.FC = ({ context, itemI ); // Права на поля — по ТЕКУЩЕМУ активному шагу пользователя: оклады и - // отдел/участок — по должности согласующего, дата приёма — любому исполнителю. + // отдел/участок — по должности согласующего, дата приёма — любому исполнителю, + // все поля — согласующему из fullEditRights.ts (по email). // isDelegator намеренно не даёт прав: делегировавший не решает за делегата // (см. DecisionPanel), значит и эти поля за него править не должен. useEffect(() => { @@ -152,21 +161,28 @@ const RecruitingHireView: React.FC = ({ context, itemI const isExecution = pending?.phase === APPROVAL_PHASE.execution; setPayrollEditable(isActive && !isExecution && canEditPayrollFields(pending?.position)); setHireDateEditable(isActive && isExecution); + setFullEditable(isActive && !isExecution && canEditAllFields(effectiveUserEmail(context))); }) .catch(() => { if (!alive) return; setPayrollEditable(false); setHireDateEditable(false); + setFullEditable(false); }); return () => { alive = false; }; // decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем. - }, [itemId, approverService, decisionSeq]); + }, [itemId, approverService, decisionSeq, context]); - // Пишем ТОЛЬКО эти поля: иначе согласующий затёр бы остальную заявку - // устаревшим снимком формы, загруженным при открытии экрана. - const saveApproverFields = useCallback(async (): Promise => { + // Полное право — перезаписываем все поля заявки. Иначе пишем ТОЛЬКО свои + // поля: согласующий не должен затирать остальную заявку устаревшим снимком + // формы, загруженным при открытии экрана. + const saveApproverFields = async (): Promise => { + if (fullEditable) { + await updateAllHireFields(itemId, data, listId); + return; + } const fields: Partial = {}; if (payrollEditable) { fields.SalaryProbation = data.SalaryProbation; @@ -177,27 +193,28 @@ const RecruitingHireView: React.FC = ({ context, itemI fields.HireHireDate = data.HireHireDate; } await updateApproverFields(itemId, fields, listId); - }, [ - itemId, - listId, - payrollEditable, - hireDateEditable, - data.SalaryProbation, - data.SalaryAfterProbation, - data.Department, - data.HireHireDate - ]); + }; - // Перед решением: исполнитель не может отметить «Выполнено» без даты приёма - // (при подаче её могли не указать — «По приезду на объект»). Бросок отменяет - // решение, текст ошибки показывает DecisionPanel. - const handleBeforeDecide = async (): Promise => { + // Перед решением (бросок отменяет решение, текст ошибки показывает DecisionPanel): + // • исполнитель не может отметить «Выполнено» без даты приёма (при подаче + // её могли не указать — «По приезду на объект»); + // • с полным правом нельзя согласовать заявку с очищенным обязательным + // полем. Возврат/отклонение — можно: там заявка дальше не идёт. + const handleBeforeDecide = async (decision: ApproverDecision): Promise => { if (hireDateEditable && !data.HireHireDate) { throw new Error(HIRE_DATE_REQUIRED_MESSAGE); } + if (fullEditable && decision === 'approve') { + const missing = missingRequiredFields(data); + if (missing.length > 0) throw new Error(`заполните обязательные поля: ${missing.join(', ')}.`); + } await saveApproverFields(); }; - const hasEditableFields = payrollEditable || hireDateEditable; + const hasEditableFields = payrollEditable || hireDateEditable || fullEditable; + + let editHint = PAYROLL_HINT; + if (fullEditable) editHint = FULL_EDIT_HINT; + else if (hireDateEditable) editHint = HIRE_DATE_HINT; const handleSaveFields = async (): Promise => { setSaveState('saving'); @@ -294,7 +311,7 @@ const RecruitingHireView: React.FC = ({ context, itemI {hasEditableFields && (
- {hireDateEditable ? HIRE_DATE_HINT : PAYROLL_HINT} + {editHint} @@ -317,7 +334,8 @@ const RecruitingHireView: React.FC = ({ context, itemI } headerRight={status ? : undefined} > - + {/* Вложения ниже — вне этого блока: полное право открывает только поля. */} + diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 819d238..3ab6dda 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -19,9 +19,9 @@ function buildTitle(data: IFormData): string { return ['Прием', data.HireCandidateName, data.Position].filter(Boolean).join(' — '); } -/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */ -function buildItem(data: IFormData, status: string): Record { - const item: Record = { Title: buildTitle(data), RequestStatus: status }; +/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа), без статуса. */ +function buildFields(data: IFormData): Record { + const item: Record = { Title: buildTitle(data) }; (Object.keys(data) as (keyof IFormData)[]).forEach((key) => { if (key === 'Companies') { @@ -45,8 +45,21 @@ function buildItem(data: IFormData, status: string): Record { return item; } +function buildItem(data: IFormData, status: string): Record { + return { ...buildFields(data), RequestStatus: status }; +} + export type ISaveResult = ISaveItemResult; +/** + * Перезаписать ВСЕ поля заявки — для согласующего с полным правом правки + * (см. shared/approval/fullEditRights.ts). Статус, маршрут и вложения не + * трогаются: заявка остаётся на том же шаге согласования. + */ +export async function updateAllHireFields(id: number, data: IFormData, listId?: string): Promise { + await resolveDataList(FORM_ID, listId).items.getById(id).update(buildFields(data)); +} + /** * Поля, которые участники маршрута правят на своём шаге: оклады и отдел — * начальник расчётной группы (см. approverRights.ts), дата приёма — активный diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.module.scss b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.module.scss index 6a27763..294dd98 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.module.scss +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.module.scss @@ -12,3 +12,36 @@ justify-content: flex-end; margin-bottom: $sp-4; } + +// Полоса «вы можете редактировать все поля» для согласующего с полным правом — +// см. shared/approval/fullEditRights.ts (тот же приём, что в RecruitingHireView). +.reviewBar { + display: flex; + flex-wrap: wrap; // на узком экране кнопка уходит под подсказку + align-items: center; + gap: $sp-3; + margin-bottom: $sp-4; + padding: $sp-3 $sp-4; + border: 1px solid $gold-300; + border-radius: $r-md; + background: $gold-50; +} + +.reviewHint { + flex: 1; + min-width: 0; + font-size: $fs-sm; + color: $navy-800; +} + +.savedNote { + font-size: $fs-sm; + color: $green-600; + font-weight: 600; +} + +.saveError { + font-size: $fs-sm; + color: $red-500; + font-weight: 600; +} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index 87f12af..0663d5a 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './RecruitingSelectionView.module.scss'; -import { Alert, FormCard, InitiatorLine, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui'; +import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui'; import { ApproverService, DecisionPanel, @@ -14,9 +14,11 @@ import { isApprovedStatus, resolveApproverOrgInfo, canEditRoute, + canEditAllFields, REQUEST_STATUS, IMatrixRow, - APPROVAL_PHASE + APPROVAL_PHASE, + ApproverDecision } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { effectiveUserEmail } from '../../shared/impersonation'; @@ -29,7 +31,8 @@ import { } from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadSelectionRequest } from './load'; -import { selectionApprovalService } from './submit'; +import { selectionApprovalService, updateAllSelectionFields } from './submit'; +import { missingRequiredFields } from './validation'; import { ApplicationSection } from './sections/ApplicationSection'; import { DepartmentSection } from './sections/DepartmentSection'; import { VacancySection } from './sections/VacancySection'; @@ -48,11 +51,14 @@ interface IRecruitingSelectionViewProps { onCodeLoaded?: (code: string) => void; } -// В режиме чтения поля не редактируются, поэтому обновление — пустышка. -const NOOP_SET: SetField = () => undefined; +type SaveState = 'idle' | 'saving' | 'saved' | 'error'; + +const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля заявки (кроме вложений).'; // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же -// секции, что и форма подачи; редактирование выключено через . +// секции, что и форма подачи; редактирование выключено через , +// кроме согласующего из fullEditRights.ts на его активном шаге — он правит все поля +// (вложения — нет). const RecruitingSelectionView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки @@ -70,6 +76,13 @@ const RecruitingSelectionView: React.FC = ({ cont // привязано к активному шагу — не зависит от того, чья сейчас очередь. const canRemoveApprover = canEditRoute(effectiveUserEmail(context)); const [routeError, setRouteError] = useState(undefined); + // Все поля заявки правит на своём АКТИВНОМ шаге согласующий из + // shared/approval/fullEditRights.ts (сейчас — Алтынай Бултурукова). + const [fullEditable, setFullEditable] = useState(false); + const [saveState, setSaveState] = useState('idle'); + const [saveErrorMessage, setSaveErrorMessage] = useState(''); + + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — // selectionApprovalService можно звать не опасаясь исключения. @@ -105,6 +118,55 @@ const RecruitingSelectionView: React.FC = ({ cont approval.getJobTitle(l), resolveApproverOrgInfo(context) ); + // Право на все поля — только на СВОЁМ активном шаге согласования. isDelegator + // прав не даёт: делегировавший не решает за делегата (см. DecisionPanel). + useEffect(() => { + let alive = true; + approverService + .getPendingRowForCurrentUser(itemId) + .then((pending) => { + if (!alive) return; + const isActiveApproval = + !!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution; + setFullEditable(isActiveApproval && canEditAllFields(effectiveUserEmail(context))); + }) + .catch(() => { + if (alive) setFullEditable(false); + }); + return () => { + alive = false; + }; + // decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем. + }, [itemId, approverService, decisionSeq, context]); + + const saveAllFields = async (): Promise => { + if (!fullEditable) return; + await updateAllSelectionFields(itemId, data, listId); + }; + + const handleSaveFields = async (): Promise => { + setSaveState('saving'); + setSaveErrorMessage(''); + try { + await saveAllFields(); + setSaveState('saved'); + } catch (e) { + setSaveErrorMessage(friendlyErrorMessage(e)); + setSaveState('error'); + } + }; + + // Правки сохраняются вместе с решением — иначе решение ушло бы со старыми + // данными. Согласовать с очищенным обязательным полем нельзя; вернуть/ + // отклонить — можно (бросок отменяет решение, текст показывает DecisionPanel). + const handleBeforeDecide = async (decision: ApproverDecision): Promise => { + if (decision === 'approve') { + const missing = missingRequiredFields(data); + if (missing.length > 0) throw new Error(`заполните обязательные поля: ${missing.join(', ')}.`); + } + await saveAllFields(); + }; + // Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep). const handleRemoveApprover = (row: IMatrixRow): void => { if (!row.id) return; @@ -152,7 +214,7 @@ const RecruitingSelectionView: React.FC = ({ cont ); } - const sectionProps = { data, set: NOOP_SET, context }; + const sectionProps = { data, set, context }; const notify = (): void => { if (!requestTypeId) return; @@ -169,6 +231,19 @@ const RecruitingSelectionView: React.FC = ({ cont {isApprovedStatus(status) && }
+ {fullEditable && ( +
+ {FULL_EDIT_HINT} + + {saveState === 'saved' && Сохранено} + {saveState === 'error' && ( + {saveErrorMessage || 'Ошибка сохранения'} + )} +
+ )} +
= ({ cont } headerRight={status ? : undefined} > - + {/* Вложения ниже — вне этого блока: полное право открывает только поля. */} + @@ -223,6 +299,7 @@ const RecruitingSelectionView: React.FC = ({ cont service={approverService} requestId={itemId} search={(q) => approval.searchUsers(q)} + onBeforeDecide={fullEditable ? handleBeforeDecide : undefined} onDecided={(newStatus) => { setStatus(newStatus); setDecisionSeq((n) => n + 1); diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index 7b7c659..b88d3d1 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -1,6 +1,6 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; -import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; +import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; import { FORM_ID, IFormData } from './model'; // Поля-люди (один человек): логин → ensureUser → Id в колонку Id. @@ -85,6 +85,17 @@ async function applyPeople(item: Record, data: IFormData): Prom export type ISaveResult = ISaveItemResult; +/** + * Перезаписать ВСЕ поля заявки — для согласующего с полным правом правки + * (см. shared/approval/fullEditRights.ts). Статус, маршрут и вложения не + * трогаются: заявка остаётся на том же шаге согласования. + */ +export async function updateAllSelectionFields(id: number, data: IFormData, listId?: string): Promise { + const item = buildScalarBody(data); + await applyPeople(item, data); + await resolveDataList(FORM_ID, listId).items.getById(id).update(item); +} + /** * Сохранить заявку «на подбор»: содержимое + маршрут (согласующие + исполнители). * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. diff --git a/src/webparts/requestPortal/shared/approval/fullEditRights.ts b/src/webparts/requestPortal/shared/approval/fullEditRights.ts new file mode 100644 index 0000000..52361c6 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/fullEditRights.ts @@ -0,0 +1,20 @@ +/** + * Кто на СВОЁМ АКТИВНОМ шаге согласования может править ВСЕ поля заявки + * (а не только отдельные, как расчётная группа в RecruitingHire). + * + * Пока фиксированный email одного человека (Алтынай Бултурукова, начальник + * отдела подбора и адаптации персонала) — группы SharePoint для этого ещё + * нет, как и в routeEditRights.ts. ВРЕМЕННО. + * + * Подключено только в «Заявке на подбор» и «Заявке на прием» (Реферальная + * рекомендация — нет, по решению Тимура 2026-09-24). Вложения этим правом не + * открываются — только поля. + */ +const FULL_FIELD_EDITOR_EMAILS: ReadonlySet = new Set([ + 'a.bulturukova@tnsu.kz' // Алтынай Бултурукова +]); + +/** Может ли этот email править все поля заявки на своём активном шаге (см. выше). */ +export function canEditAllFields(email: string | undefined): boolean { + return !!email && FULL_FIELD_EDITOR_EMAILS.has(email.trim().toLowerCase()); +} diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index f800e87..ff2f8ae 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -63,6 +63,7 @@ export type { IUseMatrixEditor } from './useMatrixEditor'; export { resolveApproverOrgInfo } from './approverOrgInfo'; export type { IApproverOrgInfo } from './approverOrgInfo'; export { canEditRoute } from './routeEditRights'; +export { canEditAllFields } from './fullEditRights'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { UserPicker } from './ui/UserPicker'; export { DecisionPanel } from './ui/DecisionPanel'; diff --git a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx index 1ce37c3..a85387e 100644 --- a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx @@ -26,8 +26,10 @@ interface IDecisionPanelProps { */ onDecided?: (status: string) => void | Promise; /** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные - * согласующим). Если бросит — решение не применяется, показывается ошибка. */ - onBeforeDecide?: () => Promise; + * согласующим). Если бросит — решение не применяется, показывается ошибка. + * decision — какое решение применяется (проверки, нужные только при + * согласовании, не должны мешать возврату/отклонению). */ + onBeforeDecide?: (decision: ApproverDecision) => Promise; /** Поиск пользователей — нужен для переназначения (делегирования). Без него * кнопка «Переназначить» не показывается. */ search?: (query: string) => Promise; @@ -110,7 +112,7 @@ export const DecisionPanel: React.FC = ({ setSubmitting(true); try { if (onBeforeDecide) { - await onBeforeDecide(); + await onBeforeDecide(decision); } const status = await service.decide(requestId, pendingRow.id, decision, comment.trim()); // Сначала побочные действия решения, потом «сохранено»: пока onDecided не