feat: implemented form lifecycle hool and state machine
This commit is contained in:
@@ -1,12 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import styles from './RecruitingHireForm.module.scss';
|
||||
import { Button, Alert, FormCard, FormActions, FileDropzone, CollapseSection, FormField, Icon } from '../../shared/ui';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
FormModeProvider,
|
||||
FileDropzone,
|
||||
CollapseSection,
|
||||
FormField,
|
||||
Icon,
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
useRequestSubmit
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite
|
||||
} from '../../shared/approval';
|
||||
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
||||
@@ -18,21 +31,16 @@ import { CandidateSection } from './sections/CandidateSection';
|
||||
import { PpeSection } from './sections/PpeSection';
|
||||
import { ConditionsSection } from './sections/ConditionsSection';
|
||||
|
||||
type ActionStatus = 'idle' | 'saving' | 'success' | 'error';
|
||||
type ActionKind = 'draft' | 'submit';
|
||||
|
||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [actionStatus, setActionStatus] = useState<ActionStatus>('idle');
|
||||
const [actionKind, setActionKind] = useState<ActionKind | undefined>(undefined);
|
||||
const [status, setStatus] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
const submit = useRequestSubmit();
|
||||
|
||||
const set: SetField = (key, value) => {
|
||||
setData((prev) => ({ ...prev, [key]: value }));
|
||||
setActionStatus('idle');
|
||||
};
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
||||
// пользователя (кандидата ещё нет в системе).
|
||||
@@ -42,7 +50,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
||||
[context, userEmail, approval]
|
||||
);
|
||||
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) =>
|
||||
const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) =>
|
||||
approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
@@ -55,41 +63,36 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
const saving = actionStatus === 'saving';
|
||||
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
||||
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 }));
|
||||
|
||||
async function run(kind: ActionKind, requestStatus: string): Promise<void> {
|
||||
if (!isValid) {
|
||||
setError('Заполните обязательные поля: Ф.И.О. кандидата, Должность.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setActionKind(kind);
|
||||
setActionStatus('saving');
|
||||
try {
|
||||
const id = await saveHireRequest(
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
requestStatus
|
||||
);
|
||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||
const id = await saveHireRequest(
|
||||
requestId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
status
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (files.length > 0) {
|
||||
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
|
||||
setStatus(requestStatus);
|
||||
setActionStatus('success');
|
||||
if (onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
} catch (e) {
|
||||
setActionStatus('error');
|
||||
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||
}
|
||||
}
|
||||
if (onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => run('draft', REQUEST_STATUS.draft);
|
||||
const handleSubmit = (): Promise<void> => run('submit', REQUEST_STATUS.onApproval);
|
||||
const handleSaveDraft = (): Promise<void> =>
|
||||
submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run('submit', save(REQUEST_STATUS.onApproval), validate);
|
||||
|
||||
const sectionProps = { data, set, context };
|
||||
|
||||
@@ -100,32 +103,39 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
|
||||
<FormCard title={requestType.title} subtitle="Категория: Отдел рекрутинга">
|
||||
{status && (
|
||||
<div className={styles.statusRow}>
|
||||
<span className={styles.statusPill}>{status}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionStatus === 'success' && (
|
||||
<FormCard
|
||||
title={requestType.title}
|
||||
subtitle="Категория: Отдел рекрутинга"
|
||||
headerRight={
|
||||
requestId || requestStatus ? (
|
||||
<>
|
||||
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||
{requestId && <RequestNumber id={requestId} />}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{submit.savedKind && !submit.error && (
|
||||
<Alert variant="ok">
|
||||
{actionKind === 'draft'
|
||||
? 'Черновик сохранён.'
|
||||
: 'Заявка отправлена на согласование.'}
|
||||
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <Alert variant="err">{error}</Alert>}
|
||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||
|
||||
<ApplicationSection {...sectionProps} />
|
||||
<CandidateSection {...sectionProps} />
|
||||
<PpeSection {...sectionProps} />
|
||||
<ConditionsSection {...sectionProps} />
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<ApplicationSection {...sectionProps} />
|
||||
<CandidateSection {...sectionProps} />
|
||||
<PpeSection {...sectionProps} />
|
||||
<ConditionsSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<FormField label="Файлы" full>
|
||||
<FileDropzone files={files} onChange={setFiles} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
{!readOnly && (
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<FormField label="Файлы" full>
|
||||
<FileDropzone files={files} onChange={setFiles} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
)}
|
||||
|
||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
@@ -139,19 +149,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<SubmitActions
|
||||
submit={submit}
|
||||
isValid={isValid}
|
||||
readOnly={readOnly}
|
||||
onCancel={onCancel}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './RecruitingHireView.module.scss';
|
||||
import { Alert, FormCard, FormModeProvider, CollapseSection, FormField, AttachmentList } from '../../shared/ui';
|
||||
import { ApproverService, DecisionPanel } from '../../shared/approval';
|
||||
import { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval';
|
||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadHireRequest } from './load';
|
||||
@@ -26,6 +26,7 @@ const NOOP_SET: SetField = () => undefined;
|
||||
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -42,7 +43,8 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
Promise.all([loadHireRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (alive) {
|
||||
setData(loaded);
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setAttachments(files);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -74,7 +76,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormCard title="Заявка на прием" subtitle={`№${itemId} · режим согласования`}>
|
||||
<FormCard
|
||||
title="Заявка на прием"
|
||||
subtitle={`№${itemId} · режим согласования`}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<ApplicationSection {...sectionProps} />
|
||||
<CandidateSection {...sectionProps} />
|
||||
|
||||
@@ -10,13 +10,18 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
|
||||
'SalaryAfterProbation', 'WorkSchedule', 'EmploymentForm'
|
||||
];
|
||||
|
||||
/** Прочитать заявку «на прием» по id и разложить в модель формы (для режима чтения). */
|
||||
export async function loadHireRequest(itemId: number): Promise<IFormData> {
|
||||
export interface ILoadedRequest {
|
||||
data: IFormData;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Прочитать заявку «на прием» по id: модель формы + статус (для режима чтения). */
|
||||
export async function loadHireRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
const sp = getSP();
|
||||
const item = (await sp.web.lists
|
||||
.getByTitle(LIST_TITLE)
|
||||
.items.getById(itemId)
|
||||
.select(...SCALAR_FIELDS)()) as Record<string, unknown>;
|
||||
.select(...SCALAR_FIELDS, 'Status')()) as Record<string, unknown>;
|
||||
|
||||
const data: IFormData = { ...EMPTY_DATA };
|
||||
const writable = data as unknown as Record<string, string>;
|
||||
@@ -32,5 +37,5 @@ export async function loadHireRequest(itemId: number): Promise<IFormData> {
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
return { data, status: (item.Status as string) || '' };
|
||||
}
|
||||
|
||||
@@ -21,11 +21,10 @@ function buildTitle(data: IFormData): string {
|
||||
return ['Прием', data.CandidateName, data.Position].filter(Boolean).join(' — ');
|
||||
}
|
||||
|
||||
/** Записать контент заявки со статусом. Возвращает Id созданного элемента. */
|
||||
async function writeContent(data: IFormData, status: string): Promise<number> {
|
||||
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */
|
||||
function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
||||
const item: Record<string, unknown> = { Title: buildTitle(data), Status: status };
|
||||
|
||||
// Текст/выбор/даты/числа.
|
||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||
const value = data[key];
|
||||
if (NUMBER_FIELDS.indexOf(key) !== -1) {
|
||||
@@ -35,23 +34,35 @@ async function writeContent(data: IFormData, status: string): Promise<number> {
|
||||
}
|
||||
});
|
||||
|
||||
const added = await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.add(item);
|
||||
return (added as { Id: number }).Id;
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить заявку «на прием»: контент + маршрут (согласующие + исполнители) со статусом.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* status = «Черновик» (сохранение) или «На согласовании» (отправка).
|
||||
* executors может быть пустым — тогда после согласования заявка станет «Согласовано».
|
||||
* Возвращает Id заявки.
|
||||
*/
|
||||
export async function saveHireRequest(
|
||||
existingId: number | undefined,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string
|
||||
): Promise<number> {
|
||||
const id = await writeContent(data, status);
|
||||
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
|
||||
const item = buildItem(data, status);
|
||||
|
||||
let id: number;
|
||||
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;
|
||||
}
|
||||
|
||||
await writeRoute(hireApprovalService(), id, approvers, executors, status);
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
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 {
|
||||
Alert,
|
||||
FormCard,
|
||||
FormModeProvider,
|
||||
FileDropzone,
|
||||
CollapseSection,
|
||||
FormField,
|
||||
Icon,
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
useRequestSubmit
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApprovalMatrix,
|
||||
StatusBadge,
|
||||
useMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
IApproverToWrite
|
||||
} from '../../shared/approval';
|
||||
import { uploadAttachments } from '../../shared/attachments/attachments.service';
|
||||
@@ -22,20 +35,18 @@ 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[]>([]);
|
||||
// Id и статус текущей заявки: заполняются после первого сохранения.
|
||||
const [requestId, setRequestId] = useState<number | undefined>(undefined);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
const submit = useRequestSubmit();
|
||||
|
||||
const set: SetField = (key, value) => {
|
||||
setData((prev) => ({ ...prev, [key]: value }));
|
||||
setActionStatus('idle');
|
||||
};
|
||||
// Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение.
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
||||
useEffect(() => {
|
||||
@@ -51,7 +62,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) {
|
||||
setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`);
|
||||
submit.setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -65,7 +76,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
|
||||
[context, userEmail, approval]
|
||||
);
|
||||
const matrix = useMatrixEditor(loadMatrix, userEmail, true, true, (login) => approval.getJobTitle(login));
|
||||
const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) => approval.getJobTitle(login));
|
||||
|
||||
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
|
||||
const loadExecutors = useCallback(
|
||||
@@ -76,41 +87,37 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
const saving = actionStatus === 'saving';
|
||||
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0;
|
||||
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 }));
|
||||
|
||||
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,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
requestStatus,
|
||||
sourceId
|
||||
);
|
||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||
const id = await saveWorkplaceRequest(
|
||||
requestId,
|
||||
data,
|
||||
toApprovers(matrix.rows),
|
||||
toApprovers(executors.rows),
|
||||
status,
|
||||
sourceId
|
||||
);
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (files.length > 0) {
|
||||
await uploadAttachments(DATA_LIST_TITLE, id, files);
|
||||
setActionStatus('success');
|
||||
if (onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
} catch (e) {
|
||||
setActionStatus('error');
|
||||
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
||||
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
|
||||
}
|
||||
}
|
||||
if (onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => run('draft', REQUEST_STATUS.draft);
|
||||
const handleSubmit = (): Promise<void> => run('submit', REQUEST_STATUS.onApproval);
|
||||
const handleSaveDraft = (): Promise<void> =>
|
||||
submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run('submit', save(REQUEST_STATUS.onApproval), validate);
|
||||
|
||||
const sectionProps = { data, set, context };
|
||||
|
||||
@@ -121,22 +128,37 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
|
||||
<FormCard title={requestType.title} subtitle="Категория: IT">
|
||||
{actionStatus === 'success' && (
|
||||
<FormCard
|
||||
title={requestType.title}
|
||||
subtitle="Категория: IT"
|
||||
headerRight={
|
||||
requestId || requestStatus ? (
|
||||
<>
|
||||
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||
{requestId && <RequestNumber id={requestId} />}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{submit.savedKind && !submit.error && (
|
||||
<Alert variant="ok">
|
||||
{actionKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||
</Alert>
|
||||
)}
|
||||
{error && <Alert variant="err">{error}</Alert>}
|
||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<EquipmentSection {...sectionProps} />
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<EquipmentSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<FormField label="Файлы" full>
|
||||
<FileDropzone files={files} onChange={setFiles} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
{!readOnly && (
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<FormField label="Файлы" full>
|
||||
<FileDropzone files={files} onChange={setFiles} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
)}
|
||||
|
||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
@@ -150,19 +172,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<SubmitActions
|
||||
submit={submit}
|
||||
isValid={isValid}
|
||||
readOnly={readOnly}
|
||||
onCancel={onCancel}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { ApproverService, DecisionPanel, StatusBadge } from '../../shared/approval';
|
||||
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadWorkplaceRequest } from './load';
|
||||
@@ -21,6 +21,7 @@ const NOOP_SET: SetField = () => undefined;
|
||||
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId }) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -36,7 +37,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
Promise.all([loadWorkplaceRequest(itemId), getAttachments(DATA_LIST_TITLE, itemId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (alive) {
|
||||
setData(loaded);
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setAttachments(files);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -68,7 +70,11 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormCard title="Организация рабочего места" subtitle={`№${itemId} · режим согласования`}>
|
||||
<FormCard
|
||||
title="Организация рабочего места"
|
||||
subtitle={`№${itemId} · режим согласования`}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<EmployeeSection {...sectionProps} />
|
||||
<EquipmentSection {...sectionProps} />
|
||||
|
||||
@@ -8,12 +8,17 @@ const SCALAR_FIELDS: (keyof IFormData)[] = [
|
||||
'Software', 'AccessRights'
|
||||
];
|
||||
|
||||
/** Прочитать заявку по id и разложить в модель формы (для режима чтения). */
|
||||
export async function loadWorkplaceRequest(itemId: number): Promise<IFormData> {
|
||||
export interface ILoadedRequest {
|
||||
data: IFormData;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Прочитать заявку по id: модель формы + статус (для режима чтения). */
|
||||
export async function loadWorkplaceRequest(itemId: number): Promise<ILoadedRequest> {
|
||||
const item = (await getSP()
|
||||
.web.lists.getByTitle(LIST_TITLE)
|
||||
.items.getById(itemId)
|
||||
.select(...SCALAR_FIELDS, 'NeedsEquipment')()) as Record<string, unknown>;
|
||||
.select(...SCALAR_FIELDS, 'NeedsEquipment', 'Status')()) as Record<string, unknown>;
|
||||
|
||||
const data: IFormData = { ...EMPTY_DATA };
|
||||
const writable = data as unknown as Record<string, string>;
|
||||
@@ -31,5 +36,5 @@ export async function loadWorkplaceRequest(itemId: number): Promise<IFormData> {
|
||||
|
||||
data.NeedsEquipment = item.NeedsEquipment === true;
|
||||
|
||||
return data;
|
||||
return { data, status: (item.Status as string) || '' };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IFormData, EMPTY_DATA } from './model';
|
||||
* Копируются согласованные поля; остальное остаётся пустым для ручного заполнения.
|
||||
*/
|
||||
export async function loadPrefillFromHire(hireId: number): Promise<IFormData> {
|
||||
const hire = await loadHireRequest(hireId);
|
||||
const { data: hire } = await loadHireRequest(hireId);
|
||||
return {
|
||||
...EMPTY_DATA,
|
||||
FullName: hire.CandidateName,
|
||||
|
||||
@@ -17,11 +17,8 @@ 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> {
|
||||
/** Поля элемента списка данных из модели формы. */
|
||||
function buildItem(data: IFormData, status: string, sourceHireId?: number): Record<string, unknown> {
|
||||
const item: Record<string, unknown> = { Title: buildTitle(data), Status: status };
|
||||
|
||||
(Object.keys(data) as (keyof IFormData)[]).forEach((key) => {
|
||||
@@ -37,23 +34,35 @@ async function writeContent(data: IFormData, status: string, sourceHireId?: numb
|
||||
item.SourceHireId = sourceHireId; // lookup → «Прием на работу — данные»
|
||||
}
|
||||
|
||||
const added = await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.add(item);
|
||||
return (added as { Id: number }).Id;
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить заявку «орг. рабочего места»: контент + маршрут (согласующие + исполнители).
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* Исполнители пишутся сразу — фаза исполнения стартует автоматически после согласования.
|
||||
* sourceHireId — если заявка создана на основе «прием».
|
||||
* sourceHireId — если заявка создана на основе «прием». Возвращает Id заявки.
|
||||
*/
|
||||
export async function saveWorkplaceRequest(
|
||||
existingId: number | undefined,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
sourceHireId?: number
|
||||
): Promise<number> {
|
||||
const id = await writeContent(data, status, sourceHireId);
|
||||
const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE);
|
||||
const item = buildItem(data, status, sourceHireId);
|
||||
|
||||
let id: number;
|
||||
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;
|
||||
}
|
||||
|
||||
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
|
||||
return id;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user