From c2e785961b70525f39a86de62e05004853a5429c 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, 4 Aug 2026 11:08:16 +0500 Subject: [PATCH] feat: implemented workpalcesetup form --- docs/routing.md | 86 +++++++++++ .../components/RequestPortal.tsx | 9 +- .../features/submit/SubmitFeature.tsx | 26 +++- .../RecruitingHire/RecruitingHireForm.tsx | 2 - .../RecruitingHire/RecruitingHireView.tsx | 2 - .../forms/RecruitingHire/load.ts | 13 +- .../forms/RecruitingHire/model.ts | 7 +- .../forms/RecruitingHire/provisioning.json | 9 +- .../sections/CandidateSection.tsx | 4 +- .../sections/ResponsibleSection.tsx | 24 --- .../forms/RecruitingHire/submit.ts | 21 +-- .../WorkplaceSetupForm.module.scss | 20 +++ .../WorkplaceSetup/WorkplaceSetupForm.tsx | 146 ++++++++++++++++++ .../WorkplaceSetupView.module.scss | 8 + .../WorkplaceSetup/WorkplaceSetupView.tsx | 89 +++++++++++ .../WorkplaceSetup/approval/provisioning.json | 12 ++ .../forms/WorkplaceSetup/load.ts | 35 +++++ .../forms/WorkplaceSetup/model.ts | 46 ++++++ .../forms/WorkplaceSetup/prefill.ts | 20 +++ .../forms/WorkplaceSetup/provisioning.json | 21 +++ .../sections/EmployeeSection.tsx | 32 ++++ .../sections/EquipmentSection.tsx | 17 ++ .../forms/WorkplaceSetup/submit.ts | 57 +++++++ src/webparts/requestPortal/forms/registry.ts | 3 + .../requestPortal/forms/viewRegistry.ts | 3 + src/webparts/requestPortal/models/types.ts | 5 + .../shared/approval/ui/approval.module.scss | 2 + src/webparts/requestPortal/shared/routing.ts | 16 +- 28 files changed, 650 insertions(+), 85 deletions(-) create mode 100644 docs/routing.md delete mode 100644 src/webparts/requestPortal/forms/RecruitingHire/sections/ResponsibleSection.tsx create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.module.scss create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/load.ts create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/model.ts create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/sections/EmployeeSection.tsx create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/sections/EquipmentSection.tsx create mode 100644 src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts diff --git a/docs/routing.md b/docs/routing.md new file mode 100644 index 0000000..99cd6cd --- /dev/null +++ b/docs/routing.md @@ -0,0 +1,86 @@ +# Маршрутизация портала заявок (для интеграций) + +Портал — **один web part на одной странице**. Что показать, он решает по **параметрам URL**. +Этот документ — контракт ссылок для тех, кто их формирует (Power Automate, кнопки, уведомления). + +> Страница портала: `https://<тенант>.sharepoint.com/sites/requests/SitePages/<Страница>.aspx` +> (точный адрес страницы уточнить у владельца портала). + +--- + +## Важно: префикс `rp` + +Имена `id`, `mode`, `type` **зарезервированы SharePoint** и ломают страницу (`?id=` → +«Элемент не существует»). Поэтому все наши параметры — с префиксом **`rp`**: `rpmode`, `rptype`, `rpid`, `rpsource`. + +--- + +## Режимы + +| Режим | Когда | Параметры | Что открывается | +| --- | --- | --- | --- | +| **Подача** (по умолчанию) | нет наших параметров | — | Категории → плитки → форма | +| **Согласование** | согласующему пришла заявка | `rpmode=approve` + `rptype` + `rpid` | Заявка только для чтения + кнопки решения | +| **Создание на основе** | производная заявка с предзаполнением | `rptype` + `rpsource` | Форма, предзаполненная из исходной заявки | + +--- + +## Параметры + +| Параметр | Значение | Пример | +| --- | --- | --- | +| `rpmode` | `approve` — режим согласующего (иначе не указывать) | `rpmode=approve` | +| `rptype` | ключ формы (см. таблицу ниже) | `rptype=recruiting-hire` | +| `rpid` | **Id элемента списка ДАННЫХ** заявки (колонка ИД) — для `approve` | `rpid=42` | +| `rpsource` | **Id исходной заявки** для предзаполнения — для «создания на основе» | `rpsource=42` | + +--- + +## Ключи форм (`rptype`) + +| `rptype` | Заявка | Категория | Согласование | Режим согласующего | Создание на основе | +| --- | --- | --- | --- | --- | --- | +| `recruiting-selection` | Заявка на подбор | Рекрутинг | нет | нет | — | +| `recruiting-hire` | Заявка на прием | Рекрутинг | да | да | — | +| `it-workplace-setup` | Организация рабочего места | IT | да | да | из `recruiting-hire` | +| `hr-transfer` | На перемещение сотрудников | Отдел кадров | (в разработке) | — | — | + +--- + +## Готовые примеры ссылок + +Пусть страница портала = `…/sites/requests/SitePages/Портал-Заявок.aspx`. + +**Согласующему открыть заявку на прием №42:** +``` +…/Портал-Заявок.aspx?rpmode=approve&rptype=recruiting-hire&rpid=42 +``` + +**Согласующему открыть заявку «орг. рабочего места» №7:** +``` +…/Портал-Заявок.aspx?rpmode=approve&rptype=it-workplace-setup&rpid=7 +``` + +**Создать «орг. рабочего места» на основе заявки на прием №42 (предзаполнение):** +``` +…/Портал-Заявок.aspx?rptype=it-workplace-setup&rpsource=42 +``` + +--- + +## Кто формирует ссылки + +- **Согласование** (`rpmode=approve…`) — поток Power Automate, который уведомляет согласующего + (аналог `ct-notify-kps`). Поток знает `rpid` (Id элемента списка данных) и подставляет `rptype` + константой под свою форму. +- **Создание на основе** (`rptype…&rpsource…`) — кнопка на заявке-источнике (напр. «Создать заявку + на организацию рабочего места на основе текущей»). Кнопка подставляет `rpsource` = Id текущей заявки. + +--- + +## Примечания + +- `rpid` и `rpsource` — это **Id элемента списка ДАННЫХ** (не строки согласования). +- «Создание на основе» — **снимок**: значения копируются на момент создания; дальнейшие изменения + исходной заявки в производную не переносятся. У производной сохраняется колонка-источник (Lookup). +- Если нужного набора параметров нет/они неполные — портал открывается в обычном режиме подачи. diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 6a43257..6e6cf00 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -29,7 +29,14 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context {route.mode === 'approve' && route.formKey && route.itemId ? ( ) : ( - + )} ); diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx index c8a9d7f..c915c37 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx @@ -1,24 +1,34 @@ import * as React from 'react'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './SubmitFeature.module.scss'; import { Icon } from '../../shared/ui/Icon/Icon'; import { CategoryTabs } from '../../components/CategoryTabs/CategoryTabs'; import { RequestGrid } from '../../components/RequestGrid/RequestGrid'; import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../../data/categories'; -import { getRequestsByCategory } from '../../data/requests'; +import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests'; import { getFormComponent } from '../../forms/registry'; import { IRequestType } from '../../models/types'; export interface ISubmitFeatureProps { context: WebPartContext; + /** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */ + initialForm?: { formKey: string; sourceId: number }; } // Фича «Подать заявку»: категории → плитки → беспоук-форма. // Владелец: автор портала. Живёт целиком в features/submit/**. -const SubmitFeature: React.FC = ({ context }) => { - const [activeCategoryId, setActiveCategoryId] = useState(DEFAULT_CATEGORY_ID); - const [selectedRequest, setSelectedRequest] = useState(null); +const SubmitFeature: React.FC = ({ context, initialForm }) => { + // Если пришли по ссылке «создать на основе» — сразу открываем нужную форму. + const initialSelected = useMemo( + () => (initialForm ? REQUEST_TYPES.filter((r) => r.id === initialForm.formKey)[0] ?? null : null), + [initialForm] + ); + + const [activeCategoryId, setActiveCategoryId] = useState( + initialSelected ? initialSelected.categoryId : DEFAULT_CATEGORY_ID + ); + const [selectedRequest, setSelectedRequest] = useState(initialSelected); const requests = getRequestsByCategory(activeCategoryId); @@ -45,10 +55,14 @@ const SubmitFeature: React.FC = ({ context }) => { ); } + // sourceId — только для формы, открытой «на основе» исходной заявки. + const sourceId = + initialForm && request.id === initialForm.formKey ? initialForm.sourceId : undefined; + // Ленивый рендер формы: её код подтягивается только сейчас, при открытии. return ( Загрузка формы…}> - + ); }; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 6f21039..e41ef88 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -17,7 +17,6 @@ import { ApplicationSection } from './sections/ApplicationSection'; import { CandidateSection } from './sections/CandidateSection'; import { PpeSection } from './sections/PpeSection'; import { ConditionsSection } from './sections/ConditionsSection'; -import { ResponsibleSection } from './sections/ResponsibleSection'; type ActionStatus = 'idle' | 'saving' | 'success' | 'error'; type ActionKind = 'draft' | 'submit'; @@ -107,7 +106,6 @@ const RecruitingHireForm: React.FC = ({ requestType, context, - diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index a2a939c..bde125d 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -12,7 +12,6 @@ import { ApplicationSection } from './sections/ApplicationSection'; import { CandidateSection } from './sections/CandidateSection'; import { PpeSection } from './sections/PpeSection'; import { ConditionsSection } from './sections/ConditionsSection'; -import { ResponsibleSection } from './sections/ResponsibleSection'; interface IRecruitingHireViewProps { context: WebPartContext; @@ -81,7 +80,6 @@ const RecruitingHireView: React.FC = ({ context, itemI - diff --git a/src/webparts/requestPortal/forms/RecruitingHire/load.ts b/src/webparts/requestPortal/forms/RecruitingHire/load.ts index d62ec56..c37f106 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/load.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/load.ts @@ -16,12 +16,7 @@ export async function loadHireRequest(itemId: number): Promise { const item = (await sp.web.lists .getByTitle(LIST_TITLE) .items.getById(itemId) - .select( - ...SCALAR_FIELDS, - 'SiteManager/EMail', - 'SecuritySpecialist/EMail' - ) - .expand('SiteManager', 'SecuritySpecialist')()) as Record; + .select(...SCALAR_FIELDS)()) as Record; const data: IFormData = { ...EMPTY_DATA }; const writable = data as unknown as Record; @@ -37,11 +32,5 @@ export async function loadHireRequest(itemId: number): Promise { } }); - // Люди: из expand берём e-mail (по нему picker покажет ФИО в режиме чтения). - const manager = item.SiteManager as { EMail?: string } | undefined; - const security = item.SecuritySpecialist as { EMail?: string } | undefined; - data.SiteManager = manager?.EMail ?? ''; - data.SecuritySpecialist = security?.EMail ?? ''; - return data; } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/model.ts b/src/webparts/requestPortal/forms/RecruitingHire/model.ts index 03bdfa1..5f78d87 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/model.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/model.ts @@ -26,9 +26,6 @@ export interface IFormData { SalaryAfterProbation: string; WorkSchedule: string; EmploymentForm: string; - // Ответственные лица - SiteManager: string; - SecuritySpecialist: string; } /** Обновление одного поля формы с сохранением типа значения. */ @@ -57,9 +54,7 @@ export const EMPTY_DATA: IFormData = { SalaryProbation: '', SalaryAfterProbation: '', WorkSchedule: '', - EmploymentForm: '', - SiteManager: '', - SecuritySpecialist: '' + EmploymentForm: '' }; /** Значение «Тип приёма», при котором показывается поле «Взамен кого». */ diff --git a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json index ed1c442..7f73f96 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json @@ -11,8 +11,8 @@ { "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, { "internalName": "Position", "title": "Должность", "type": "Text", "required": true }, { "internalName": "WorkKind", "title": "Вид работ", "type": "Text" }, - { "internalName": "SiteName", "title": "Наименование участка", "type": "Text" }, - { "internalName": "SiteLocation", "title": "Местоположение участка", "type": "Text" }, + { "internalName": "SiteName", "title": "Отдел / участок", "type": "Text" }, + { "internalName": "SiteLocation", "title": "Местоположение", "type": "Text" }, { "internalName": "PpeHeight", "title": "СИЗ: рост", "type": "Number" }, { "internalName": "PpeClothingSize", "title": "СИЗ: размер одежды", "type": "Text" }, @@ -21,9 +21,6 @@ { "internalName": "SalaryProbation", "title": "Оклад при испытательном сроке", "type": "Number", "decimals": 2 }, { "internalName": "SalaryAfterProbation", "title": "Оклад по завершении ИС", "type": "Number", "decimals": 2 }, { "internalName": "WorkSchedule", "title": "График работы", "type": "Text" }, - { "internalName": "EmploymentForm", "title": "Вид оформления", "type": "Choice", "choices": ["ГПХ", "Штат"] }, - - { "internalName": "SiteManager", "title": "Руководитель участка", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "SecuritySpecialist", "title": "Специалист СБ", "type": "User", "userSelectionMode": "PeopleOnly" } + { "internalName": "EmploymentForm", "title": "Вид оформления", "type": "Choice", "choices": ["ГПХ", "Штат"] } ] } diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx index 0f3bf2b..50b1ce8 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/sections/CandidateSection.tsx @@ -16,10 +16,10 @@ export const CandidateSection: React.FC = ({ data, set }) => ( set('WorkKind', v)} /> - + set('SiteName', v)} /> - + set('SiteLocation', v)} /> diff --git a/src/webparts/requestPortal/forms/RecruitingHire/sections/ResponsibleSection.tsx b/src/webparts/requestPortal/forms/RecruitingHire/sections/ResponsibleSection.tsx deleted file mode 100644 index ae2d063..0000000 --- a/src/webparts/requestPortal/forms/RecruitingHire/sections/ResponsibleSection.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { CollapseSection, FormField, PeopleField } from '../../../shared/ui'; -import { ISectionProps, PERSON_PLACEHOLDER } from '../model'; - -export const ResponsibleSection: React.FC = ({ data, set, context }) => ( - - - set('SiteManager', v)} - placeholder={PERSON_PLACEHOLDER} - /> - - - set('SecuritySpecialist', v)} - placeholder={PERSON_PLACEHOLDER} - /> - - -); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 49a613a..84a3188 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -8,8 +8,6 @@ export const APPROVAL_LIST_TITLE = 'Прием на работу — согла // (GET /api/request-types → id 5). Захардкожен, как «отпуск = 1» в pto-tracker. export const HIRE_REQUEST_TYPE_ID = 5; -// Поля-люди (один человек): логин → ensureUser → Id в колонку Id. -const PERSON_FIELDS: (keyof IFormData)[] = ['SiteManager', 'SecuritySpecialist']; // Числовые поля: пустая строка → null, иначе Number(). const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation']; @@ -18,12 +16,6 @@ export function hireApprovalService(): 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; -} - /** Осмысленный заголовок элемента списка (системная колонка Title). */ function buildTitle(data: IFormData): string { return ['Прием', data.CandidateName, data.Position].filter(Boolean).join(' — '); @@ -33,11 +25,8 @@ function buildTitle(data: IFormData): string { async function writeContent(data: IFormData, status: string): Promise { const item: Record = { Title: buildTitle(data), Status: status }; - // Текст/выбор/даты/числа (людей пропускаем — они ниже). + // Текст/выбор/даты/числа. (Object.keys(data) as (keyof IFormData)[]).forEach((key) => { - if (PERSON_FIELDS.indexOf(key) !== -1) { - return; - } const value = data[key]; if (NUMBER_FIELDS.indexOf(key) !== -1) { item[key] = value === '' ? null : Number(value); @@ -46,14 +35,6 @@ async function writeContent(data: IFormData, status: string): Promise { } }); - // Один человек → Id. - 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); - } - } - const added = await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.add(item); return (added as { Id: number }).Id; } diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.module.scss b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.module.scss new file mode 100644 index 0000000..e1ef36d --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.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/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx new file mode 100644 index 0000000..42860f5 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; +import styles from './WorkplaceSetupForm.module.scss'; +import { Button, Alert, FormCard, FormActions, FileDropzone, CollapseSection, FormField, Icon } from '../../shared/ui'; +import { + ApprovalMatrix, + useMatrixEditor, + getPlannedMatrix, + REQUEST_STATUS, + IApproverToWrite +} from '../../shared/approval'; +import { uploadAttachments } from '../../shared/attachments/attachments.service'; +import { IRequestFormProps } from '../../models/types'; +import { IFormData, EMPTY_DATA, SetField } from './model'; +import { + saveWorkplaceRequest, + workplaceApprovalService, + WORKPLACE_REQUEST_TYPE_ID, + DATA_LIST_TITLE +} from './submit'; +import { loadPrefillFromHire } from './prefill'; +import { EmployeeSection } from './sections/EmployeeSection'; +import { EquipmentSection } from './sections/EquipmentSection'; + +type ActionStatus = 'idle' | 'saving' | 'success' | 'error'; +type ActionKind = 'draft' | 'submit'; + +const WorkplaceSetupForm: React.FC = ({ requestType, context, onCancel, onSaved, sourceId }) => { + const [data, setData] = useState(EMPTY_DATA); + const [actionStatus, setActionStatus] = useState('idle'); + const [actionKind, setActionKind] = useState(undefined); + const [error, setError] = useState(null); + const [files, setFiles] = useState([]); + + const set: SetField = (key, value) => { + setData((prev) => ({ ...prev, [key]: value })); + setActionStatus('idle'); + }; + + // Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource). + useEffect(() => { + if (!sourceId) { + return; + } + let alive = true; + loadPrefillFromHire(sourceId) + .then((prefilled) => { + if (alive) { + setData(prefilled); + } + }) + .catch((e) => { + if (alive) { + setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`); + } + }); + return () => { + alive = false; + }; + }, [sourceId]); + + const approval = useMemo(() => workplaceApprovalService(), []); + const userEmail = context.pageContext.user.email; + const loadMatrix = useCallback( + () => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval), + [context, userEmail, approval] + ); + const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) => approval.getJobTitle(login)); + + const saving = actionStatus === 'saving'; + const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0; + + const matrixApprovers = (): IApproverToWrite[] => + matrix.rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign })); + + async function run(kind: ActionKind, requestStatus: string): Promise { + if (!isValid) { + setError('Заполните обязательные поля: Ф.И.О., Должность.'); + return; + } + setError(null); + setActionKind(kind); + setActionStatus('saving'); + try { + const id = await saveWorkplaceRequest(data, matrixApprovers(), requestStatus, sourceId); + await uploadAttachments(DATA_LIST_TITLE, id, files); + setActionStatus('success'); + if (onSaved) { + onSaved(); + } + } catch (e) { + setActionStatus('error'); + setError(`Ошибка сохранения: ${(e as Error).message}`); + } + } + + const handleSaveDraft = (): Promise => run('draft', REQUEST_STATUS.draft); + const handleSubmit = (): Promise => run('submit', REQUEST_STATUS.onApproval); + + const sectionProps = { data, set, context }; + + return ( +
+ + + + {actionStatus === 'success' && ( + + {actionKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + + )} + {error && {error}} + + + + + + + + + + + approval.searchUsers(q)} /> + + + + + + + +
+ ); +}; + +export default WorkplaceSetupForm; diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss new file mode 100644 index 0000000..d921f54 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss @@ -0,0 +1,8 @@ +@import '../../../../styles/design-tokens'; + +.state { + padding: $sp-8; + color: $gray-500; + font-size: $fs-sm; + text-align: center; +} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx new file mode 100644 index 0000000..748c7e9 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import styles from './WorkplaceSetupView.module.scss'; +import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui'; +import { ApproverService, DecisionPanel } from '../../shared/approval'; +import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { IFormData, EMPTY_DATA, SetField } from './model'; +import { loadWorkplaceRequest } from './load'; +import { workplaceApprovalService, DATA_LIST_TITLE } from './submit'; +import { EmployeeSection } from './sections/EmployeeSection'; +import { EquipmentSection } from './sections/EquipmentSection'; + +interface IWorkplaceSetupViewProps { + context: WebPartContext; + itemId: number; +} + +const NOOP_SET: SetField = () => undefined; + +// Заявка «орг. рабочего места» только для просмотра (режим согласующего). +const WorkplaceSetupView: React.FC = ({ context, itemId }) => { + const [data, setData] = useState(EMPTY_DATA); + const [attachments, setAttachments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const approverService = useMemo( + () => new ApproverService(workplaceApprovalService(), DATA_LIST_TITLE), + [] + ); + + useEffect(() => { + let alive = true; + setLoading(true); + Promise.all([loadWorkplaceRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)]) + .then(([loaded, files]) => { + if (alive) { + setData(loaded); + 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 }; + + return ( +
+ + + + + + + + + + + + + + +
+ ); +}; + +export default WorkplaceSetupView; diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json new file mode 100644 index 0000000..9deee5e --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json @@ -0,0 +1,12 @@ +{ + "dataListTitle": "Организация рабочего места — согласование", + "columns": [ + { "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, + { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + { "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" }, + { "internalName": "Date", "title": "Дата", "type": "DateTime" }, + { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, + { "internalName": "Order", "title": "Порядок", "type": "Number" }, + { "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" } + ] +} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts new file mode 100644 index 0000000..8eb291e --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts @@ -0,0 +1,35 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { IFormData, EMPTY_DATA } from './model'; + +const LIST_TITLE = 'Организация рабочего места — данные'; + +const SCALAR_FIELDS: (keyof IFormData)[] = [ + 'FullName', 'NameLatin', 'Phone', 'Position', 'Department', 'WorkKind', 'Location', 'StartDate', + 'Software', 'AccessRights' +]; + +/** Прочитать заявку по id и разложить в модель формы (для режима чтения). */ +export async function loadWorkplaceRequest(itemId: number): Promise { + const item = (await getSP() + .web.lists.getByTitle(LIST_TITLE) + .items.getById(itemId) + .select(...SCALAR_FIELDS, 'NeedsEquipment')()) as Record; + + const data: IFormData = { ...EMPTY_DATA }; + const writable = data as unknown as Record; + + SCALAR_FIELDS.forEach((key) => { + const value = item[key]; + if (value === null || value === undefined) { + writable[key] = ''; + } else if (key === 'StartDate' && typeof value === 'string') { + writable[key] = value.slice(0, 10); + } else { + writable[key] = String(value); + } + }); + + data.NeedsEquipment = item.NeedsEquipment === true; + + return data; +} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/model.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/model.ts new file mode 100644 index 0000000..64c9f39 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/model.ts @@ -0,0 +1,46 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; + +/** + * Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка + * «Организация рабочего места — данные». + * Часть полей предзаполняется из «Заявки на прием» (см. prefill.ts). + */ +export interface IFormData { + // Сотрудник (предзаполняется из «прием», кроме NameLatin) + FullName: string; + NameLatin: string; + Phone: string; + Position: string; + Department: string; + WorkKind: string; + Location: string; + StartDate: string; + // Оборудование и доступы (заполняются вручную) + NeedsEquipment: boolean; + Software: string; + AccessRights: string; +} + +/** Обновление одного поля формы с сохранением типа значения. */ +export type SetField = (key: K, value: IFormData[K]) => void; + +/** Пропсы каждой секции формы. */ +export interface ISectionProps { + data: IFormData; + set: SetField; + context: WebPartContext; +} + +export const EMPTY_DATA: IFormData = { + FullName: '', + NameLatin: '', + Phone: '', + Position: '', + Department: '', + WorkKind: '', + Location: '', + StartDate: '', + NeedsEquipment: false, + Software: '', + AccessRights: '' +}; diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts new file mode 100644 index 0000000..334e8b9 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts @@ -0,0 +1,20 @@ +import { loadHireRequest } from '../RecruitingHire/load'; +import { IFormData, EMPTY_DATA } from './model'; + +/** + * Предзаполнить форму «орг. рабочего места» из «Заявки на прием» (снимок на момент создания). + * Копируются согласованные поля; остальное остаётся пустым для ручного заполнения. + */ +export async function loadPrefillFromHire(hireId: number): Promise { + const hire = await loadHireRequest(hireId); + return { + ...EMPTY_DATA, + FullName: hire.CandidateName, + Phone: hire.Phone, + Position: hire.Position, + Department: hire.SiteName, + WorkKind: hire.WorkKind, + Location: hire.SiteLocation, + StartDate: hire.HireDate + }; +} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json new file mode 100644 index 0000000..cd7df1b --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json @@ -0,0 +1,21 @@ +{ + "dataListTitle": "Организация рабочего места — данные", + "columns": [ + { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + + { "internalName": "FullName", "title": "Ф.И.О.", "type": "Text", "required": true }, + { "internalName": "NameLatin", "title": "Фамилия и имя (латиница)", "type": "Text" }, + { "internalName": "Phone", "title": "Телефон", "type": "Text" }, + { "internalName": "Position", "title": "Должность", "type": "Text", "required": true }, + { "internalName": "Department", "title": "Отдел / участок", "type": "Text" }, + { "internalName": "WorkKind", "title": "Вид работ", "type": "Text" }, + { "internalName": "Location", "title": "Местоположение", "type": "Text" }, + { "internalName": "StartDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, + + { "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" }, + { "internalName": "Software", "title": "Необходимое ПО", "type": "Note" }, + { "internalName": "AccessRights", "title": "Доступы", "type": "Note" }, + + { "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" } + ] +} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EmployeeSection.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EmployeeSection.tsx new file mode 100644 index 0000000..893a459 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EmployeeSection.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { CollapseSection, FormField, TextInput, PhoneInput } from '../../../shared/ui'; +import { ISectionProps } from '../model'; + +export const EmployeeSection: React.FC = ({ data, set }) => ( + + + set('FullName', v)} /> + + + set('NameLatin', v)} /> + + + set('Phone', v)} /> + + + set('Position', v)} /> + + + set('Department', v)} /> + + + set('WorkKind', v)} /> + + + set('Location', v)} /> + + + set('StartDate', v)} /> + + +); diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EquipmentSection.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EquipmentSection.tsx new file mode 100644 index 0000000..9fbf954 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EquipmentSection.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { CollapseSection, FormField, TextArea, Toggle } from '../../../shared/ui'; +import { ISectionProps } from '../model'; + +export const EquipmentSection: React.FC = ({ data, set }) => ( + + + set('NeedsEquipment', v)} onLabel="Да" /> + + +