--no-edit

This commit is contained in:
Тимур Городецкий
2026-08-07 10:59:43 +05:00
24 changed files with 960 additions and 267 deletions
@@ -22,7 +22,9 @@ import {
notifyActiveStep,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite
IApproverToWrite,
APPROVAL_PHASE,
findUnassignedStep
} from '../../shared/approval';
import {
uploadAttachments,
@@ -32,7 +34,6 @@ import {
} from '../../shared/attachments/attachments.service';
import { IRequestFormProps } from '../../models/types';
import { IFormData, EMPTY_DATA, SetField } from './model';
import { loadHireRequest } from './load';
import {
saveHireRequest,
hireApprovalService,
@@ -40,26 +41,28 @@ import {
DATA_LIST_TITLE,
APPROVAL_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,
editId
}) => {
const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, editId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
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();
// Режим правки: грузим существующую заявку (id/статус → сохранение как update) и
// её уже прикреплённые вложения.
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Открыта СВОЯ заявка на редактирование — подгружаем её содержимое, статус и
// уже прикреплённые вложения.
useEffect(() => {
if (!editId) {
return;
@@ -69,41 +72,59 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
.then(([loaded, atts]) => {
if (alive) {
setData(loaded.data);
setRequestId(editId);
setRequestStatus(loaded.status);
setExistingAttachments(atts);
}
})
.catch(() => undefined);
.catch((e) => {
if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`);
}
});
return () => {
alive = false;
};
}, [editId]);
const submit = useRequestSubmit();
const readOnly = !isEditableStatus(requestStatus);
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Маршрут согласования (планируемый) резолвится по 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;
@@ -111,7 +132,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
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 handleRemoveAttachment = (fileName: string): void => {
@@ -141,8 +169,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
if (onSaved) {
onSaved();
}
// Отправка на согласование — уведомляем первого активного согласующего
// (поток оповещения). Черновик не уведомляет. Ошибки внутри проглатываются.
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: HIRE_REQUEST_TYPE_ID,
@@ -152,10 +179,28 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
}
};
// На отправку (в отличие от черновика) маршрут должен быть полностью
// укомплектован — иначе шаг без назначенного человека молча пропадёт при
// записи в 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 };
@@ -10,13 +10,13 @@ import {
ApprovalMatrix,
useMatrixEditor,
notifyActiveStep,
isEditableStatus,
APPROVAL_PHASE
} from '../../shared/approval';
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, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit';
import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection';
@@ -121,22 +121,15 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
{isEditableStatus(status) && (
<Button
variant="secondary"
onClick={() =>
navigate({ mode: 'edit', nav: 'new', formKey: 'recruiting-hire', itemId })
}
>
<Icon name="edit" size="sm" />
Редактировать
</Button>
)}
<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" />
Организация рабочего места на основе этой
@@ -22,7 +22,9 @@ import {
notifyActiveStep,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite
IApproverToWrite,
APPROVAL_PHASE,
findUnassignedStep
} from '../../shared/approval';
import {
uploadAttachments,
@@ -44,19 +46,13 @@ import { loadWorkplaceRequest } from './load';
import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection';
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
requestType,
context,
onCancel,
onSaved,
sourceId,
editId
}) => {
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context, onCancel, onSaved, sourceId, editId }) => {
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
// 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();
@@ -65,7 +61,9 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Режим правки: грузим существующую заявку и её id/статус (сохранение → update).
// Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и
// уже прикреплённые вложения. От предзаполнения «на основе» (ниже) отличается
// тем, что это та же запись: сохранение уйдёт в неё, а не создаст новую.
useEffect(() => {
if (!editId) {
return;
@@ -75,12 +73,15 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
.then(([loaded, atts]) => {
if (alive) {
setData(loaded.data);
setRequestId(editId);
setRequestStatus(loaded.status);
setExistingAttachments(atts);
}
})
.catch(() => undefined);
.catch((e) => {
if (alive) {
submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`);
}
});
return () => {
alive = false;
};
@@ -110,19 +111,42 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
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;
@@ -130,7 +154,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
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 handleRemoveAttachment = (fileName: string): void => {
@@ -171,10 +202,28 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
}
};
// На отправку (в отличие от черновика) маршрут должен быть полностью
// укомплектован — иначе шаг без назначенного человека молча пропадёт при
// записи в 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 };