From 2e6e138e8dcf03d8d10a18fe5e991ad50e21e9ff 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: Tue, 11 Aug 2026 17:35:49 +0500 Subject: [PATCH] feat: finished recruitin selection form --- .../forms/RecruitingSelection/README.md | 26 +- .../RecruitingSelectionForm.tsx | 255 ++++++++++++++---- .../RecruitingSelectionView.module.scss | 14 + .../RecruitingSelectionView.tsx | 157 +++++++++++ .../approval/provisioning.json | 14 + .../forms/RecruitingSelection/load.ts | 66 +++++ .../RecruitingSelection/provisioning.json | 1 + .../forms/RecruitingSelection/submit.ts | 78 ++++-- .../requestPortal/forms/viewRegistry.ts | 3 + 9 files changed, 529 insertions(+), 85 deletions(-) create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.module.scss create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/approval/provisioning.json create mode 100644 src/webparts/requestPortal/forms/RecruitingSelection/load.ts diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/README.md b/src/webparts/requestPortal/forms/RecruitingSelection/README.md index 762838c..e29d139 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/README.md +++ b/src/webparts/requestPortal/forms/RecruitingSelection/README.md @@ -2,29 +2,29 @@ **Назначение:** заявка на подбор персонала / открытие вакансии. **Категория:** Отдел рекрутинга -**Списки SP:** «Заявка на подбор — данные» (только данные — согласования нет) -**RequestTypeId (внешний API маршрутов):** — (маршрут не используется) +**Списки SP:** «Заявка на подбор — данные» / «Заявка на подбор — согласование» +**RequestTypeId (внешний API маршрутов):** 4 ## Маршрут -- Фаз нет: отправка = запись в список данных, без согласования и исполнения. +- Фазы: только согласование (как RecruitingHire, но без фазы исполнения — как Vacation). + Маршрут резолвится по email инициатора (текущего пользователя), матрица + **редактируема** заявителем при новой заявке / черновике / возврате. +- После согласования заявка получает статус «Согласовано». ## Особое поведение - **Самая большая форма (~42 поля).** - **Поля-люди:** `Responsible`, `ProposedMentor`, `DirectManager` — один человек; `HiringDecisionMaker` — несколько (мульти). Пишутся как `Id` - (см. `submit.ts` → `resolveUserId`). -- **Нет режима согласующего** (в `viewRegistry` не зарегистрирована) и **нет общего - жизненного цикла** (черновик/статус/upsert) — это ранняя форма, простая отправка - через `submitRecruitingSelection`. + (см. `submit.ts` → `applyPeople`/`resolveUserId`), читаются с `expand` по `EMail` + (`load.ts`). Пустое значение при обновлении очищает поле (`…Id = null` / `[]`). +- **Общий жизненный цикл:** черновик / отправка / статус (`RequestStatus`), upsert по + `existingId`, вложения, оповещение согласующего при отправке (`notifyActiveStep`) — + всё через `shared/approval` и `shared/ui`, как в Hire/1C/Vacation. ## Секции формы -См. `sections/` формы. +ApplicationSection · DepartmentSection · VacancySection · HealthTripsSection · ConditionsSection ## Точки входа - rptype: `recruiting-selection` -- Режим согласующего (просмотр): нет +- Режим согласующего (просмотр): есть (`RecruitingSelectionView`) - «Создать на основе»: — - -> Замечание: сделана до введения общего жизненного цикла и согласования. При -> необходимости приводится к общему шаблону (`useRequestSubmit` + `SubmitActions` -> + маршрут согласования), как в остальных формах. diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 131c783..859e050 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -1,53 +1,176 @@ import * as React from 'react'; -import { useState } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import styles from './RecruitingSelectionForm.module.scss'; -import { Button, Alert, FormCard, FormActions, Icon } from '../../shared/ui'; +import { + Alert, + FormCard, + FormModeProvider, + FileDropzone, + AttachmentList, + CollapseSection, + FormField, + Icon, + RequestNumber, + SubmitActions, + useRequestSubmit +} from '../../shared/ui'; +import { + ApprovalMatrix, + StatusBadge, + useMatrixEditor, + getPlannedMatrix, + notifyActiveStep, + REQUEST_STATUS, + isEditableStatus, + IApproverToWrite, + findUnassignedStep +} from '../../shared/approval'; +import { + uploadAttachments, + getAttachments, + deleteAttachment, + IAttachmentInfo +} from '../../shared/attachments/attachments.service'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; -import { submitRecruitingSelection } from './submit'; +import { + saveSelectionRequest, + selectionApprovalService, + RECRUITING_SELECTION_REQUEST_TYPE_ID, + DATA_LIST_TITLE, + APPROVAL_LIST_TITLE +} from './submit'; +import { loadSelectionRequest } from './load'; import { ApplicationSection } from './sections/ApplicationSection'; import { DepartmentSection } from './sections/DepartmentSection'; import { VacancySection } from './sections/VacancySection'; import { HealthTripsSection } from './sections/HealthTripsSection'; import { ConditionsSection } from './sections/ConditionsSection'; -const RecruitingSelectionForm: React.FC = ({ requestType, context, onCancel, onSaved }) => { +const RecruitingSelectionForm: React.FC = ({ requestType, context, onCancel, onSaved, editId }) => { const [data, setData] = useState(EMPTY_DATA); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); - const [savedCode, setSavedCode] = useState(undefined); - const [error, setError] = useState(null); + const [files, setFiles] = useState([]); + const [existingAttachments, setExistingAttachments] = useState([]); + const [requestId, setRequestId] = useState(editId); + const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(undefined); + const submit = useRequestSubmit(); - const set: SetField = (key, value) => { - setData((prev) => ({ ...prev, [key]: value })); - setSaved(false); - }; + const readOnly = !isEditableStatus(requestStatus); + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); - async function handleSave(): Promise { - const missing: string[] = []; - if (!data.Responsible) missing.push('Ответственный за подачу заявки'); - if (!data.DepartmentName) missing.push('Название отдела/департамента'); - if (!data.Position) missing.push('Должность'); - if (missing.length > 0) { - setError(`Заполните обязательные поля: ${missing.join(', ')}`); + // Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое, + // статус и уже прикреплённые вложения. + useEffect(() => { + if (!editId) { return; } + let alive = true; + Promise.all([loadSelectionRequest(editId), getAttachments(DATA_LIST_TITLE, editId)]) + .then(([loaded, atts]) => { + if (alive) { + setData(loaded.data); + setRequestStatus(loaded.status); + setRequestCode(loaded.code || undefined); + setExistingAttachments(atts); + } + }) + .catch((e) => { + if (alive) { + submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`); + } + }); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editId]); - setError(null); - setSaving(true); - try { - const code = await submitRecruitingSelection(data); - setSaved(true); - setSavedCode(code); - if (onSaved) { - onSaved(); - } - } catch (e) { - setError(`Ошибка сохранения: ${(e as Error).message}`); - } finally { - setSaving(false); + // Маршрут согласования резолвится по email инициатора (текущего пользователя). + // У заявки на редактировании маршрут читается из уже записанных строк, чтобы не + // затереть правки инициатора при первом сохранении. + const approval = useMemo(() => selectionApprovalService(), []); + const userEmail = context.pageContext.user.email; + const loadMatrix = useCallback( + () => + editId + ? approval.getMatrixForRequest(editId) + : getPlannedMatrix(context, RECRUITING_SELECTION_REQUEST_TYPE_ID, userEmail, approval, 'Approval'), + [context, userEmail, approval, editId] + ); + const matrix = useMatrixEditor( + loadMatrix, + `${userEmail}-${editId ?? 'new'}`, + !readOnly, + true, + (login) => approval.getJobTitle(login) + ); + + const isValid = !!(data.Responsible && data.DepartmentName.trim() && data.Position.trim()); + const validate = (): string | undefined => + isValid + ? undefined + : 'Заполните обязательные поля: ответственный за подачу, название отдела, должность.'; + + const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => + rows.map((r) => ({ + email: r.email, + position: r.position, + allowReassign: r.allowReassign, + order: r.order + })); + + const handleRemoveAttachment = (fileName: string): void => { + if (requestId === undefined) { + return; } - } + deleteAttachment(DATA_LIST_TITLE, requestId, fileName) + .then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName))) + .catch(() => undefined); + }; + + const save = (status: string): (() => Promise) => async (): Promise => { + const { id, code } = await saveSelectionRequest(requestId, data, toApprovers(matrix.rows), status); + setRequestId(id); + setRequestStatus(status); + if (code) { + setRequestCode(code); + } + if (files.length > 0) { + await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента + setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало + setExistingAttachments(await getAttachments(DATA_LIST_TITLE, id)); + } + if (onSaved) { + onSaved(); + } + // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + if (status === REQUEST_STATUS.onApproval) { + await notifyActiveStep(context, approval, id, { + requestTypeId: RECRUITING_SELECTION_REQUEST_TYPE_ID, + dataListName: DATA_LIST_TITLE, + approvalListName: APPROVAL_LIST_TITLE + }); + } + }; + + // На отправку (в отличие от черновика) маршрут должен быть полностью укомплектован — + // иначе шаг без назначенного человека молча пропадёт при записи в SharePoint. + const validateSubmit = (): string | undefined => { + const formError = validate(); + if (formError) return formError; + if (matrix.error) { + return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; + } + const unassigned = findUnassignedStep(matrix.rows); + if (unassigned) { + return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; + } + 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 }; @@ -58,27 +181,57 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con К списку заявок - - {saved && ( - Заявка сохранена в SharePoint. Код заявки: №{savedCode}. + + {requestStatus && } + {requestCode && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( + + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + )} - {error && {error}} + {submit.error && {submit.error}} - - - - - + + + + + + + - - - - + {!readOnly && ( + + {existingAttachments.length > 0 && ( + + + + )} + 0 ? 'Добавить файлы' : 'Файлы'} full> + + + + )} + + approval.searchUsers(q)} /> + + ); diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.module.scss b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.module.scss new file mode 100644 index 0000000..6a27763 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.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/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx new file mode 100644 index 0000000..76c8c99 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -0,0 +1,157 @@ +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, FormModeProvider, CollapseSection, FormField, AttachmentList, PrintButton } from '../../shared/ui'; +import { + ApproverService, + DecisionPanel, + StatusBadge, + ApprovalMatrix, + useMatrixEditor, + notifyActiveStep +} from '../../shared/approval'; +import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { IFormData, EMPTY_DATA, SetField } from './model'; +import { loadSelectionRequest } from './load'; +import { + selectionApprovalService, + RECRUITING_SELECTION_REQUEST_TYPE_ID, + DATA_LIST_TITLE, + APPROVAL_LIST_TITLE +} from './submit'; +import { ApplicationSection } from './sections/ApplicationSection'; +import { DepartmentSection } from './sections/DepartmentSection'; +import { VacancySection } from './sections/VacancySection'; +import { HealthTripsSection } from './sections/HealthTripsSection'; +import { ConditionsSection } from './sections/ConditionsSection'; + +interface IRecruitingSelectionViewProps { + context: WebPartContext; + itemId: number; +} + +// В режиме чтения поля не редактируются, поэтому обновление — пустышка. +const NOOP_SET: SetField = () => undefined; + +// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же +// секции, что и форма подачи; редактирование выключено через . +const RecruitingSelectionView: React.FC = ({ context, itemId }) => { + const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const approval = useMemo(() => selectionApprovalService(), []); + const approverService = useMemo(() => new ApproverService(approval, DATA_LIST_TITLE), [approval]); + + // Матрица маршрута из фактических строк заявки (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); + Promise.all([loadSelectionRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)]) + .then(([loaded, files]) => { + if (alive) { + setData(loaded.data); + setStatus(loaded.status); + setCode(loaded.code); + setAttachments(files); + setError(null); + setLoading(false); + } + }) + .catch((e) => { + if (alive) { + setError(`Не удалось загрузить заявку №${itemId}: ${(e as Error).message}`); + setLoading(false); + } + }); + return () => { + alive = false; + }; + }, [itemId]); + + if (loading) { + return
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + const sectionProps = { data, set: NOOP_SET, context }; + + const notify = (): void => { + notifyActiveStep(context, approval, itemId, { + requestTypeId: RECRUITING_SELECTION_REQUEST_TYPE_ID, + dataListName: DATA_LIST_TITLE, + approvalListName: APPROVAL_LIST_TITLE + }).catch(() => undefined); + }; + + return ( +
+
+ +
+ +
+ : 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 RecruitingSelectionView; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/approval/provisioning.json b/src/webparts/requestPortal/forms/RecruitingSelection/approval/provisioning.json new file mode 100644 index 0000000..0c56450 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/approval/provisioning.json @@ -0,0 +1,14 @@ +{ + "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": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" } + ] +} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/load.ts b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts new file mode 100644 index 0000000..e3027e7 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/load.ts @@ -0,0 +1,66 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { IFormData, EMPTY_DATA } from './model'; + +const LIST_TITLE = 'Заявка на подбор — данные'; + +// Категории полей — определяют, как читать значение из элемента списка. +const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager']; +const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker']; +const BOOL_FIELDS: (keyof IFormData)[] = ['IsRegistered', 'IsApproved', 'CompletionMark', 'MaterialResponsibility']; +const DATE_FIELDS: (keyof IFormData)[] = ['SubmitDate', 'ClosingDeadline']; + +type PersonValue = { EMail?: string } | undefined; + +export interface ILoadedRequest { + data: IFormData; + status: string; + /** Уникальный код заявки; '' — если запись создана до появления поля. */ + code: string; +} + +/** Прочитать заявку «на подбор» по id: модель формы + статус (правка / просмотр). */ +export async function loadSelectionRequest(itemId: number): Promise { + const keys = Object.keys(EMPTY_DATA) as (keyof IFormData)[]; + const isPerson = (k: keyof IFormData): boolean => + PERSON_FIELDS.indexOf(k) !== -1 || MULTI_PERSON_FIELDS.indexOf(k) !== -1; + + // Скалярные поля — по имени; поля-люди — вложенный EMail (+ expand). + const scalarSelects = keys.filter((k) => !isPerson(k)); + const personSelects = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS].map((k) => `${k}/EMail`); + const expands = [...PERSON_FIELDS, ...MULTI_PERSON_FIELDS]; + + const item = (await getSP() + .web.lists.getByTitle(LIST_TITLE) + .items.getById(itemId) + .select(...scalarSelects, 'RequestStatus', 'RequestUniqueCode', ...personSelects) + .expand(...expands)()) as Record; + + const data: IFormData = { ...EMPTY_DATA }; + const writable = data as unknown as Record; + + keys.forEach((key) => { + if (PERSON_FIELDS.indexOf(key) !== -1) { + writable[key] = (item[key] as PersonValue)?.EMail || ''; + } else if (MULTI_PERSON_FIELDS.indexOf(key) !== -1) { + const list = (item[key] as PersonValue[]) || []; + writable[key] = list.map((u) => u?.EMail || '').filter((e) => e.length > 0); + } else if (BOOL_FIELDS.indexOf(key) !== -1) { + writable[key] = !!item[key]; + } else { + const value = item[key]; + if (value === null || value === undefined) { + writable[key] = ''; + } else if (DATE_FIELDS.indexOf(key) !== -1 && typeof value === 'string') { + writable[key] = value.slice(0, 10); // ISO → YYYY-MM-DD для input type=date + } else { + writable[key] = String(value); + } + } + }); + + return { + data, + status: (item.RequestStatus as string) || '', + code: (item.RequestUniqueCode as string) || '' + }; +} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json index 22ec23c..30af6ea 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json @@ -2,6 +2,7 @@ "dataListTitle": "Заявка на подбор — данные", "columns": [ { "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" }, + { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, { "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true }, { "internalName": "IsRegistered", "title": "Зарегистрирована", "type": "Boolean" }, diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index b8c475b..26f63c5 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -1,10 +1,12 @@ import { getSP } from '../../shared/pnp/pnpjs'; +import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; import { buildRequestUniqueCode } from '../../shared/requestCode'; import { IFormData } from './model'; -const LIST_TITLE = 'Заявка на подбор — данные'; +export const DATA_LIST_TITLE = 'Заявка на подбор — данные'; +export const APPROVAL_LIST_TITLE = 'Заявка на подбор — согласование'; // Тип заявки во внешнем approval-route API (GET /api/request-types → id 4). -const RECRUITING_SELECTION_REQUEST_TYPE_ID = 4; +export const RECRUITING_SELECTION_REQUEST_TYPE_ID = 4; // Поля-люди (один человек): логин → ensureUser → Id в колонку Id. const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager']; @@ -13,18 +15,20 @@ const MULTI_PERSON_FIELDS: (keyof IFormData)[] = ['HiringDecisionMaker']; // Числовые поля: пустая строка → null, иначе Number(). const NUMBER_FIELDS: (keyof IFormData)[] = ['DepartmentHeadcount', 'RateProbation', 'RateAfterProbation']; +/** Экземпляр сервиса списка согласования этой формы. */ +export function selectionApprovalService(): ApprovalListService { + return new ApprovalListService(APPROVAL_LIST_TITLE); +} + /** Логин → числовой Id пользователя на текущем сайте. */ async function resolveUserId(login: string): Promise { const ensured = await getSP().web.ensureUser(login); // v4: возвращает ISiteUserInfo с .Id return ensured.Id; } -/** Записать заявку «на подбор» в список данных SharePoint. Возвращает уникальный код заявки. */ -export async function submitRecruitingSelection(data: IFormData): Promise { - const sp = getSP(); +/** Скалярные/числовые/да-нет поля из модели (людей и статус пропускаем — они отдельно). */ +function buildScalarBody(data: IFormData): Record { const item: Record = {}; - - // Текст/заметки/выбор/даты/да-нет/числа (людей пропускаем — они ниже). (Object.keys(data) as (keyof IFormData)[]).forEach((key) => { if (PERSON_FIELDS.indexOf(key) !== -1 || MULTI_PERSON_FIELDS.indexOf(key) !== -1) { return; @@ -38,29 +42,61 @@ export async function submitRecruitingSelection(data: IFormData): PromiseId. +/** Дописать в тело поля-люди: один человек → Id, несколько → массив Id. Пусто → очистка. */ +async function applyPeople(item: Record, data: IFormData): Promise { for (let i = 0; i < PERSON_FIELDS.length; i++) { const login = data[PERSON_FIELDS[i]] as string; - if (login) { - item[`${PERSON_FIELDS[i]}Id`] = await resolveUserId(login); - } + item[`${PERSON_FIELDS[i]}Id`] = login ? await resolveUserId(login) : null; } - - // Несколько человек → Id = массив Id. for (let i = 0; i < MULTI_PERSON_FIELDS.length; i++) { - const logins = data[MULTI_PERSON_FIELDS[i]] as string[]; + const logins = (data[MULTI_PERSON_FIELDS[i]] as string[]) || []; const ids: number[] = []; for (let j = 0; j < logins.length; j++) { ids.push(await resolveUserId(logins[j])); } item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids; } - - const added = await sp.web.lists.getByTitle(LIST_TITLE).items.add(item); - const id = (added as { Id: number }).Id; - // Код известен только после создания элемента (нужен его Id). - const code = buildRequestUniqueCode(RECRUITING_SELECTION_REQUEST_TYPE_ID, id, new Date()); - await sp.web.lists.getByTitle(LIST_TITLE).items.getById(id).update({ RequestUniqueCode: code }); - return code; +} + +export interface ISaveResult { + id: number; + /** Задан только при создании новой заявки (существующий код не меняется). */ + code?: string; +} + +/** + * Сохранить заявку «на подбор»: содержимое + маршрут согласования. + * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. + * status = «Черновик» (сохранение) или «На согласовании» (отправка). Фазы исполнения + * нет — writeRoute вызывается с пустым списком исполнителей. Возвращает Id (+ код при создании). + */ +export async function saveSelectionRequest( + existingId: number | undefined, + data: IFormData, + approvers: IApproverToWrite[], + status: string +): Promise { + const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE); + const item = buildScalarBody(data); + item.RequestStatus = status; + await applyPeople(item, data); + + let id: number; + let code: string | undefined; + if (existingId) { + await list.items.getById(existingId).update(item); + id = existingId; + } else { + const added = await list.items.add(item); + id = (added as { Id: number }).Id; + // Код известен только после создания элемента (нужен его Id). + code = buildRequestUniqueCode(RECRUITING_SELECTION_REQUEST_TYPE_ID, id, new Date()); + await list.items.getById(id).update({ RequestUniqueCode: code }); + } + + await writeRoute(selectionApprovalService(), id, approvers, [], status); + return { id, code }; } diff --git a/src/webparts/requestPortal/forms/viewRegistry.ts b/src/webparts/requestPortal/forms/viewRegistry.ts index fc75b36..b0f2ccd 100644 --- a/src/webparts/requestPortal/forms/viewRegistry.ts +++ b/src/webparts/requestPortal/forms/viewRegistry.ts @@ -13,6 +13,9 @@ type ViewComponent = React.ComponentType; * Добавить просмотр = создать <Форма>View.tsx + строка здесь. */ const viewRegistry: Record> = { + 'recruiting-selection': React.lazy( + () => import(/* webpackChunkName: 'view-recruiting-selection' */ './RecruitingSelection/RecruitingSelectionView') + ), 'recruiting-hire': React.lazy( () => import(/* webpackChunkName: 'view-recruiting-hire' */ './RecruitingHire/RecruitingHireView') ),