feat: implemented workpalcesetup form

This commit is contained in:
Тимур Городецкий
2026-08-04 11:08:16 +05:00
parent dc60d2dbee
commit c2e785961b
28 changed files with 650 additions and 85 deletions
+86
View File
@@ -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).
- Если нужного набора параметров нет/они неполные — портал открывается в обычном режиме подачи.
@@ -29,7 +29,14 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
{route.mode === 'approve' && route.formKey && route.itemId ? (
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
) : (
<SubmitFeature context={context} />
<SubmitFeature
context={context}
initialForm={
route.mode === 'create' && route.formKey && route.sourceId
? { formKey: route.formKey, sourceId: route.sourceId }
: undefined
}
/>
)}
</section>
);
@@ -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<ISubmitFeatureProps> = ({ context }) => {
const [activeCategoryId, setActiveCategoryId] = useState<string>(DEFAULT_CATEGORY_ID);
const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(null);
const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, initialForm }) => {
// Если пришли по ссылке «создать на основе» — сразу открываем нужную форму.
const initialSelected = useMemo(
() => (initialForm ? REQUEST_TYPES.filter((r) => r.id === initialForm.formKey)[0] ?? null : null),
[initialForm]
);
const [activeCategoryId, setActiveCategoryId] = useState<string>(
initialSelected ? initialSelected.categoryId : DEFAULT_CATEGORY_ID
);
const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(initialSelected);
const requests = getRequestsByCategory(activeCategoryId);
@@ -45,10 +55,14 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context }) => {
);
}
// sourceId — только для формы, открытой «на основе» исходной заявки.
const sourceId =
initialForm && request.id === initialForm.formKey ? initialForm.sourceId : undefined;
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
return (
<React.Suspense fallback={<div className={styles.loading}>Загрузка формы…</div>}>
<FormComponent requestType={request} context={context} onCancel={handleBack} />
<FormComponent requestType={request} context={context} onCancel={handleBack} sourceId={sourceId} />
</React.Suspense>
);
};
@@ -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<IRequestFormProps> = ({ requestType, context,
<CandidateSection {...sectionProps} />
<PpeSection {...sectionProps} />
<ConditionsSection {...sectionProps} />
<ResponsibleSection {...sectionProps} />
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
@@ -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<IRecruitingHireViewProps> = ({ context, itemI
<CandidateSection {...sectionProps} />
<PpeSection {...sectionProps} />
<ConditionsSection {...sectionProps} />
<ResponsibleSection {...sectionProps} />
</FormModeProvider>
<CollapseSection title="Вложения" icon="upload">
@@ -16,12 +16,7 @@ export async function loadHireRequest(itemId: number): Promise<IFormData> {
const item = (await sp.web.lists
.getByTitle(LIST_TITLE)
.items.getById(itemId)
.select(
...SCALAR_FIELDS,
'SiteManager/EMail',
'SecuritySpecialist/EMail'
)
.expand('SiteManager', 'SecuritySpecialist')()) as Record<string, unknown>;
.select(...SCALAR_FIELDS)()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>;
@@ -37,11 +32,5 @@ export async function loadHireRequest(itemId: number): Promise<IFormData> {
}
});
// Люди: из 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;
}
@@ -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: ''
};
/** Значение «Тип приёма», при котором показывается поле «Взамен кого». */
@@ -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": ["ГПХ", "Штат"] }
]
}
@@ -16,10 +16,10 @@ export const CandidateSection: React.FC<ISectionProps> = ({ data, set }) => (
<FormField label="Вид работ" htmlFor="WorkKind">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
</FormField>
<FormField label="Наименование участка" htmlFor="SiteName">
<FormField label="Отдел / участок" htmlFor="SiteName">
<TextInput id="SiteName" value={data.SiteName} onChange={(v) => set('SiteName', v)} />
</FormField>
<FormField label="Местоположение участка" htmlFor="SiteLocation">
<FormField label="Местоположение" htmlFor="SiteLocation">
<TextInput id="SiteLocation" value={data.SiteLocation} onChange={(v) => set('SiteLocation', v)} />
</FormField>
</CollapseSection>
@@ -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<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Ответственные лица" icon="user-check">
<FormField label="Руководитель участка" full>
<PeopleField
context={context}
value={data.SiteManager}
onChange={(v) => set('SiteManager', v)}
placeholder={PERSON_PLACEHOLDER}
/>
</FormField>
<FormField label="Специалист СБ" full>
<PeopleField
context={context}
value={data.SecuritySpecialist}
onChange={(v) => set('SecuritySpecialist', v)}
placeholder={PERSON_PLACEHOLDER}
/>
</FormField>
</CollapseSection>
);
@@ -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 в колонку <Field>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<number> {
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<number> {
const item: Record<string, unknown> = { 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<number> {
}
});
// Один человек → <Field>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;
}
@@ -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;
}
}
@@ -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<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [actionStatus, setActionStatus] = useState<ActionStatus>('idle');
const [actionKind, setActionKind] = useState<ActionKind | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
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<void> {
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<void> => run('draft', REQUEST_STATUS.draft);
const handleSubmit = (): Promise<void> => run('submit', REQUEST_STATUS.onApproval);
const sectionProps = { data, set, context };
return (
<div>
<button type="button" className={styles.back} onClick={onCancel}>
<Icon name="arrow-left" size="sm" />
<span>К списку заявок</span>
</button>
<FormCard title={requestType.title} subtitle="Категория: IT">
{actionStatus === 'success' && (
<Alert variant="ok">
{actionKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
</Alert>
)}
{error && <Alert variant="err">{error}</Alert>}
<EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} />
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
<FileDropzone files={files} onChange={setFiles} />
</FormField>
</CollapseSection>
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
<FormActions>
<Button variant="secondary" onClick={onCancel} disabled={saving}>
Отмена
</Button>
<Button variant="secondary" onClick={handleSaveDraft} disabled={!isValid || saving}>
<Icon name="document" size="sm" />
Сохранить черновик
</Button>
<Button variant="accent" onClick={handleSubmit} disabled={!isValid || saving}>
<Icon name="send" size="sm" />
{saving && actionKind === 'submit' ? 'Отправка…' : 'Отправить на согласование'}
</Button>
</FormActions>
</FormCard>
</div>
);
};
export default WorkplaceSetupForm;
@@ -0,0 +1,8 @@
@import '../../../../styles/design-tokens';
.state {
padding: $sp-8;
color: $gray-500;
font-size: $fs-sm;
text-align: center;
}
@@ -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<IWorkplaceSetupViewProps> = ({ context, itemId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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 <div className={styles.state}>Загрузка заявки…</div>;
}
if (error) {
return (
<div className={styles.state}>
<Alert variant="err">{error}</Alert>
</div>
);
}
const sectionProps = { data, set: NOOP_SET, context };
return (
<div>
<FormCard title="Организация рабочего места" subtitle={`№${itemId} · режим согласования`}>
<FormModeProvider readOnly>
<EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} />
</FormModeProvider>
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
<AttachmentList items={attachments} />
</FormField>
</CollapseSection>
</FormCard>
<DecisionPanel service={approverService} requestId={itemId} />
</div>
);
};
export default WorkplaceSetupView;
@@ -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" }
]
}
@@ -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<IFormData> {
const item = (await getSP()
.web.lists.getByTitle(LIST_TITLE)
.items.getById(itemId)
.select(...SCALAR_FIELDS, 'NeedsEquipment')()) as Record<string, unknown>;
const data: IFormData = { ...EMPTY_DATA };
const writable = data as unknown as Record<string, string>;
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;
}
@@ -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 = <K extends keyof IFormData>(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: ''
};
@@ -0,0 +1,20 @@
import { loadHireRequest } from '../RecruitingHire/load';
import { IFormData, EMPTY_DATA } from './model';
/**
* Предзаполнить форму «орг. рабочего места» из «Заявки на прием» (снимок на момент создания).
* Копируются согласованные поля; остальное остаётся пустым для ручного заполнения.
*/
export async function loadPrefillFromHire(hireId: number): Promise<IFormData> {
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
};
}
@@ -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" }
]
}
@@ -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<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Сотрудник" icon="people">
<FormField label="Ф.И.О." htmlFor="FullName" required full>
<TextInput id="FullName" value={data.FullName} onChange={(v) => set('FullName', v)} />
</FormField>
<FormField label="Фамилия и имя (латиница)" htmlFor="NameLatin" full>
<TextInput id="NameLatin" value={data.NameLatin} onChange={(v) => set('NameLatin', v)} />
</FormField>
<FormField label="Телефон" htmlFor="Phone">
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
</FormField>
<FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Отдел / участок" htmlFor="Department">
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
</FormField>
<FormField label="Вид работ" htmlFor="WorkKind">
<TextInput id="WorkKind" value={data.WorkKind} onChange={(v) => set('WorkKind', v)} />
</FormField>
<FormField label="Местоположение" htmlFor="Location">
<TextInput id="Location" value={data.Location} onChange={(v) => set('Location', v)} />
</FormField>
<FormField label="Дата приёма" htmlFor="StartDate">
<TextInput id="StartDate" type="date" value={data.StartDate} onChange={(v) => set('StartDate', v)} />
</FormField>
</CollapseSection>
);
@@ -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<ISectionProps> = ({ data, set }) => (
<CollapseSection title="Оборудование и доступы" icon="monitor">
<FormField label="Нужна ли техника">
<Toggle checked={data.NeedsEquipment} onChange={(v) => set('NeedsEquipment', v)} onLabel="Да" />
</FormField>
<FormField label="Необходимое ПО" htmlFor="Software" full>
<TextArea id="Software" value={data.Software} onChange={(v) => set('Software', v)} />
</FormField>
<FormField label="Доступы" htmlFor="AccessRights" full>
<TextArea id="AccessRights" value={data.AccessRights} onChange={(v) => set('AccessRights', v)} />
</FormField>
</CollapseSection>
);
@@ -0,0 +1,57 @@
import { getSP } from '../../shared/pnp/pnpjs';
import { ApprovalListService, IApproverToWrite, writeApprovers } from '../../shared/approval';
import { IFormData } from './model';
export const DATA_LIST_TITLE = 'Организация рабочего места — данные';
export const APPROVAL_LIST_TITLE = 'Организация рабочего места — согласование';
// Тип заявки во внешнем approval-route API (GET /api/request-types → id 7).
export const WORKPLACE_REQUEST_TYPE_ID = 7;
/** Экземпляр сервиса списка согласования этой формы. */
export function workplaceApprovalService(): ApprovalListService {
return new ApprovalListService(APPROVAL_LIST_TITLE);
}
/** Осмысленный заголовок элемента (системная колонка Title). */
function buildTitle(data: IFormData): string {
return ['Раб. место', data.FullName, data.Position].filter(Boolean).join(' — ');
}
/**
* Записать контент заявки. sourceHireId — id исходной «Заявки на прием»
* (записывается в lookup-колонку SourceHire для прослеживаемости). Возвращает Id.
*/
async function writeContent(data: IFormData, status: string, sourceHireId?: number): Promise<number> {
const item: Record<string, unknown> = { Title: buildTitle(data), Status: status };
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
const value = data[key];
if (typeof value === 'boolean') {
item[key] = value;
} else {
item[key] = value === '' ? null : value;
}
});
if (sourceHireId) {
item.SourceHireId = sourceHireId; // lookup → «Прием на работу — данные»
}
const added = await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.add(item);
return (added as { Id: number }).Id;
}
/**
* Сохранить заявку «орг. рабочего места»: контент + маршрут согласующих.
* sourceHireId — если заявка создана на основе «прием».
*/
export async function saveWorkplaceRequest(
data: IFormData,
approvers: IApproverToWrite[],
status: string,
sourceHireId?: number
): Promise<number> {
const id = await writeContent(data, status, sourceHireId);
await writeApprovers(workplaceApprovalService(), id, approvers, status);
return id;
}
@@ -18,6 +18,9 @@ const formRegistry: Record<string, React.LazyExoticComponent<FormComponent>> = {
'recruiting-hire': React.lazy(
() =>
import(/* webpackChunkName: 'form-recruiting-hire' */ './RecruitingHire/RecruitingHireForm')
),
'it-workplace-setup': React.lazy(
() => import(/* webpackChunkName: 'form-workplace-setup' */ './WorkplaceSetup/WorkplaceSetupForm')
)
};
@@ -15,6 +15,9 @@ type ViewComponent = React.ComponentType<IApprovalViewProps>;
const viewRegistry: Record<string, React.LazyExoticComponent<ViewComponent>> = {
'recruiting-hire': React.lazy(
() => import(/* webpackChunkName: 'view-recruiting-hire' */ './RecruitingHire/RecruitingHireView')
),
'it-workplace-setup': React.lazy(
() => import(/* webpackChunkName: 'view-workplace-setup' */ './WorkplaceSetup/WorkplaceSetupView')
)
};
@@ -32,4 +32,9 @@ export interface IRequestFormProps {
onCancel: () => void;
/** Необязательное уведомление родителя после успешного сохранения. */
onSaved?: () => void;
/**
* Id исходной заявки для предзаполнения (напр. «орг. места» на основе «прием»).
* Формы, не поддерживающие производные заявки, игнорируют.
*/
sourceId?: number;
}
@@ -2,6 +2,7 @@
// ---- Общий инпут (пикер) ----
.input {
box-sizing: border-box; // иначе width:100% + padding вылезает за границу
width: 100%;
padding: 11px 13px;
border: 1px solid $gray-300;
@@ -69,6 +70,7 @@
// ---- Матрица согласования ----
.matrixWrap {
box-sizing: border-box;
margin-top: $sp-5;
padding: $sp-4;
border: 1px solid $gray-200;
+12 -4
View File
@@ -9,14 +9,16 @@
// (напр. ?id= он трактует как навигацию к элементу → «Элемент не существует»),
// поэтому используем собственные, неконфликтные имена параметров.
export type PortalMode = 'submit' | 'approve';
export type PortalMode = 'submit' | 'approve' | 'create';
export interface IRoute {
mode: PortalMode;
/** Ключ формы (тип заявки) — только для mode='approve'. */
/** Ключ формы (тип заявки) — для 'approve' и 'create'. */
formKey?: string;
/** Id заявки в списке данных — только для mode='approve'. */
/** Id заявки в списке данных — для 'approve'. */
itemId?: number;
/** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */
sourceId?: number;
}
/** Разобрать строку запроса (по умолчанию — из адреса страницы) в маршрут. */
@@ -26,10 +28,16 @@ export function parseRoute(search: string = window.location.search): IRoute {
const formKey = params.get('rptype') ?? undefined;
const idRaw = params.get('rpid');
const itemId = idRaw ? Number(idRaw) : undefined;
const sourceRaw = params.get('rpsource');
const sourceId = sourceRaw ? Number(sourceRaw) : undefined;
// Режим согласующего валиден только при полном наборе параметров.
// Режим согласующего — при полном наборе параметров.
if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) {
return { mode: 'approve', formKey, itemId };
}
// Создание производной заявки с предзаполнением из исходной.
if (formKey && sourceId && !isNaN(sourceId)) {
return { mode: 'create', formKey, sourceId };
}
return { mode: 'submit' };
}