From e72fd27ee8edc664aca77659fc88d847668b6a4e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 21 Aug 2026 12:18:25 +0500 Subject: [PATCH 1/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BC=D0=B0=D0=BD=D0=B8=D1=84=D0=B5=D1=81=D1=82=D1=8B?= =?UTF-8?q?=20provisioning.json=20=D0=B4=D0=BB=D1=8F=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=8B=20=C2=AB=D0=A0=D0=B5=D1=84=D0=B5=D1=80=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Списки данных и согласования по конвенции остальных форм — колонки под поля бумажной формы (рекомендатель, кандидат, вакансия, оценка рекомендателя). Co-Authored-By: Claude Sonnet 5 --- .../forms/Referral/approval/provisioning.json | 16 +++++++++ .../forms/Referral/provisioning.json | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/webparts/requestPortal/forms/Referral/approval/provisioning.json create mode 100644 src/webparts/requestPortal/forms/Referral/provisioning.json diff --git a/src/webparts/requestPortal/forms/Referral/approval/provisioning.json b/src/webparts/requestPortal/forms/Referral/approval/provisioning.json new file mode 100644 index 0000000..4e58915 --- /dev/null +++ b/src/webparts/requestPortal/forms/Referral/approval/provisioning.json @@ -0,0 +1,16 @@ +{ + "dataListTitle": "Реферальная рекомендация — согласование", + "columns": [ + { "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, + { "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] }, + { "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Реферальная рекомендация — данные", "lookupShowField": "Title" }, + { "internalName": "Date", "title": "Дата", "type": "DateTime" }, + { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, + { "internalName": "StepOrder", "title": "Порядок", "type": "Number" }, + { "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" }, + { "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] }, + { "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" }, + { "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" }, + { "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" } + ] +} diff --git a/src/webparts/requestPortal/forms/Referral/provisioning.json b/src/webparts/requestPortal/forms/Referral/provisioning.json new file mode 100644 index 0000000..f7652a5 --- /dev/null +++ b/src/webparts/requestPortal/forms/Referral/provisioning.json @@ -0,0 +1,33 @@ +{ + "dataListTitle": "Реферальная рекомендация — данные", + "columns": [ + { "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" }, + { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + + { "internalName": "SubmitDate", "title": "Дата подачи", "type": "DateTime", "dateOnly": true }, + { "internalName": "Referrer", "title": "Рекомендатель", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, + { "internalName": "ReferrerPosition", "title": "Должность рекомендателя", "type": "Text" }, + { "internalName": "ReferrerDepartment", "title": "Подразделение / Объект рекомендателя", "type": "Text" }, + { "internalName": "ReferrerPhone", "title": "Контактный телефон рекомендателя", "type": "Text" }, + + { "internalName": "CandidateName", "title": "ФИО кандидата", "type": "Text", "required": true }, + { "internalName": "CandidatePhone", "title": "Контактный телефон кандидата", "type": "Text" }, + { "internalName": "CandidateEmail", "title": "Email кандидата", "type": "Text" }, + { "internalName": "CandidateCity", "title": "Город проживания кандидата", "type": "Text" }, + + { "internalName": "VacancyTitle", "title": "Название вакансии", "type": "Text", "required": true }, + { "internalName": "VacancyDepartment", "title": "Подразделение / Объект вакансии", "type": "Text" }, + + { "internalName": "CurrentEmployer", "title": "Текущее место работы кандидата", "type": "Text" }, + { "internalName": "CurrentPosition", "title": "Текущая должность кандидата", "type": "Text" }, + { "internalName": "ExperienceYears", "title": "Общий стаж по специальности", "type": "Text" }, + { "internalName": "AcquaintanceDuration", "title": "Как давно знаете кандидата", "type": "Text" }, + { "internalName": "AcquaintanceSource", "title": "Откуда знаете кандидата", "type": "Choice", "choices": ["Работали вместе", "Друзья", "Родственники", "Другое"] }, + { "internalName": "AcquaintanceSourceOther", "title": "Откуда знаете кандидата (другое)", "type": "Text" }, + + { "internalName": "Strengths", "title": "Почему рекомендуете кандидата (сильные стороны)", "type": "Note" }, + { "internalName": "ConcernsNotes", "title": "Моменты, о которых стоит знать", "type": "Note" }, + { "internalName": "ReadyForShiftWork", "title": "Готовность к вахтовому графику", "type": "Choice", "choices": ["Да", "Нет", "Не применимо"] }, + { "internalName": "SalaryExpectations", "title": "Ожидания по заработной плате", "type": "Text" } + ] +} -- 2.34.1 From b7250544ba9af17fef94e1f2d00903350cea8cd9 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 21 Aug 2026 13:03:23 +0500 Subject: [PATCH 2/8] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20UTF-8=20BOM=20=D0=B2=20Provision-Lists.ps1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Без BOM Windows PowerShell 5.1 читает файл в системной кодировке и ломает кириллицу в строковых литералах (названия списков, ошибки). Co-Authored-By: Claude Sonnet 5 --- provisioning/Provision-Lists.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provisioning/Provision-Lists.ps1 b/provisioning/Provision-Lists.ps1 index 0809b4e..fc6b629 100644 --- a/provisioning/Provision-Lists.ps1 +++ b/provisioning/Provision-Lists.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Идемпотентно создаёт списки данных форм портала заявок в SharePoint Online по манифестам provisioning.json (единый источник правды по колонкам). -- 2.34.1 From 3ec1df0e16679515b6d22965dc37fe24443df831 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 21 Aug 2026 13:42:20 +0500 Subject: [PATCH 3/8] =?UTF-8?q?=D0=A4=D0=BE=D1=80=D0=BC=D0=B0=20=C2=AB?= =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B5=D1=80=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=C2=BB=20(recruiting-referral)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сотрудник рекомендует внешнего кандидата на вакансию. Пять секций: рекомендатель, данные кандидата, вакансия, информация о кандидате, оценка рекомендателя. Есть режим согласующего (ReferralView). Кандидат — не сотрудник тенанта, поэтому его ФИО/телефон/почта — текстовые поля, а не People; маршрут согласования резолвится по рекомендателю. Регистрация: registry, viewRegistry, плитка в data/requests, база имени списка в listTitles, индекс в docs/forms.md. Co-Authored-By: Claude Opus 5 --- docs/forms.md | 1 + src/webparts/requestPortal/data/requests.ts | 1 + .../requestPortal/forms/Referral/README.md | 44 +++ .../forms/Referral/ReferralForm.module.scss | 20 ++ .../forms/Referral/ReferralForm.tsx | 320 ++++++++++++++++++ .../forms/Referral/ReferralView.module.scss | 14 + .../forms/Referral/ReferralView.tsx | 170 ++++++++++ .../requestPortal/forms/Referral/load.ts | 89 +++++ .../requestPortal/forms/Referral/model.ts | 117 +++++++ .../Referral/sections/AssessmentSection.tsx | 31 ++ .../sections/CandidateInfoSection.tsx | 50 +++ .../Referral/sections/CandidateSection.tsx | 24 ++ .../Referral/sections/ReferrerSection.tsx | 37 ++ .../Referral/sections/VacancySection.tsx | 19 ++ .../requestPortal/forms/Referral/submit.ts | 85 +++++ src/webparts/requestPortal/forms/registry.ts | 3 + .../requestPortal/forms/viewRegistry.ts | 3 + .../requestPortal/shared/listTitles.ts | 1 + 18 files changed, 1029 insertions(+) create mode 100644 src/webparts/requestPortal/forms/Referral/README.md create mode 100644 src/webparts/requestPortal/forms/Referral/ReferralForm.module.scss create mode 100644 src/webparts/requestPortal/forms/Referral/ReferralForm.tsx create mode 100644 src/webparts/requestPortal/forms/Referral/ReferralView.module.scss create mode 100644 src/webparts/requestPortal/forms/Referral/ReferralView.tsx create mode 100644 src/webparts/requestPortal/forms/Referral/load.ts create mode 100644 src/webparts/requestPortal/forms/Referral/model.ts create mode 100644 src/webparts/requestPortal/forms/Referral/sections/AssessmentSection.tsx create mode 100644 src/webparts/requestPortal/forms/Referral/sections/CandidateInfoSection.tsx create mode 100644 src/webparts/requestPortal/forms/Referral/sections/CandidateSection.tsx create mode 100644 src/webparts/requestPortal/forms/Referral/sections/ReferrerSection.tsx create mode 100644 src/webparts/requestPortal/forms/Referral/sections/VacancySection.tsx create mode 100644 src/webparts/requestPortal/forms/Referral/submit.ts diff --git a/docs/forms.md b/docs/forms.md index 9a11499..dd2e4b2 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -42,6 +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); маршрут по рекомендателю; условное уточнение «откуда знаете» | ✅ | | [`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/data/requests.ts b/src/webparts/requestPortal/data/requests.ts index 96c2f3f..4e3a902 100644 --- a/src/webparts/requestPortal/data/requests.ts +++ b/src/webparts/requestPortal/data/requests.ts @@ -8,6 +8,7 @@ export const REQUEST_TYPES: IRequestType[] = [ // Отдел рекрутинга { id: 'recruiting-selection', categoryId: 'recruiting', title: 'Заявка на подбор', icon: 'search' }, { id: 'recruiting-hire', categoryId: 'recruiting', title: 'Заявка на прием', icon: 'people' }, + { id: 'recruiting-referral', categoryId: 'recruiting', title: 'Реферальная рекомендация', icon: 'send' }, // IT { id: 'it-workplace-setup', categoryId: 'it', title: 'Заявка на организацию рабочего места', icon: 'monitor' }, diff --git a/src/webparts/requestPortal/forms/Referral/README.md b/src/webparts/requestPortal/forms/Referral/README.md new file mode 100644 index 0000000..dce4cc6 --- /dev/null +++ b/src/webparts/requestPortal/forms/Referral/README.md @@ -0,0 +1,44 @@ +# Реферальная рекомендация (recruiting-referral) + +**Назначение:** сотрудник рекомендует внешнего кандидата на открытую вакансию +(реферальная программа). +**Категория:** Отдел рекрутинга +**Списки SP:** «Реферальная рекомендация — данные» / «Реферальная рекомендация — согласование» +**RequestTypeId (внешний API маршрутов):** _плейсхолдер_ — получит числовой id при +заведении типа в админке approval-route (форма резолвит его в рантайме по pageUrl, +хардкодить не нужно — см. `docs/forms.md`). + +## Маршрут +- Тип заявки в approval-route API (`/api/request-types`): `pageUrl` «recruiting-referral». +- Фазы: только согласование. Фазы исполнения нет — после согласования заявка + получает статус «Согласовано». +- **Матрица редактируема заявителем** при новой заявке / черновике / возврате: + `useMatrixEditor(editable = !readOnly)`. + +## Особое поведение +- **Кандидат — не сотрудник компании.** Его Ф.И.О., телефон, email и город — + обычные текстовые поля, а НЕ People-picker: в каталоге тенанта его ещё нет. + People-поле здесь только одно — `Referrer` (рекомендатель). +- **Маршрут резолвится по рекомендателю** (`getPlannedMatrix(..., data.Referrer, ...)`), + reloadKey матрицы включает `data.Referrer`. Рекомендацию согласуют по линии + рекомендателя — кандидата в тенанте нет, резолвить условия по нему нечем. +- **Автоподстановка при создании:** рекомендатель = текущий пользователь, его + должность из профиля (`SPS-JobTitle` через `approval.getJobTitle`), дата подачи = + сегодня. При выборе другого рекомендателя должность подтягивается заново. +- **Условное поле:** «Откуда знаете» = «Другое» → появляется обязательное + уточнение `AcquaintanceSourceOther`. При смене варианта уточнение очищается, + и в список пишется `null` (см. `buildItem` в `submit.ts`). +- **Маски:** `PhoneInput` на `ReferrerPhone` и `CandidatePhone`. +- **Заголовок элемента** (`Title`): `Реферальная рекомендация — {ФИО кандидата} — {вакансия} — дд.мм.гггг`. + +## Секции формы +ReferrerSection (дата подачи · рекомендатель · должность · подразделение · телефон) +· CandidateSection (Ф.И.О. · телефон · email · город) +· VacancySection (название вакансии · подразделение) +· CandidateInfoSection (текущая работа · должность · стаж · как давно знаете · откуда знаете [+ уточнение]) +· AssessmentSection (сильные стороны · о чём стоит знать · вахта · ожидания по ЗП) + +## Точки входа +- rptype: `recruiting-referral` +- Режим согласующего (просмотр): есть (`ReferralView`) +- «Создать на основе»: нет diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.module.scss b/src/webparts/requestPortal/forms/Referral/ReferralForm.module.scss new file mode 100644 index 0000000..e1ef36d --- /dev/null +++ b/src/webparts/requestPortal/forms/Referral/ReferralForm.module.scss @@ -0,0 +1,20 @@ +@import '../../../../styles/design-tokens'; + +.back { + display: inline-flex; + align-items: center; + gap: $sp-2; + margin-bottom: $sp-4; + padding: 0; + border: 0; + background: transparent; + color: $navy-700; + font-family: $font-sans; + font-size: $fs-sm; + cursor: pointer; + transition: color .18s; + + &:hover { + color: $gold-600; + } +} diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx new file mode 100644 index 0000000..8ee8192 --- /dev/null +++ b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx @@ -0,0 +1,320 @@ +import * as React from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; +import styles from './ReferralForm.module.scss'; +import { + Alert, + FormCard, + InitiatorLine, + FormModeProvider, + Icon, + RequestNumber, + SubmitActions, + useRequestSubmit +} from '../../shared/ui'; +import { + ApprovalMatrix, + StatusBadge, + useMatrixEditor, + getPlannedMatrix, + notifyActiveStep, + useRequestTypeId, + REQUEST_STATUS, + isEditableStatus, + IApproverToWrite, + unassignedStepMessage, + resolveWriteListId +} from '../../shared/approval'; +import { IRequestFormProps } from '../../models/types'; +import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField, OTHER_VALUE, todayIso } from './model'; +import { saveReferralRequest, referralApprovalService } from './submit'; +import { loadReferralRequest } from './load'; +import { ReferrerSection } from './sections/ReferrerSection'; +import { CandidateSection } from './sections/CandidateSection'; +import { VacancySection } from './sections/VacancySection'; +import { CandidateInfoSection } from './sections/CandidateInfoSection'; +import { AssessmentSection } from './sections/AssessmentSection'; + +const ReferralForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId, + editListId +}) => { + const [data, setData] = useState(EMPTY_DATA); + const [requestId, setRequestId] = useState(editId); + const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(undefined); + const submit = useRequestSubmit(); + + const readOnly = !isEditableStatus(requestStatus); + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); + + // editListId уже проверен в EditFeature — если бы партиции не нашлось в реестре, + // форма сюда вообще не смонтировалась бы, поэтому referralApprovalService можно + // звать не опасаясь исключения. + const approval = useMemo(() => referralApprovalService(editListId), [editListId]); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. + // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; + + // Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое и статус. + useEffect(() => { + if (!editId) { + return; + } + let alive = true; + loadReferralRequest(editId, editListId) + .then((loaded) => { + if (alive) { + setData(loaded.data); + setRequestStatus(loaded.status); + setRequestCode(loaded.code || undefined); + } + }) + .catch((e) => { + if (alive) { + submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`); + } + }); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editId, editListId]); + + // Новая заявка: рекомендатель — сам подающий (подставляем его и должность из + // профиля), дата подачи — сегодня. + useEffect(() => { + if (editId) { + return; + } + let alive = true; + const user = context.pageContext.user; + setData((prev) => ({ + ...prev, + Referrer: prev.Referrer || user.email, + SubmitDate: prev.SubmitDate || todayIso() + })); + approval + .getJobTitle(user.loginName) + .then((title) => { + if (alive && title) { + setData((prev) => ({ ...prev, ReferrerPosition: prev.ReferrerPosition || title })); + } + }) + .catch(() => undefined); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editId]); + + // Выбор другого рекомендателя подтягивает его должность. + const handleReferrerChange = (login: string): void => { + setData((prev) => ({ ...prev, Referrer: login })); + if (!login) { + return; + } + approval + .getJobTitle(login) + .then((title) => setData((prev) => ({ ...prev, ReferrerPosition: title || prev.ReferrerPosition }))) + .catch(() => undefined); + }; + + // Планируемый маршрут резолвится по e-mail РЕКОМЕНДАТЕЛЯ (кандидата в тенанте + // ещё нет, да и согласуют рекомендацию по линии рекомендателя). У заявки на + // редактировании маршрут читается из уже записанных строк, чтобы не затереть + // правки инициатора. + const loadMatrix = useCallback( + () => + editId + ? approval.getMatrixForRequest(editId) + : getPlannedMatrix(context, requestTypeId as number, data.Referrer, approval, 'Approval'), + [context, approval, editId, data.Referrer, requestTypeId] + ); + const matrix = useMatrixEditor( + loadMatrix, + `${data.Referrer}-${editId ?? 'new'}`, + !readOnly, + !!editId || (!!data.Referrer && !!requestTypeId), + (login) => approval.getJobTitle(login) + ); + + // «Другое» в «откуда знаете» без уточнения — незаполненное поле по сути. + const sourceValid = !!( + data.AcquaintanceSource && + (data.AcquaintanceSource !== OTHER_VALUE || data.AcquaintanceSourceOther.trim()) + ); + const isValid = !!( + data.SubmitDate && + data.Referrer && + data.ReferrerPosition.trim() && + data.ReferrerPhone.trim() && + data.CandidateName.trim() && + data.CandidatePhone.trim() && + data.VacancyTitle.trim() && + sourceValid && + data.Strengths.trim() + ); + const validate = (): string | undefined => { + if ( + !data.SubmitDate || + !data.Referrer || + !data.ReferrerPosition.trim() || + !data.ReferrerPhone.trim() || + !data.CandidateName.trim() || + !data.CandidatePhone.trim() || + !data.VacancyTitle.trim() || + !data.AcquaintanceSource || + !data.Strengths.trim() + ) { + return 'Заполните обязательные поля: дата подачи, рекомендатель, его должность и телефон, Ф.И.О. и телефон кандидата, название вакансии, откуда знаете кандидата, сильные стороны.'; + } + if (!sourceValid) { + return 'Уточните, откуда вы знаете кандидата — выбран вариант «Другое».'; + } + if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; + if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; + return undefined; + }; + + const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => + rows.map((r) => ({ + email: r.email, + position: r.position, + allowReassign: r.allowReassign, + order: r.order, + // Транзитом из маршрута в строку списка: наследование бэкенд применил + // при resolve, форма только переносит значения (см. IMatrixRow.slaHours). + slaHours: r.slaHours, + askOverdueReason: r.askOverdueReason + })); + + const save = (status: string): (() => Promise) => async (): Promise => { + // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова + // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу + // замыкания не видит — локальный const и явная проверка снимают обе проблемы. + const rtId = requestTypeId; + if (!rtId) { + throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + } + // Новая заявка пишется в ТЕКУЩУЮ партицию — запрашиваем её у approval-route + // API непосредственно перед записью (не кеш из onInit): именно этот вызов + // проверяет порог элементов и запускает архивацию следующей партиции. + const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); + const { id, code } = await saveReferralRequest( + requestId, + rtId, + data, + toApprovers(matrix.rows), + status, + writeListId + ); + setRequestId(id); + setRequestStatus(status); + if (code) { + setRequestCode(code); + } + if (onSaved) { + onSaved(); + } + // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + // Имена списков — той же партиции, куда фактически записана заявка (writeListId), + // не текущей: поток ищет строку маршрута именно в НЕЙ. + if (status === REQUEST_STATUS.onApproval) { + await notifyActiveStep(context, approval, id, { + requestTypeId: rtId, + dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), + approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID) + }); + } + }; + + // На отправку (в отличие от черновика) маршрут должен быть полностью + // укомплектован — иначе шаг без назначенного человека молча пропадёт при записи. + const validateSubmit = (): string | undefined => { + const formError = validate(); + if (formError) return formError; + if (matrix.error) { + return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; + } + const unassignedError = unassignedStepMessage( + matrix.rows, + (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` + ); + if (unassignedError) return unassignedError; + return undefined; + }; + + const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); + const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + + const sectionProps = { data, set, context, onReferrerChange: handleReferrerChange }; + + return ( +
+ + + + } + headerRight={ + requestId || requestStatus ? ( + <> + {requestStatus && } + {requestCode && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( + + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + + )} + {submit.error && {submit.error}} + + + + + + + + + + approval.searchUsers(q)} /> + + + +
+ ); +}; + +export default ReferralForm; diff --git a/src/webparts/requestPortal/forms/Referral/ReferralView.module.scss b/src/webparts/requestPortal/forms/Referral/ReferralView.module.scss new file mode 100644 index 0000000..6a27763 --- /dev/null +++ b/src/webparts/requestPortal/forms/Referral/ReferralView.module.scss @@ -0,0 +1,14 @@ +@import '../../../../styles/design-tokens'; + +.state { + padding: $sp-8; + color: $gray-500; + font-size: $fs-sm; + text-align: center; +} + +.toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: $sp-4; +} diff --git a/src/webparts/requestPortal/forms/Referral/ReferralView.tsx b/src/webparts/requestPortal/forms/Referral/ReferralView.tsx new file mode 100644 index 0000000..6634718 --- /dev/null +++ b/src/webparts/requestPortal/forms/Referral/ReferralView.tsx @@ -0,0 +1,170 @@ +import * as React from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import styles from './ReferralView.module.scss'; +import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui'; +import { + ApproverService, + DecisionPanel, + StatusBadge, + ApprovalMatrix, + useMatrixEditor, + notifyActiveStep, + useRequestTypeId +} from '../../shared/approval'; +import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { + approvalListTitle, + approvalListTitleForListId, + dataListTitle, + dataListTitleForListId +} from '../../shared/listTitles'; +import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; +import { loadReferralRequest } from './load'; +import { referralApprovalService } from './submit'; +import { ReferrerSection } from './sections/ReferrerSection'; +import { CandidateSection } from './sections/CandidateSection'; +import { VacancySection } from './sections/VacancySection'; +import { CandidateInfoSection } from './sections/CandidateInfoSection'; +import { AssessmentSection } from './sections/AssessmentSection'; + +interface IReferralViewProps { + context: WebPartContext; + itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; + /** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */ + listId?: string; +} + +// В режиме чтения поля не редактируются, поэтому обновление — пустышка. +const NOOP_SET: SetField = () => undefined; + +// Реферальная рекомендация только для просмотра (режим согласующего). Переиспользует +// те же секции, что и форма подачи; редактирование выключено через +// — поля сами становятся неизменяемыми. +const ReferralView: React.FC = ({ context, itemId, formKey, listId }) => { + const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — + // referralApprovalService можно звать не опасаясь исключения. + const approval = useMemo(() => referralApprovalService(listId), [listId]); + const { id: requestTypeId } = useRequestTypeId(context, formKey); + // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для + // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). + const dataListRef = listId || dataListTitle(FORM_ID); + const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); + + // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в + // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. + const [decisionSeq, setDecisionSeq] = useState(0); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => + approval.getJobTitle(l) + ); + + useEffect(() => { + let alive = true; + setLoading(true); + loadReferralRequest(itemId, listId) + .then((loaded) => { + if (alive) { + setData(loaded.data); + setStatus(loaded.status); + setCode(loaded.code); + setError(null); + setLoading(false); + } + }) + .catch((e) => { + if (alive) { + setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`); + setLoading(false); + } + }); + return () => { + alive = false; + }; + }, [itemId, listId]); + + if (loading) { + return
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + const sectionProps = { data, set: NOOP_SET, context }; + + const notify = (): void => { + // requestTypeId ещё не резолвился — решение уже применилось (строка списка + // обновлена), просто нечего уведомлять на следующем шаге; не проваливаем + // весь UI ради необязательного оповещения. + 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 ( +
+
+ +
+ +
+ + } + headerRight={status ? : undefined} + > + + + + + + + + + + approval.searchUsers(q)} /> +
+ + {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} +
+ approval.searchUsers(q)} + onDecided={(newStatus) => { + setStatus(newStatus); + setDecisionSeq((n) => n + 1); + notify(); + }} + onDelegated={() => { + setDecisionSeq((n) => n + 1); + notify(); + }} + /> +
+
+ ); +}; + +export default ReferralView; diff --git a/src/webparts/requestPortal/forms/Referral/load.ts b/src/webparts/requestPortal/forms/Referral/load.ts new file mode 100644 index 0000000..cbc574c --- /dev/null +++ b/src/webparts/requestPortal/forms/Referral/load.ts @@ -0,0 +1,89 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { dataListTitle } from '../../shared/listTitles'; +import { FORM_ID, IFormData, EMPTY_DATA } from './model'; + +export interface ILoadedRequest { + data: IFormData; + /** Ф.И.О. рекомендателя из поля Referrer (для заголовка режима чтения). */ + referrerName: string; + status: string; + /** Уникальный код заявки; '' — если запись создана до появления поля. */ + code: string; +} + +/** + * Прочитать реферальную рекомендацию по id: модель формы + статус (правка / просмотр). + * + * listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/ + * listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции; задан — идём напрямую + * по url списка, а не по имени: так не нужно резолвить, как называется архивный + * список, и запрос не зависит от реестра партиций. + */ +export async function loadReferralRequest(itemId: number, listId?: string): Promise { + const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); + const item = (await list + .items.getById(itemId) + .select( + 'Id', + 'SubmitDate', + 'ReferrerPosition', + 'ReferrerDepartment', + 'ReferrerPhone', + 'CandidateName', + 'CandidatePhone', + 'CandidateEmail', + 'CandidateCity', + 'VacancyTitle', + 'VacancyDepartment', + 'CurrentEmployer', + 'CurrentPosition', + 'ExperienceYears', + 'AcquaintanceDuration', + 'AcquaintanceSource', + 'AcquaintanceSourceOther', + 'Strengths', + 'ConcernsNotes', + 'ReadyForShiftWork', + 'SalaryExpectations', + 'RequestStatus', + 'RequestUniqueCode', + 'Referrer/Title', + 'Referrer/EMail' + ) + .expand('Referrer')()) as Record; + + const referrer = item.Referrer as { Title?: string; EMail?: string } | undefined; + const submitDate = (item.SubmitDate as string) || ''; + + const data: IFormData = { + ...EMPTY_DATA, + SubmitDate: submitDate.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + Referrer: referrer?.EMail || '', + ReferrerPosition: (item.ReferrerPosition as string) || '', + ReferrerDepartment: (item.ReferrerDepartment as string) || '', + ReferrerPhone: (item.ReferrerPhone as string) || '', + CandidateName: (item.CandidateName as string) || '', + CandidatePhone: (item.CandidatePhone as string) || '', + CandidateEmail: (item.CandidateEmail as string) || '', + CandidateCity: (item.CandidateCity as string) || '', + VacancyTitle: (item.VacancyTitle as string) || '', + VacancyDepartment: (item.VacancyDepartment as string) || '', + CurrentEmployer: (item.CurrentEmployer as string) || '', + CurrentPosition: (item.CurrentPosition as string) || '', + ExperienceYears: (item.ExperienceYears as string) || '', + AcquaintanceDuration: (item.AcquaintanceDuration as string) || '', + AcquaintanceSource: (item.AcquaintanceSource as string) || '', + AcquaintanceSourceOther: (item.AcquaintanceSourceOther as string) || '', + Strengths: (item.Strengths as string) || '', + ConcernsNotes: (item.ConcernsNotes as string) || '', + ReadyForShiftWork: (item.ReadyForShiftWork as string) || '', + SalaryExpectations: (item.SalaryExpectations as string) || '' + }; + + return { + data, + referrerName: referrer?.Title || '', + status: (item.RequestStatus as string) || '', + code: (item.RequestUniqueCode as string) || '' + }; +} diff --git a/src/webparts/requestPortal/forms/Referral/model.ts b/src/webparts/requestPortal/forms/Referral/model.ts new file mode 100644 index 0000000..616493c --- /dev/null +++ b/src/webparts/requestPortal/forms/Referral/model.ts @@ -0,0 +1,117 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { RequestTypeId } from '../../models/types'; +import { ISelectOption } from '../../shared/ui'; + +/** + * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков + * SharePoint выводятся из него, см. shared/listTitles.ts. + */ +export const FORM_ID: RequestTypeId = 'recruiting-referral'; + +/** Вариант «Другое» у Choice-полей: включает парное поле-уточнение. */ +export const OTHER_VALUE = 'Другое'; + +/** Откуда рекомендатель знает кандидата. */ +export const ACQUAINTANCE_SOURCE_OPTIONS: ISelectOption[] = [ + { value: 'Работали вместе', label: 'Работали вместе' }, + { value: 'Друзья', label: 'Друзья' }, + { value: 'Родственники', label: 'Родственники' }, + { value: OTHER_VALUE, label: OTHER_VALUE } +]; + +/** Готовность кандидата к вахтовому графику. */ +export const SHIFT_WORK_OPTIONS: ISelectOption[] = [ + { value: 'Да', label: 'Да' }, + { value: 'Нет', label: 'Нет' }, + { value: 'Не применимо', label: 'Не применимо' } +]; + +/** + * Данные формы «Реферальная рекомендация». Ключи = internalName колонок списка + * данных (см. provisioning.json), кроме Referrer — он хранит e-mail (то, что + * отдаёт PeopleField), а в список пишется как ReferrerId. + * + * Кандидат — НЕ сотрудник компании, поэтому его ФИО/телефон/почта — обычный + * текст, а не People-поля: в каталоге тенанта его ещё нет. + */ +export interface IFormData { + /** ISO YYYY-MM-DD (input type=date). По умолчанию — сегодня. */ + SubmitDate: string; + /** E-mail рекомендателя (People). Пусто = не выбран. */ + Referrer: string; + ReferrerPosition: string; + ReferrerDepartment: string; + ReferrerPhone: string; + + CandidateName: string; + CandidatePhone: string; + CandidateEmail: string; + CandidateCity: string; + + VacancyTitle: string; + VacancyDepartment: string; + + CurrentEmployer: string; + CurrentPosition: string; + ExperienceYears: string; + AcquaintanceDuration: string; + /** Один из ACQUAINTANCE_SOURCE_OPTIONS; OTHER_VALUE открывает …Other. */ + AcquaintanceSource: string; + AcquaintanceSourceOther: string; + + Strengths: string; + ConcernsNotes: string; + /** Один из SHIFT_WORK_OPTIONS. */ + ReadyForShiftWork: string; + SalaryExpectations: string; +} + +/** Обновление одного поля формы с сохранением типа значения. */ +export type SetField = (key: K, value: IFormData[K]) => void; + +/** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker). */ +export interface ISectionProps { + data: IFormData; + set: SetField; + context: WebPartContext; + /** + * Выбор рекомендателя подтягивает его должность — поэтому у поля отдельный + * обработчик. Если не передан (режим чтения) — правка через set. + */ + onReferrerChange?: (login: string) => void; +} + +export const EMPTY_DATA: IFormData = { + SubmitDate: '', + Referrer: '', + ReferrerPosition: '', + ReferrerDepartment: '', + ReferrerPhone: '', + CandidateName: '', + CandidatePhone: '', + CandidateEmail: '', + CandidateCity: '', + VacancyTitle: '', + VacancyDepartment: '', + CurrentEmployer: '', + CurrentPosition: '', + ExperienceYears: '', + AcquaintanceDuration: '', + AcquaintanceSource: '', + AcquaintanceSourceOther: '', + Strengths: '', + ConcernsNotes: '', + ReadyForShiftWork: '', + SalaryExpectations: '' +}; + +/** Плейсхолдер для поля выбора рекомендателя. */ +export const REFERRER_PLACEHOLDER = 'Начните вводить Ф.И.О. рекомендателя…'; + +/** Сегодняшняя дата как YYYY-MM-DD — значение по умолчанию для «Дата подачи». */ +export function todayIso(): string { + const now = new Date(); + const m = now.getMonth() + 1; + const d = now.getDate(); + return `${now.getFullYear()}-${m < 10 ? '0' : ''}${m}-${d < 10 ? '0' : ''}${d}`; +} diff --git a/src/webparts/requestPortal/forms/Referral/sections/AssessmentSection.tsx b/src/webparts/requestPortal/forms/Referral/sections/AssessmentSection.tsx new file mode 100644 index 0000000..67d978a --- /dev/null +++ b/src/webparts/requestPortal/forms/Referral/sections/AssessmentSection.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../shared/ui'; +import { ISectionProps, SHIFT_WORK_OPTIONS } from '../model'; + +/** Секция 5: субъективная оценка рекомендателя — сильные стороны, риски, условия. */ +export const AssessmentSection: React.FC = ({ data, set }) => ( + + +