Параллельные шаги (#3)

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-06 12:36:22 +00:00
parent 40f9ac6c03
commit df9d12622d
24 changed files with 967 additions and 175 deletions
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './RecruitingHireForm.module.scss';
import {
Alert,
@@ -20,21 +20,26 @@ import {
getPlannedMatrix,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite
IApproverToWrite,
APPROVAL_PHASE,
findUnassignedStep
} from '../../shared/approval';
import { uploadAttachments } from '../../shared/attachments/attachments.service';
import { IRequestFormProps } from '../../models/types';
import { IFormData, EMPTY_DATA, SetField } from './model';
import { saveHireRequest, hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE } from './submit';
import { loadHireRequest } from './load';
import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection';
import { ConditionsSection } from './sections/ConditionsSection';
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved }) => {
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]);
const [requestId, setRequestId] = useState<number | undefined>(undefined);
// При открытии на редактирование (editId) — сразу известен, иначе появится
// после первого сохранения.
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const submit = useRequestSubmit();
@@ -42,25 +47,68 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Открыта СВОЯ заявка на редактирование — подгружаем её содержимое и статус.
useEffect(() => {
if (!editId) {
return;
}
let alive = true;
loadHireRequest(editId)
.then((loaded) => {
if (alive) {
setData(loaded.data);
setRequestStatus(loaded.status);
}
})
.catch((e) => {
if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`);
}
});
return () => {
alive = false;
};
}, [editId]);
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
// пользователя (кандидата ещё нет в системе).
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
// редактирование, маршрут читается из уже записанных строк — планирование
// затёрло бы правки, сделанные при первом сохранении.
const approval = useMemo(() => hireApprovalService(), []);
const userEmail = context.pageContext.user.email;
const loadMatrix = useCallback(
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
[context, userEmail, approval]
() =>
editId
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
: getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
[context, userEmail, approval, editId]
);
const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) =>
approval.getJobTitle(login)
const matrix = useMatrixEditor(
loadMatrix,
`${userEmail}-${editId ?? 'new'}`,
!readOnly,
true,
(login) => approval.getJobTitle(login)
);
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
const loadExecutors = useCallback(
() => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
[context, userEmail, approval]
() =>
editId
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
: getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
[context, userEmail, approval, editId]
);
const executors = useMatrixEditor(loadExecutors, userEmail, false, true, (login) =>
approval.getJobTitle(login)
const executors = useMatrixEditor(
loadExecutors,
`${userEmail}-${editId ?? 'new'}`,
false,
true,
(login) => approval.getJobTitle(login)
);
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
@@ -68,7 +116,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
isValid ? undefined : 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
// order обязателен: параллельные шаги делят один номер, по позиции в
// массиве его не восстановить (см. useMatrixEditor/withOrders).
rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order,
}));
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const id = await saveHireRequest(
@@ -89,10 +144,28 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
}
};
// На отправку (в отличие от черновика) маршрут должен быть полностью
// укомплектован — иначе шаг без назначенного человека молча пропадёт при
// записи в SharePoint (createSteps не может записать Person-поле без
// значения), и согласующие в середине маршрута просто не увидят заявку.
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
const unassigned = findUnassignedStep(matrix.rows);
if (unassigned) {
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
}
const unassignedExecutor = findUnassignedStep(executors.rows);
if (unassignedExecutor) {
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`;
}
return undefined;
};
const handleSaveDraft = (): Promise<void> =>
submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> =>
submit.run('submit', save(REQUEST_STATUS.onApproval), validate);
submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
const sectionProps = { data, set, context };
@@ -15,6 +15,7 @@ import { navigate } from '../../shared/routing';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { IFormData, EMPTY_DATA, SetField } from './model';
import { loadHireRequest } from './load';
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
import { hireApprovalService, DATA_LIST_TITLE } from './submit';
import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection';
@@ -121,9 +122,13 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<div className={`${styles.toolbar} rp-no-print`}>
<Button
variant="secondary"
onClick={() =>
navigate({ mode: 'create', nav: 'new', formKey: WORKPLACE_FORM_KEY, sourceId: itemId })
}
onClick={() => {
// Раздел «Новая заявка» остаётся смонтированным при переключении
// вкладок, но переход «на основе» пересоздаёт форму — если там
// остались несохранённые правки, спрашиваем перед их потерей.
if (!confirmDiscardFormChanges()) return;
navigate({ mode: 'create', nav: 'new', formKey: WORKPLACE_FORM_KEY, sourceId: itemId });
}}
>
<Icon name="copy" size="sm" />
Организация рабочего места на основе этой
@@ -20,7 +20,9 @@ import {
getPlannedMatrix,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite
IApproverToWrite,
APPROVAL_PHASE,
findUnassignedStep
} from '../../shared/approval';
import { uploadAttachments } from '../../shared/attachments/attachments.service';
import { IRequestFormProps } from '../../models/types';
@@ -32,14 +34,16 @@ import {
DATA_LIST_TITLE
} from './submit';
import { loadPrefillFromHire } from './prefill';
import { loadWorkplaceRequest } from './load';
import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection';
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId }) => {
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId, editId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]);
// Id и статус текущей заявки: заполняются после первого сохранения.
const [requestId, setRequestId] = useState<number | undefined>(undefined);
// Id и статус текущей заявки: заполняются после первого сохранения, а при
// открытии на редактирование (editId) — сразу из загруженной записи.
const [requestId, setRequestId] = useState<number | undefined>(editId);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
const submit = useRequestSubmit();
@@ -48,6 +52,31 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Открыта СВОЯ заявка на редактирование — подгружаем её содержимое и статус.
// От предзаполнения «на основе» (ниже) отличается тем, что это та же запись:
// сохранение уйдёт в неё, а не создаст новую.
useEffect(() => {
if (!editId) {
return;
}
let alive = true;
loadWorkplaceRequest(editId)
.then((loaded) => {
if (alive) {
setData(loaded.data);
setRequestStatus(loaded.status);
}
})
.catch((e) => {
if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`);
}
});
return () => {
alive = false;
};
}, [editId]);
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
useEffect(() => {
if (!sourceId) {
@@ -72,19 +101,42 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
const approval = useMemo(() => workplaceApprovalService(), []);
const userEmail = context.pageContext.user.email;
// У новой заявки маршрут ПЛАНИРУЕТСЯ (resolve по внешнему API), у открытой на
// редактирование — читается из уже записанных строк: их могли поправить
// вручную при первом сохранении, и планирование затёрло бы эти правки.
const loadMatrix = useCallback(
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
[context, userEmail, approval]
() =>
editId
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution))
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'),
[context, userEmail, approval, editId]
);
const matrix = useMatrixEditor(
loadMatrix,
`${userEmail}-${editId ?? 'new'}`,
!readOnly,
true,
(login) => approval.getJobTitle(login)
);
const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) => approval.getJobTitle(login));
// Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения.
const loadExecutors = useCallback(
() => getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
[context, userEmail, approval]
() =>
editId
? approval
.getMatrixForRequest(editId)
.then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution))
: getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'),
[context, userEmail, approval, editId]
);
const executors = useMatrixEditor(loadExecutors, userEmail, false, true, (login) =>
approval.getJobTitle(login)
const executors = useMatrixEditor(
loadExecutors,
`${userEmail}-${editId ?? 'new'}`,
false,
true,
(login) => approval.getJobTitle(login)
);
const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0;
@@ -92,7 +144,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
isValid ? undefined : 'Заполните обязательные поля: Ф.И.О., Должность.';
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
// order обязателен: параллельные шаги делят один номер, по позиции в
// массиве его не восстановить (см. useMatrixEditor/withOrders).
rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order,
}));
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const id = await saveWorkplaceRequest(
@@ -114,10 +173,28 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
}
};
// На отправку (в отличие от черновика) маршрут должен быть полностью
// укомплектован — иначе шаг без назначенного человека молча пропадёт при
// записи в SharePoint (createSteps не может записать Person-поле без
// значения), и согласующие в середине маршрута просто не увидят заявку.
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
const unassigned = findUnassignedStep(matrix.rows);
if (unassigned) {
return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`;
}
const unassignedExecutor = findUnassignedStep(executors.rows);
if (unassignedExecutor) {
return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`;
}
return undefined;
};
const handleSaveDraft = (): Promise<void> =>
submit.run('draft', save(REQUEST_STATUS.draft), validate);
const handleSubmit = (): Promise<void> =>
submit.run('submit', save(REQUEST_STATUS.onApproval), validate);
submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit);
const sectionProps = { data, set, context };