2 Commits

Author SHA1 Message Date
Тимур Городецкий 3a15d83ded --no-edit 2026-08-07 10:59:43 +05:00
Тимур Городецкий d2e137dfad feat: added notifications workflow 2026-08-07 10:35:28 +05:00
21 changed files with 451 additions and 116 deletions
+4
View File
@@ -29,6 +29,10 @@
{ {
"resource": "9232d9e2-9807-4753-808f-07250e5474b0", "resource": "9232d9e2-9807-4753-808f-07250e5474b0",
"scope": "Reports.Read" "scope": "Reports.Read"
},
{
"resource": "Microsoft Flow Service",
"scope": "Flows.Read.All"
} }
], ],
"features": [ "features": [
@@ -75,7 +75,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
// Форма при этом пересоздаётся (см. key у SubmitFeature), поэтому прежние // Форма при этом пересоздаётся (см. key у SubmitFeature), поэтому прежние
// несохранённые правки к ней уже не относятся — снимаем флаг. // несохранённые правки к ней уже не относятся — снимаем флаг.
React.useEffect(() => { React.useEffect(() => {
if (route.mode === 'create' && route.formKey) { if ((route.mode === 'create' || route.mode === 'edit') && route.formKey) {
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0]; const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
if (found) setActiveCategoryId(found.categoryId); if (found) setActiveCategoryId(found.categoryId);
markFormClean(); markFormClean();
@@ -17,15 +17,23 @@ export interface ISubmitFeatureProps {
activeCategoryId: string; activeCategoryId: string;
/** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */ /** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */
initialForm?: { formKey: string; sourceId: number }; initialForm?: { formKey: string; sourceId: number };
/** Открыть сразу форму на РЕДАКТИРОВАНИЕ существующей заявки (черновик/возвращена). */
editForm?: { formKey: string; itemId: number };
} }
// Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal). // Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal).
// Владелец: автор портала. Живёт целиком в features/submit/**. // Владелец: автор портала. Живёт целиком в features/submit/**.
const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryId, initialForm }) => { const SubmitFeature: React.FC<ISubmitFeatureProps> = ({
// Если пришли по ссылке «создать на основе» — сразу открываем нужную форму. context,
activeCategoryId,
initialForm,
editForm,
}) => {
// «Создать на основе» и «редактировать» оба сразу открывают нужную форму, минуя плитки.
const preOpen = initialForm ?? editForm;
const initialSelected = useMemo( const initialSelected = useMemo(
() => (initialForm ? REQUEST_TYPES.filter((r) => r.id === initialForm.formKey)[0] ?? null : null), () => (preOpen ? REQUEST_TYPES.filter((r) => r.id === preOpen.formKey)[0] ?? null : null),
[initialForm] [preOpen]
); );
const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(initialSelected); const [selectedRequest, setSelectedRequest] = useState<IRequestType | null>(initialSelected);
@@ -70,9 +78,10 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryI
); );
} }
// sourceId — только для формы, открытой «на основе» исходной заявки. // sourceId — «на основе» исходной заявки; editId — правка существующей.
const sourceId = const sourceId =
initialForm && request.id === initialForm.formKey ? initialForm.sourceId : undefined; initialForm && request.id === initialForm.formKey ? initialForm.sourceId : undefined;
const editId = editForm && request.id === editForm.formKey ? editForm.itemId : undefined;
// Ленивый рендер формы: её код подтягивается только сейчас, при открытии. // Ленивый рендер формы: её код подтягивается только сейчас, при открытии.
return ( return (
@@ -83,6 +92,7 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryI
onCancel={handleBack} onCancel={handleBack}
onSaved={emitRequestsChanged} onSaved={emitRequestsChanged}
sourceId={sourceId} sourceId={sourceId}
editId={editId}
/> />
</React.Suspense> </React.Suspense>
); );
@@ -6,6 +6,7 @@ import {
FormCard, FormCard,
FormModeProvider, FormModeProvider,
FileDropzone, FileDropzone,
AttachmentList,
CollapseSection, CollapseSection,
FormField, FormField,
Icon, Icon,
@@ -18,16 +19,28 @@ import {
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
notifyActiveStep,
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE, APPROVAL_PHASE,
findUnassignedStep findUnassignedStep
} from '../../shared/approval'; } 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 { IRequestFormProps } from '../../models/types';
import { IFormData, EMPTY_DATA, SetField } from './model'; import { IFormData, EMPTY_DATA, SetField } from './model';
import { saveHireRequest, hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE } from './submit'; import {
saveHireRequest,
hireApprovalService,
HIRE_REQUEST_TYPE_ID,
DATA_LIST_TITLE,
APPROVAL_LIST_TITLE
} from './submit';
import { loadHireRequest } from './load'; import { loadHireRequest } from './load';
import { ApplicationSection } from './sections/ApplicationSection'; import { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection'; import { CandidateSection } from './sections/CandidateSection';
@@ -37,6 +50,7 @@ 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 [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
// При открытии на редактирование (editId) — сразу известен, иначе появится // При открытии на редактирование (editId) — сразу известен, иначе появится
// после первого сохранения. // после первого сохранения.
const [requestId, setRequestId] = useState<number | undefined>(editId); const [requestId, setRequestId] = useState<number | undefined>(editId);
@@ -47,17 +61,19 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Открыта СВОЯ заявка на редактирование — подгружаем её содержимое и статус. // Открыта СВОЯ заявка на редактирование — подгружаем её содержимое, статус и
// уже прикреплённые вложения.
useEffect(() => { useEffect(() => {
if (!editId) { if (!editId) {
return; return;
} }
let alive = true; let alive = true;
loadHireRequest(editId) Promise.all([loadHireRequest(editId), getAttachments(DATA_LIST_TITLE, editId)])
.then((loaded) => { .then(([loaded, atts]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setExistingAttachments(atts);
} }
}) })
.catch((e) => { .catch((e) => {
@@ -125,6 +141,16 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
order: r.order, order: r.order,
})); }));
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
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 save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const id = await saveHireRequest( const id = await saveHireRequest(
requestId, requestId,
@@ -138,10 +164,19 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
if (files.length > 0) { if (files.length > 0) {
await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента await uploadAttachments(DATA_LIST_TITLE, id, files); // файлы — после создания элемента
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(DATA_LIST_TITLE, id)); // показать добавленные
} }
if (onSaved) { if (onSaved) {
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,
});
}
}; };
// На отправку (в отличие от черновика) маршрут должен быть полностью // На отправку (в отличие от черновика) маршрут должен быть полностью
@@ -204,7 +239,12 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({ requestType, context,
{!readOnly && ( {!readOnly && (
<CollapseSection title="Вложения" icon="upload"> <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} /> <FileDropzone files={files} onChange={setFiles} />
</FormField> </FormField>
</CollapseSection> </CollapseSection>
@@ -9,6 +9,7 @@ import {
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
useMatrixEditor, useMatrixEditor,
notifyActiveStep,
APPROVAL_PHASE APPROVAL_PHASE
} from '../../shared/approval'; } from '../../shared/approval';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
@@ -16,7 +17,7 @@ import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attach
import { IFormData, EMPTY_DATA, SetField } from './model'; import { IFormData, EMPTY_DATA, SetField } from './model';
import { loadHireRequest } from './load'; import { loadHireRequest } from './load';
import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; import { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
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 { ApplicationSection } from './sections/ApplicationSection';
import { CandidateSection } from './sections/CandidateSection'; import { CandidateSection } from './sections/CandidateSection';
import { PpeSection } from './sections/PpeSection'; import { PpeSection } from './sections/PpeSection';
@@ -179,6 +180,12 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
onDecided={(newStatus) => { onDecided={(newStatus) => {
setStatus(newStatus); setStatus(newStatus);
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
// Очередь сдвинулась — уведомляем следующий активный шаг.
notifyActiveStep(context, approval, itemId, {
requestTypeId: HIRE_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE,
}).catch(() => undefined);
}} }}
/> />
</div> </div>
@@ -1,6 +1,7 @@
{ {
"dataListTitle": "Прием на работу — данные", "dataListTitle": "Прием на работу — данные",
"columns": [ "columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "Phone", "title": "Телефон", "type": "Text" }, { "internalName": "Phone", "title": "Телефон", "type": "Text" },
@@ -61,6 +61,8 @@ export async function saveHireRequest(
} else { } else {
const added = await list.items.add(item); const added = await list.items.add(item);
id = (added as { Id: number }).Id; id = (added as { Id: number }).Id;
// Уникальный код заявки: пока = id (id известен только после создания).
await list.items.getById(id).update({ RequestUniqueCode: String(id) });
} }
await writeRoute(hireApprovalService(), id, approvers, executors, status); await writeRoute(hireApprovalService(), id, approvers, executors, status);
@@ -1,6 +1,7 @@
{ {
"dataListTitle": "Заявка на подбор — данные", "dataListTitle": "Заявка на подбор — данные",
"columns": [ "columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, { "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true }, { "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
{ "internalName": "IsRegistered", "title": "Зарегистрирована", "type": "Boolean" }, { "internalName": "IsRegistered", "title": "Зарегистрирована", "type": "Boolean" },
@@ -54,5 +54,8 @@ export async function submitRecruitingSelection(data: IFormData): Promise<void>
item[`${MULTI_PERSON_FIELDS[i]}Id`] = ids; 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, FormCard,
FormModeProvider, FormModeProvider,
FileDropzone, FileDropzone,
AttachmentList,
CollapseSection, CollapseSection,
FormField, FormField,
Icon, Icon,
@@ -18,20 +19,27 @@ import {
StatusBadge, StatusBadge,
useMatrixEditor, useMatrixEditor,
getPlannedMatrix, getPlannedMatrix,
notifyActiveStep,
REQUEST_STATUS, REQUEST_STATUS,
isEditableStatus, isEditableStatus,
IApproverToWrite, IApproverToWrite,
APPROVAL_PHASE, APPROVAL_PHASE,
findUnassignedStep findUnassignedStep
} from '../../shared/approval'; } 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 { IRequestFormProps } from '../../models/types';
import { IFormData, EMPTY_DATA, SetField } from './model'; import { IFormData, EMPTY_DATA, SetField } from './model';
import { import {
saveWorkplaceRequest, saveWorkplaceRequest,
workplaceApprovalService, workplaceApprovalService,
WORKPLACE_REQUEST_TYPE_ID, WORKPLACE_REQUEST_TYPE_ID,
DATA_LIST_TITLE DATA_LIST_TITLE,
APPROVAL_LIST_TITLE
} from './submit'; } from './submit';
import { loadPrefillFromHire } from './prefill'; import { loadPrefillFromHire } from './prefill';
import { loadWorkplaceRequest } from './load'; import { loadWorkplaceRequest } from './load';
@@ -41,6 +49,7 @@ 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 [data, setData] = useState<IFormData>(EMPTY_DATA);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [existingAttachments, setExistingAttachments] = useState<IAttachmentInfo[]>([]);
// Id и статус текущей заявки: заполняются после первого сохранения, а при // Id и статус текущей заявки: заполняются после первого сохранения, а при
// открытии на редактирование (editId) — сразу из загруженной записи. // открытии на редактирование (editId) — сразу из загруженной записи.
const [requestId, setRequestId] = useState<number | undefined>(editId); const [requestId, setRequestId] = useState<number | undefined>(editId);
@@ -52,19 +61,20 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
// Открыта СВОЯ заявка на редактирование — подгружаем её содержимое и статус. // Открыта СВОЯ заявка на редактирование — подгружаем содержимое, статус и
// От предзаполнения «на основе» (ниже) отличается тем, что это та же запись: // уже прикреплённые вложения. От предзаполнения «на основе» (ниже) отличается
// сохранение уйдёт в неё, а не создаст новую. // тем, что это та же запись: сохранение уйдёт в неё, а не создаст новую.
useEffect(() => { useEffect(() => {
if (!editId) { if (!editId) {
return; return;
} }
let alive = true; let alive = true;
loadWorkplaceRequest(editId) Promise.all([loadWorkplaceRequest(editId), getAttachments(DATA_LIST_TITLE, editId)])
.then((loaded) => { .then(([loaded, atts]) => {
if (alive) { if (alive) {
setData(loaded.data); setData(loaded.data);
setRequestStatus(loaded.status); setRequestStatus(loaded.status);
setExistingAttachments(atts);
} }
}) })
.catch((e) => { .catch((e) => {
@@ -153,6 +163,16 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
order: r.order, order: r.order,
})); }));
// Удаление уже прикреплённого файла (в правке) — сразу из заявки.
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 save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
const id = await saveWorkplaceRequest( const id = await saveWorkplaceRequest(
requestId, requestId,
@@ -167,10 +187,19 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
if (files.length > 0) { if (files.length > 0) {
await uploadAttachments(DATA_LIST_TITLE, id, files); await uploadAttachments(DATA_LIST_TITLE, id, files);
setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало setFiles([]); // выгруженные файлы убираем, чтобы пересохранение не дублировало
setExistingAttachments(await getAttachments(DATA_LIST_TITLE, id)); // показать добавленные
} }
if (onSaved) { if (onSaved) {
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,
});
}
}; };
// На отправку (в отличие от черновика) маршрут должен быть полностью // На отправку (в отличие от черновика) маршрут должен быть полностью
@@ -231,7 +260,12 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ requestType, context,
{!readOnly && ( {!readOnly && (
<CollapseSection title="Вложения" icon="upload"> <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} /> <FileDropzone files={files} onChange={setFiles} />
</FormField> </FormField>
</CollapseSection> </CollapseSection>
@@ -10,20 +10,30 @@ import {
CollapseSection, CollapseSection,
FormField, FormField,
AttachmentList, AttachmentList,
PrintButton PrintButton,
Icon
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
StatusBadge, StatusBadge,
ApprovalMatrix, ApprovalMatrix,
useMatrixEditor, useMatrixEditor,
notifyActiveStep,
isEditableStatus,
APPROVAL_PHASE APPROVAL_PHASE
} from '../../shared/approval'; } from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { IFormData, EMPTY_DATA, SetField } from './model'; import { IFormData, EMPTY_DATA, SetField } from './model';
import { loadWorkplaceRequest } from './load'; 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 { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection'; import { EquipmentSection } from './sections/EquipmentSection';
@@ -162,6 +172,17 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <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 /> <PrintButton />
</div> </div>
@@ -219,6 +240,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
onDecided={(newStatus) => { onDecided={(newStatus) => {
setStatus(newStatus); setStatus(newStatus);
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
// Очередь сдвинулась — уведомляем следующий активный шаг.
notifyActiveStep(context, approval, itemId, {
requestTypeId: WORKPLACE_REQUEST_TYPE_ID,
dataListName: DATA_LIST_TITLE,
approvalListName: APPROVAL_LIST_TITLE,
}).catch(() => undefined);
}} }}
/> />
</div> </div>
@@ -1,6 +1,7 @@
{ {
"dataListTitle": "Организация рабочего места — данные", "dataListTitle": "Организация рабочего места — данные",
"columns": [ "columns": [
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
{ "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "FullName", "title": "Ф.И.О.", "type": "Text", "required": true }, { "internalName": "FullName", "title": "Ф.И.О.", "type": "Text", "required": true },
@@ -75,6 +75,8 @@ export async function saveWorkplaceRequest(
} else { } else {
const added = await list.items.add(item); const added = await list.items.add(item);
id = (added as { Id: number }).Id; id = (added as { Id: number }).Id;
// Уникальный код заявки: пока = id (id известен только после создания).
await list.items.getById(id).update({ RequestUniqueCode: String(id) });
} }
await writeRoute(workplaceApprovalService(), id, approvers, executors, status); await writeRoute(workplaceApprovalService(), id, approvers, executors, status);
@@ -24,6 +24,8 @@ export type { IApproverToWrite } from './approvalList.service';
export { ApproverService } from './approverDecision.service'; export { ApproverService } from './approverDecision.service';
export type { ApproverDecision } from './approverDecision.service'; export type { ApproverDecision } from './approverDecision.service';
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service'; export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
export { notifyActiveStep } from './notify.service';
export type { INotifyTarget } from './notify.service';
export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor'; export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor';
export type { IUseMatrixEditor } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor';
export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { ApprovalMatrix } from './ui/ApprovalMatrix';
@@ -0,0 +1,58 @@
import { AadHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { FLOW_RESOURCE, FLOW_NOTIFY_URL } from '../config';
import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS } from './models/statuses';
// Данные формы для тела запроса к потоку (постоянны для формы).
export interface INotifyTarget {
requestTypeId: number; // RequestTypeId (id типа заявки во внешнем API маршрутов)
dataListName: string; // ListName (список данных заявки)
approvalListName: string; // ApproveListName (список согласования)
}
// POST в поток оповещения (Entra-триггер) через AadHttpClient — обычный fetch → 401,
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS).
async function postNotify(context: WebPartContext, body: Record<string, unknown>): Promise<void> {
if (!FLOW_NOTIFY_URL) {
return;
}
const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Поток оповещения: ${res.status} ${res.statusText}`);
}
}
// Находит АКТИВНЫЙ шаг маршрута (На согласовании / На исполнении) и уведомляет его.
// Вызывать после отправки заявки и после каждого положительного решения. Поток по
// переданным полям сам находит согласующего/исполнителя и шлёт уведомление.
// Уведомление некритично — ошибки глотаем, чтобы не ронять основной сценарий.
export async function notifyActiveStep(
context: WebPartContext,
approval: ApprovalListService,
requestId: number,
target: INotifyTarget
): Promise<void> {
try {
const rows = await approval.getForRequest(requestId);
const active = rows.filter(
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
)[0];
if (!active) {
return;
}
await postNotify(context, {
RequestTypeId: target.requestTypeId,
RowID: active.id,
RequestID: requestId,
ListName: target.dataListName,
ApproveListName: target.approvalListName,
});
} catch {
// не критично — просто не отправили уведомление
}
}
@@ -40,12 +40,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
}); });
// Пустые колонки не показываем — столбец из одних прочерков только шумит. // Пустые колонки не показываем — столбец из одних прочерков только шумит.
// Условия РАЗНЫЕ, потому что заполняются эти поля в разные моменты: статус
// есть у строки сразу (у черновика — «Черновик»), а дата появляется только
// вместе с решением согласующего. У черновика поэтому виден статус, но не дата.
const hasStatuses = rows.some((r) => r.status && r.status !== '—'); const hasStatuses = rows.some((r) => r.status && r.status !== '—');
const hasDates = rows.some((r) => !!r.date); const hasDates = rows.some((r) => !!r.date);
// Число колонок (для colSpan) — на случай будущих нужд не используется: комментарий
// выводим внутри ячейки согласующего, чтобы не ломать rowSpan колонки «Шаг».
return ( return (
<div className={styles.matrixWrap}> <div className={styles.matrixWrap}>
<div className={styles.matrixTitle}>{title}</div> <div className={styles.matrixTitle}>{title}</div>
@@ -55,6 +55,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<div className={styles.matrixNote}>{emptyText}</div> <div className={styles.matrixNote}>{emptyText}</div>
)} )}
{!loading && !error && rows.length > 0 && ( {!loading && !error && rows.length > 0 && (
<div className={styles.matrixScroll}>
<table className={styles.matrix}> <table className={styles.matrix}>
<thead> <thead>
<tr> <tr>
@@ -70,9 +71,6 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<tbody> <tbody>
{rows.map((r, i) => { {rows.map((r, i) => {
const block = blockByRow[i]; const block = blockByRow[i];
// Ячейка «Шаг» рисуется один раз на весь шаг и растягивается на его
// строки. Кнопки перемещения живут в ней же: двигать параллельные
// строки поодиночке бессмысленно — шаг переезжает целиком.
const isBlockStart = block.start === i; const isBlockStart = block.start === i;
return ( return (
<tr key={i} className={block.size > 1 ? styles.parallelRow : undefined}> <tr key={i} className={block.size > 1 ? styles.parallelRow : undefined}>
@@ -81,16 +79,25 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<span className={styles.stepNo}>{r.order}</span> <span className={styles.stepNo}>{r.order}</span>
</td> </td>
)} )}
<td>{r.who}</td> <td data-label={roleLabel}>
<td>{r.position}</td> {r.who}
{hasStatuses && ( {/* Комментарий решения — мелким текстом под именем (не отдельной
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td> строкой: иначе ломается rowSpan колонки «Шаг» у параллельных). */}
{r.comment && (
<span className={styles.commentInline}>
<span className={styles.commentLabel}>Комментарий:</span> {r.comment}
</span>
)} )}
{hasDates && <td>{r.date}</td>} </td>
<td data-label="Должность">{r.position}</td>
{hasStatuses && (
<td data-label="Статус">
{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}
</td>
)}
{hasDates && <td data-label="Дата">{r.date}</td>}
{/* Перемещение действует на ШАГ целиком (параллельные строки {/* Перемещение действует на ШАГ целиком (параллельные строки
переезжают вместе), поэтому стрелки живут в отдельной переезжают вместе): одна пара стрелок на весь шаг. */}
объединённой по вертикали ячейке: одна пара на весь шаг,
по центру его строк. */}
{editable && isBlockStart && ( {editable && isBlockStart && (
<td className={styles.matrixMoveCol} rowSpan={block.size}> <td className={styles.matrixMoveCol} rowSpan={block.size}>
<button <button
@@ -143,6 +150,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
})} })}
</tbody> </tbody>
</table> </table>
</div>
)} )}
{editable && !loading && ( {editable && !loading && (
<div className={styles.matrixPicker}> <div className={styles.matrixPicker}>
@@ -91,8 +91,16 @@
color: $gray-500; color: $gray-500;
} }
// Обёртка таблицы: на промежуточных ширинах — горизонтальный скролл вместо
// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже).
.matrixScroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.matrix { .matrix {
width: 100%; width: 100%;
min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает
border-collapse: collapse; border-collapse: collapse;
font-size: $fs-sm; font-size: $fs-sm;
@@ -113,6 +121,22 @@
} }
} }
// Комментарий решения — мелким текстом под именем согласующего (в той же ячейке,
// а не отдельной строкой: иначе ломается rowSpan колонки «Шаг» у параллельных).
.commentInline {
display: block;
margin-top: 3px;
font-size: $fs-caption;
color: $gray-500;
line-height: 1.45;
white-space: pre-wrap; // сохранить переносы строк из комментария
}
.commentLabel {
color: $gray-400;
font-weight: 500;
}
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она // ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую // перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap. // строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
@@ -220,3 +244,51 @@
.matrixPicker { .matrixPicker {
margin-top: $sp-3; margin-top: $sp-3;
} }
// ---- Мобильные экраны: таблица маршрута → карточки ----
// Скрываем шапку, каждая строка — блок, ячейки в столбик с подписью поля
// (из data-label). Так маршрут читается на телефоне без горизонтального скролла.
@media (max-width: 640px) {
.matrix {
min-width: 0; // на телефоне не растягиваем — раскладываем в столбик
}
.matrix thead {
display: none;
}
.matrix tr {
display: block;
border-bottom: 1px solid $gray-200;
padding: 10px 0;
}
.matrix td {
display: flex;
flex-wrap: wrap; // чтобы комментарий (100%) переносился на свою строку
align-items: center;
gap: $sp-2;
border: 0 !important;
padding: 3px 0 !important;
}
// Подпись поля перед значением (только у ячеек с data-label).
.matrix td[data-label]::before {
content: attr(data-label);
flex: 0 0 44%;
color: $gray-500;
font-size: $fs-caption;
font-weight: 500;
}
// Комментарий под именем — на всю ширину ячейки (своя строка).
.commentInline {
flex-basis: 100%;
}
.matrixActions,
.matrixMoveCol {
justify-content: flex-start;
padding-top: $sp-2 !important;
}
}
@@ -45,3 +45,16 @@ export async function uploadAttachments(
await item.attachmentFiles.add(file.name, content); await item.attachmentFiles.add(file.name, content);
} }
} }
/** Удалить вложение элемента по имени файла (для правки заявки). */
export async function deleteAttachment(
listTitle: string,
itemId: number,
fileName: string
): Promise<void> {
await getSP()
.web.lists.getByTitle(listTitle)
.items.getById(itemId)
.attachmentFiles.getByName(fileName)
.delete();
}
@@ -17,3 +17,17 @@ export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests';
*/ */
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0'; export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route'; export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
/**
* Power Automate: поток оповещения согласующего/исполнителя.
* Триггер «When an HTTP request is received» защищён Entra («любой пользователь
* тенанта»), поэтому URL без подписи (`sig`), а вызываем через AadHttpClient с
* ресурсом Flow Service (обычный fetch → 401; так же решается CORS). Разрешение
* «Microsoft Flow Service / Flows.Read.All» объявлено в package-solution.json и
* одобряется в Центре администрирования SharePoint (API access) при развёртывании.
* Шлём тело `{ RowID: <id строки списка согласования> }` — поток сам находит по
* строке согласующего, статус и заявку и уведомляет. (Паттерн из kps-ct-form.)
*/
export const FLOW_RESOURCE = 'https://service.flow.microsoft.com/';
export const FLOW_NOTIFY_URL =
'https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/07/workflows/b21516edf8a14287a65a39e4116dfe4f/triggers/manual/paths/invoke?api-version=1';
@@ -21,6 +21,8 @@
} }
.link { .link {
flex: 1;
min-width: 0;
color: $navy-700; color: $navy-700;
text-decoration: none; text-decoration: none;
@@ -30,6 +32,27 @@
} }
} }
.removeBtn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $gray-500;
cursor: pointer;
transition: .16s;
&:hover {
border-color: $red-500;
color: $red-500;
}
}
.empty { .empty {
color: $gray-500; color: $gray-500;
font-size: $fs-sm; font-size: $fs-sm;
@@ -5,10 +5,12 @@ import { IAttachmentInfo } from '../../attachments/attachments.service';
interface IAttachmentListProps { interface IAttachmentListProps {
items: IAttachmentInfo[]; items: IAttachmentInfo[];
/** Если задан — рядом с файлом показывается кнопка удаления (режим правки). */
onRemove?: (fileName: string) => void;
} }
/** Список вложений заявки как ссылки на скачивание (режим чтения/согласующего). */ /** Список вложений заявки как ссылки на скачивание; с onRemove — ещё и удаление. */
export const AttachmentList: React.FC<IAttachmentListProps> = ({ items }) => { export const AttachmentList: React.FC<IAttachmentListProps> = ({ items, onRemove }) => {
if (items.length === 0) { if (items.length === 0) {
return <p className={styles.empty}>Вложений нет.</p>; return <p className={styles.empty}>Вложений нет.</p>;
} }
@@ -21,6 +23,17 @@ export const AttachmentList: React.FC<IAttachmentListProps> = ({ items }) => {
<a href={attachment.url} target="_blank" rel="noreferrer" className={styles.link}> <a href={attachment.url} target="_blank" rel="noreferrer" className={styles.link}>
{attachment.fileName} {attachment.fileName}
</a> </a>
{onRemove && (
<button
type="button"
className={styles.removeBtn}
title="Удалить вложение"
aria-label={`Удалить ${attachment.fileName}`}
onClick={() => onRemove(attachment.fileName)}
>
<Icon name="trash" size="sm" />
</button>
)}
</li> </li>
))} ))}
</ul> </ul>