diff --git a/docs/forms.md b/docs/forms.md index dd2e4b2..d275456 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -42,7 +42,7 @@ | --- | --- | --- | :---: | :---: | :---: | --- | :---: | | [`recruiting-selection`](../src/webparts/requestPortal/forms/RecruitingSelection/README.md) | Заявка на подбор | Рекрутинг | нет | нет | нет | много полей (~42), поля-люди (в т.ч. мульти) | ✅ | | [`recruiting-hire`](../src/webparts/requestPortal/forms/RecruitingHire/README.md) | Заявка на прием | Рекрутинг | да | да | да | маска телефона, деньги, условное «взамен кого», источник для орг. места | ✅ | -| [`recruiting-referral`](../src/webparts/requestPortal/forms/Referral/README.md) | Реферальная рекомендация | Рекрутинг | да | нет | да | кандидат — внешний (текстовые поля, не People); маршрут по рекомендателю; условное уточнение «откуда знаете» | ✅ | +| [`recruiting-referral`](../src/webparts/requestPortal/forms/Referral/README.md) | Реферальная рекомендация | Рекрутинг | да | да | да | кандидат — внешний (текстовые поля, не People); маршрут по рекомендателю; исполнитель переназначает на реального; условное уточнение «откуда знаете» | ✅ | | [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ | | [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ | | [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker); условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД), сумма обучения | ✅ | diff --git a/src/webparts/requestPortal/forms/Referral/README.md b/src/webparts/requestPortal/forms/Referral/README.md index dce4cc6..57525ca 100644 --- a/src/webparts/requestPortal/forms/Referral/README.md +++ b/src/webparts/requestPortal/forms/Referral/README.md @@ -10,10 +10,15 @@ ## Маршрут - Тип заявки в approval-route API (`/api/request-types`): `pageUrl` «recruiting-referral». -- Фазы: только согласование. Фазы исполнения нет — после согласования заявка - получает статус «Согласовано». -- **Матрица редактируема заявителем** при новой заявке / черновике / возврате: - `useMatrixEditor(editable = !readOnly)`. +- Фазы: согласование + исполнение. Исполнитель в маршруте — фиксированная + роль (не выбирается заявителем), которая назначает **реального** исполнителя + через переназначение (флаг `AllowReassignToOtherApprovers` на шаге в + админке approval-route) — см. обсуждение в истории репо. Обе фазы + подгружаются отдельными запросами `getPlannedMatrix(..., 'Approval' | 'Execution')` + и рендерятся как два отдельных блока `` (как в RecruitingHire). +- **Матрица согласования редактируема заявителем** при новой заявке / черновике + / возврате: `useMatrixEditor(editable = !readOnly)`. **Матрица исполнения — + только чтение** (фиксируется маршрутом, не заявителем). ## Особое поведение - **Кандидат — не сотрудник компании.** Его Ф.И.О., телефон, email и город — diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx index 8ee8192..4e714cd 100644 --- a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx +++ b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx @@ -22,7 +22,8 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - resolveWriteListId + resolveWriteListId, + APPROVAL_PHASE } from '../../shared/approval'; import { IRequestFormProps } from '../../models/types'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -138,7 +139,9 @@ const ReferralForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval + .getMatrixForRequest(editId) + .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)) : getPlannedMatrix(context, requestTypeId as number, data.Referrer, approval, 'Approval'), [context, approval, editId, data.Referrer, requestTypeId] ); @@ -150,6 +153,27 @@ const ReferralForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Фаза исполнения: реальный исполнитель, назначаемый по маршруту (не + // редактируется заявителем — только чтение, как в RecruitingHire/Clearance). + // Именно этот шаг сейчас «пропадал» на форме: без явного запроса фазы + // 'Execution' getPlannedMatrix её просто не резолвит. + const loadExecutors = useCallback( + () => + editId + ? approval + .getMatrixForRequest(editId) + .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)) + : getPlannedMatrix(context, requestTypeId as number, data.Referrer, approval, 'Execution'), + [context, approval, editId, data.Referrer, requestTypeId] + ); + const executors = useMatrixEditor( + loadExecutors, + `${data.Referrer}-${editId ?? 'new'}`, + false, + !!editId || (!!data.Referrer && !!requestTypeId), + (login) => approval.getJobTitle(login) + ); + // «Другое» в «откуда знаете» без уточнения — незаполненное поле по сути. const sourceValid = !!( data.AcquaintanceSource && @@ -217,6 +241,7 @@ const ReferralForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + toApprovers(executors.rows), status, writeListId ); @@ -253,6 +278,11 @@ const ReferralForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; @@ -303,6 +333,16 @@ const ReferralForm: React.FC = ({ approval.searchUsers(q)} /> + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение (после согласования)" + roleLabel="Исполнитель" + emptyText="Исполнители не заданы по маршруту." + /> + )} + = ({ context, itemId, formKey, const dataListRef = listId || dataListTitle(FORM_ID); const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); - // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в + // Матрица маршрута из фактических строк заявки (read-only), разбитая по + // фазам — иначе шаг исполнения (реальный исполнитель, назначаемый по + // маршруту) смешивался бы с согласующими в одной таблице. decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback( + () => + approval + .getMatrixForRequest(itemId) + .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)), + [approval, itemId] + ); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const loadExecutors = useCallback( + () => + approval + .getMatrixForRequest(itemId) + .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)), + [approval, itemId] + ); + const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) => + approval.getJobTitle(l) + ); useEffect(() => { let alive = true; @@ -144,6 +163,16 @@ const ReferralView: React.FC = ({ context, itemId, formKey, approval.searchUsers(q)} /> + + {!executors.loading && executors.rows.length > 0 && ( + approval.searchUsers(q)} + title="Исполнение" + roleLabel="Исполнитель" + emptyText="Исполнители не назначены." + /> + )} {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/Referral/submit.ts b/src/webparts/requestPortal/forms/Referral/submit.ts index e6533d7..1e14c08 100644 --- a/src/webparts/requestPortal/forms/Referral/submit.ts +++ b/src/webparts/requestPortal/forms/Referral/submit.ts @@ -60,7 +60,9 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить реферальную рекомендацию: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей. + * После согласования — фаза исполнения: реальный исполнитель, назначаемый по + * маршруту (не заявителем). executors может быть пустым — тогда после + * согласования заявка сразу станет «Согласовано» (как у форм без исполнения). * Возвращает Id заявки (+ код при создании). * * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadReferralRequest). Имеет смысл @@ -73,6 +75,7 @@ export async function saveReferralRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { @@ -80,6 +83,6 @@ export async function saveReferralRequest( const item = buildItem(data, status, referrer.Id); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(referralApprovalService(listId), result.id, approvers, [], status); + await writeRoute(referralApprovalService(listId), result.id, approvers, executors, status); return result; }