feat: added notifications workflow

This commit is contained in:
Тимур Городецкий
2026-08-07 10:35:28 +05:00
parent 40f9ac6c03
commit d2e137dfad
23 changed files with 490 additions and 56 deletions
@@ -1,11 +1,12 @@
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,
FormCard,
FormModeProvider,
FileDropzone,
AttachmentList,
CollapseSection,
FormField,
Icon,
@@ -18,24 +19,66 @@ import {
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
notifyActiveStep,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite
} from '../../shared/approval';
import { uploadAttachments } from '../../shared/attachments/attachments.service';
import {
uploadAttachments,
getAttachments,
deleteAttachment,
IAttachmentInfo
} 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 {
saveHireRequest,
hireApprovalService,
HIRE_REQUEST_TYPE_ID,
DATA_LIST_TITLE,
APPROVAL_LIST_TITLE
} from './submit';
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 [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
const [requestId, setRequestId] = useState<number | undefined>(undefined);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
// Режим правки: грузим существующую заявку (id/статус → сохранение как update) и
// её уже прикреплённые вложения.
useEffect(() => {
if (!editId) {
return;
}
let alive = true;
Promise.all([loadHireRequest(editId), getAttachments(DATA_LIST_TITLE, editId)])
.then(([loaded, atts]) => {
if (alive) {
setData(loaded.data);
setRequestId(editId);
setRequestStatus(loaded.status);
setExistingAttachments(atts);
}
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [editId]);
const submit = useRequestSubmit();
const readOnly = !isEditableStatus(requestStatus);
@@ -70,6 +113,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
const handleRemoveAttachment = (fileName: string): void => {
if (requestId === undefined) {
return;
}
deleteAttachment(DATA_LIST_TITLE, requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined);
};
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const id = await saveHireRequest(
requestId,
@@ -83,10 +136,20 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
if (files.length > 0) {
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(DATA_LIST_TITLE, id)); // показать добавленные
}
if (onSaved) {
onSaved();
}
// Отправка на согласование — уведомляем первого активного согласующего
// (поток оповещения). Черновик не уведомляет. Ошибки внутри проглатываются.
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: HIRE_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE,
});
}
};
const handleSaveDraft = (): Promise<void> =>
@@ -131,7 +194,12 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
{!readOnly && (
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
{existingAttachments.length > 0 && (
<FormField label="Прикреплённые файлы" full>
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} />
</FormField>
)}
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
<FileDropzone files={files} onChange={setFiles} />
</FormField>
</CollapseSection>
@@ -9,13 +9,15 @@ import {
StatusBadge,
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 { hireApprovalService, DATA_LIST_TITLE } from './submit';
import { hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit';
import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection';
@@ -119,6 +121,17 @@ 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={() =>
@@ -174,6 +187,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
// Очередь сдвинулась — уведомляем следующий активный шаг.
notifyActiveStep(context, approval, itemId, {
requestTypeId: HIRE_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE,
}).catch(() => undefined);
}}
/>
</div>
@@ -1,6 +1,7 @@
{
"dataListTitle": "Прием на работу — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
@@ -61,6 +61,8 @@ export async function saveHireRequest(
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Уникальный код заявки: пока = id (id известен только после создания).
await list.items.getById(id).update({ RequestUniqueCode: String(id) });
}
await writeRoute(hireApprovalService(), id, approvers, executors, status);
@@ -1,6 +1,7 @@
{
"dataListTitle": "Заявка на подбор — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
{ "internalName": "IsRegistered", "title": "Зарегистрирована", "type": "Boolean" },
@@ -54,5 +54,8 @@ export async function submitRecruitingSelection(data: IFormData): Promise<void>
item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids;
}
await sp.web.lists.getByTitle(LIST_TITLE).items.add(item);
const added = await sp.web.lists.getByTitle(LIST_TITLE).items.add(item);
const id = (added as { Id: number }).Id;
// Уникальный код заявки: пока = id (id известен только после создания).
await sp.web.lists.getByTitle(LIST_TITLE).items.getById(id).update({ RequestUniqueCode: String(id) });
}
@@ -6,6 +6,7 @@ import {
FormCard,
FormModeProvider,
FileDropzone,
AttachmentList,
CollapseSection,
FormField,
Icon,
@@ -18,26 +19,42 @@ import {
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
notifyActiveStep,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite
} from '../../shared/approval';
import { uploadAttachments } from '../../shared/attachments/attachments.service';
import {
uploadAttachments,
getAttachments,
deleteAttachment,
IAttachmentInfo
} 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
DATA_LIST_TITLE,
APPROVAL_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[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
// Id и статус текущей заявки: заполняются после первого сохранения.
const [requestId, setRequestId] = useState<number | undefined>(undefined);
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
@@ -48,6 +65,27 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Режим правки: грузим существующую заявку и её id/статус (сохранение → update).
useEffect(() => {
if (!editId) {
return;
}
let alive = true;
Promise.all([loadWorkplaceRequest(editId), getAttachments(DATA_LIST_TITLE, editId)])
.then(([loaded, atts]) => {
if (alive) {
setData(loaded.data);
setRequestId(editId);
setRequestStatus(loaded.status);
setExistingAttachments(atts);
}
})
.catch(() => undefined);
return () => {
alive = false;
};
}, [editId]);
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
useEffect(() => {
if (!sourceId) {
@@ -94,6 +132,16 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign }));
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
const handleRemoveAttachment = (fileName: string): void => {
if (requestId === undefined) {
return;
}
deleteAttachment(DATA_LIST_TITLE, requestId, fileName)
.then(() => setExistingAttachments((prev) => prev.filter((a) => a.fileName !== fileName)))
.catch(() => undefined);
};
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const id = await saveWorkplaceRequest(
requestId,
@@ -108,10 +156,19 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
if (files.length > 0) {
await uploadAttachments(DATA_LIST_TITLE, id, files);
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(DATA_LIST_TITLE, id)); // показать добавленные
}
if (onSaved) {
onSaved();
}
// Отправка на согласование — уведомляем первый активный шаг (поток оповещения).
if (status === REQUEST_STATUS.onApproval) {
await notifyActiveStep(context, approval, id, {
requestTypeId: WORKPLACE_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE,
});
}
};
const handleSaveDraft = (): Promise<void> =>
@@ -154,7 +211,12 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
{!readOnly && (
<CollapseSection title="Вложения" icon="upload">
<FormField label="Файлы" full>
{existingAttachments.length > 0 && (
<FormField label="Прикреплённые файлы" full>
<AttachmentList items={existingAttachments} onRemove={handleRemoveAttachment} />
</FormField>
)}
<FormField label={existingAttachments.length > 0 ? 'Добавить файлы' : 'Файлы'} full>
<FileDropzone files={files} onChange={setFiles} />
</FormField>
</CollapseSection>
@@ -10,20 +10,30 @@ import {
CollapseSection,
FormField,
AttachmentList,
PrintButton
PrintButton,
Icon
} from '../../shared/ui';
import { navigate } from '../../shared/routing';
import {
ApproverService,
DecisionPanel,
StatusBadge,
ApprovalMatrix,
useMatrixEditor,
notifyActiveStep,
isEditableStatus,
APPROVAL_PHASE
} from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { IFormData, EMPTY_DATA, SetField } from './model';
import { loadWorkplaceRequest } from './load';
import { workplaceApprovalService, updateReviewFields, DATA_LIST_TITLE } from './submit';
import {
workplaceApprovalService,
updateReviewFields,
WORKPLACE_REQUEST_TYPE_ID,
DATA_LIST_TITLE,
APPROVAL_LIST_TITLE
} from './submit';
import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection';
@@ -162,6 +172,17 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
{isEditableStatus(status) && (
<Button
variant="secondary"
onClick={() =>
navigate({ mode: 'edit', nav: 'new', formKey: 'it-workplace-setup', itemId })
}
>
<Icon name="edit" size="sm" />
Редактировать
</Button>
)}
<PrintButton />
</div>
@@ -219,6 +240,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
onDecided={(newStatus) => {
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
// Очередь сдвинулась — уведомляем следующий активный шаг.
notifyActiveStep(context, approval, itemId, {
requestTypeId: WORKPLACE_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE,
}).catch(() => undefined);
}}
/>
</div>
@@ -1,6 +1,7 @@
{
"dataListTitle": "Организация рабочего места — данные",
"columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "FullName", "title": "Ф.И.О.", "type": "Text", "required": true },
@@ -75,6 +75,8 @@ export async function saveWorkplaceRequest(
} else {
const added = await list.items.add(item);
id = (added as { Id: number }).Id;
// Уникальный код заявки: пока = id (id известен только после создания).
await list.items.getById(id).update({ RequestUniqueCode: String(id) });
}
await writeRoute(workplaceApprovalService(), id, approvers, executors, status);