Параллельные шаги (#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:
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user