From d2e137dfad8d89b39ad4279244faca21b5f2f5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Fri, 7 Aug 2026 10:35:28 +0500 Subject: [PATCH] feat: added notifications workflow --- config/package-solution.json | 4 + .../components/RequestPortal.tsx | 7 +- .../features/submit/SubmitFeature.tsx | 20 +++-- .../RecruitingHire/RecruitingHireForm.tsx | 78 ++++++++++++++++-- .../RecruitingHire/RecruitingHireView.tsx | 21 ++++- .../forms/RecruitingHire/provisioning.json | 1 + .../forms/RecruitingHire/submit.ts | 2 + .../RecruitingSelection/provisioning.json | 1 + .../forms/RecruitingSelection/submit.ts | 5 +- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 70 +++++++++++++++- .../WorkplaceSetup/WorkplaceSetupView.tsx | 31 ++++++- .../forms/WorkplaceSetup/provisioning.json | 1 + .../forms/WorkplaceSetup/submit.ts | 2 + src/webparts/requestPortal/models/types.ts | 5 ++ .../requestPortal/shared/approval/index.ts | 2 + .../shared/approval/notify.service.ts | 58 +++++++++++++ .../shared/approval/ui/ApprovalMatrix.tsx | 81 +++++++++++-------- .../shared/approval/ui/approval.module.scss | 79 ++++++++++++++++++ .../shared/attachments/attachments.service.ts | 13 +++ src/webparts/requestPortal/shared/config.ts | 14 ++++ src/webparts/requestPortal/shared/routing.ts | 11 ++- .../AttachmentList/AttachmentList.module.scss | 23 ++++++ .../ui/AttachmentList/AttachmentList.tsx | 17 +++- 23 files changed, 490 insertions(+), 56 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/notify.service.ts diff --git a/config/package-solution.json b/config/package-solution.json index 75a0a6e..5c68af6 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -29,6 +29,10 @@ { "resource": "9232d9e2-9807-4753-808f-07250e5474b0", "scope": "Reports.Read" + }, + { + "resource": "Microsoft Flow Service", + "scope": "Flows.Read.All" } ], "features": [ diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 5a7cb66..bb4b1d2 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -69,7 +69,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // Переход «создать на основе» (в т.ч. из открытой заявки в панели): переключаем // вкладку категорий на категорию целевой формы, чтобы она совпала с открытой формой. 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]; if (found) setActiveCategoryId(found.categoryId); } @@ -117,6 +117,11 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context ? { formKey: route.formKey, sourceId: route.sourceId } : undefined } + editForm={ + route.mode === 'edit' && route.formKey && route.itemId + ? { formKey: route.formKey, itemId: route.itemId } + : undefined + } /> ) : route.nav === 'search' ? ( diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx index b7e773c..15c9cab 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx @@ -16,15 +16,23 @@ export interface ISubmitFeatureProps { activeCategoryId: string; /** Открыть сразу форму с предзаполнением (производная заявка), минуя плитки. */ initialForm?: { formKey: string; sourceId: number }; + /** Открыть сразу форму на РЕДАКТИРОВАНИЕ существующей заявки (черновик/возвращена). */ + editForm?: { formKey: string; itemId: number }; } // Фича «Подать заявку»: плитки → беспоук-форма (категории/вкладки — в RequestPortal). // Владелец: автор портала. Живёт целиком в features/submit/**. -const SubmitFeature: React.FC = ({ context, activeCategoryId, initialForm }) => { - // Если пришли по ссылке «создать на основе» — сразу открываем нужную форму. +const SubmitFeature: React.FC = ({ + context, + activeCategoryId, + initialForm, + editForm, +}) => { + // «Создать на основе» и «редактировать» оба сразу открывают нужную форму, минуя плитки. + const preOpen = initialForm ?? editForm; const initialSelected = useMemo( - () => (initialForm ? REQUEST_TYPES.filter((r) => r.id === initialForm.formKey)[0] ?? null : null), - [initialForm] + () => (preOpen ? REQUEST_TYPES.filter((r) => r.id === preOpen.formKey)[0] ?? null : null), + [preOpen] ); const [selectedRequest, setSelectedRequest] = useState(initialSelected); @@ -61,9 +69,10 @@ const SubmitFeature: React.FC = ({ context, activeCategoryI ); } - // sourceId — только для формы, открытой «на основе» исходной заявки. + // sourceId — «на основе» исходной заявки; editId — правка существующей. const sourceId = initialForm && request.id === initialForm.formKey ? initialForm.sourceId : undefined; + const editId = editForm && request.id === editForm.formKey ? editForm.itemId : undefined; // Ленивый рендер формы: её код подтягивается только сейчас, при открытии. return ( @@ -74,6 +83,7 @@ const SubmitFeature: React.FC = ({ context, activeCategoryI onCancel={handleBack} onSaved={emitRequestsChanged} sourceId={sourceId} + editId={editId} /> ); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index ece4b12..55889e1 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -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 = ({ requestType, context, onCancel, onSaved }) => { +const RecruitingHireForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + editId +}) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); + const [existingAttachments, setExistingAttachments] = useState([]); const [requestId, setRequestId] = useState(undefined); const [requestStatus, setRequestStatus] = useState(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 = ({ 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) => async (): Promise => { const id = await saveHireRequest( requestId, @@ -83,10 +136,20 @@ const RecruitingHireForm: React.FC = ({ 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 => @@ -131,7 +194,12 @@ const RecruitingHireForm: React.FC = ({ requestType, context, {!readOnly && ( - + {existingAttachments.length > 0 && ( + + + + )} + 0 ? 'Добавить файлы' : 'Файлы'} full> diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 20c4f63..701ff8f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -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 = ({ context, itemI return (
+ {isEditableStatus(status) && ( + + )}
diff --git a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json index 7f73f96..ec43431 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/provisioning.json @@ -1,6 +1,7 @@ { "dataListTitle": "Прием на работу — данные", "columns": [ + { "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" }, { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "Phone", "title": "Телефон", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 6197257..0014161 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -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); diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json index e203663..22ec23c 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json @@ -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" }, diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index 428a1ce..9e3fb9f 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -54,5 +54,8 @@ export async function submitRecruitingSelection(data: IFormData): Promise 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) }); } diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index da4110d..1f3ea78 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -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 = ({ requestType, context, onCancel, onSaved, sourceId }) => { +const WorkplaceSetupForm: React.FC = ({ + requestType, + context, + onCancel, + onSaved, + sourceId, + editId +}) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); + const [existingAttachments, setExistingAttachments] = useState([]); // Id и статус текущей заявки: заполняются после первого сохранения. const [requestId, setRequestId] = useState(undefined); const [requestStatus, setRequestStatus] = useState(undefined); @@ -48,6 +65,27 @@ const WorkplaceSetupForm: React.FC = ({ 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 = ({ 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) => async (): Promise => { const id = await saveWorkplaceRequest( requestId, @@ -108,10 +156,19 @@ const WorkplaceSetupForm: React.FC = ({ 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 => @@ -154,7 +211,12 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, {!readOnly && ( - + {existingAttachments.length > 0 && ( + + + + )} + 0 ? 'Добавить файлы' : 'Файлы'} full> diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx index c98b430..15ca635 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -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 = ({ context, itemI return (
+ {isEditableStatus(status) && ( + + )}
@@ -219,6 +240,12 @@ const WorkplaceSetupView: React.FC = ({ 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); }} />
diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json index cd7df1b..bdb5846 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json @@ -1,6 +1,7 @@ { "dataListTitle": "Организация рабочего места — данные", "columns": [ + { "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" }, { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "FullName", "title": "Ф.И.О.", "type": "Text", "required": true }, diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts index 4ed48b1..7fcaa26 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts @@ -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); diff --git a/src/webparts/requestPortal/models/types.ts b/src/webparts/requestPortal/models/types.ts index a7750f3..24ad032 100644 --- a/src/webparts/requestPortal/models/types.ts +++ b/src/webparts/requestPortal/models/types.ts @@ -37,4 +37,9 @@ export interface IRequestFormProps { * Формы, не поддерживающие производные заявки, игнорируют. */ sourceId?: number; + /** + * Id существующей заявки для РЕДАКТИРОВАНИЯ (черновик/возвращена/отклонена): + * форма грузит её данные и при сохранении обновляет ту же запись. + */ + editId?: number; } diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index eeee35d..9132938 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -24,6 +24,8 @@ export type { IApproverToWrite } from './approvalList.service'; export { ApproverService } from './approverDecision.service'; export type { ApproverDecision } from './approverDecision.service'; export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service'; +export { notifyActiveStep } from './notify.service'; +export type { INotifyTarget } from './notify.service'; export { useMatrixEditor } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; diff --git a/src/webparts/requestPortal/shared/approval/notify.service.ts b/src/webparts/requestPortal/shared/approval/notify.service.ts new file mode 100644 index 0000000..d97740e --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/notify.service.ts @@ -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): Promise { + 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 { + 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 { + // не критично — просто не отправили уведомление + } +} diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index 1cd1f44..9f9c6f9 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -39,6 +39,7 @@ export const ApprovalMatrix: React.FC = ({
{emptyText}
)} {!loading && !error && rows.length > 0 && ( +
@@ -51,45 +52,57 @@ export const ApprovalMatrix: React.FC = ({ {rows.map((r, i) => ( - - - - - - {editable && ( - + + + + + {editable && ( + + )} + + {r.comment && ( + + + )} - + ))}
{r.who}{r.position}{r.status && r.status !== '—' ? : '—'}{r.date} - - - + +
{r.who}{r.position} + {r.status && r.status !== '—' ? : '—'} {r.date} + + + +
+ Комментарий: {r.comment} +
+
)} {editable && !loading && (
diff --git a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss index 9948c11..ea956fc 100644 --- a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss @@ -91,8 +91,16 @@ color: $gray-500; } +// Обёртка таблицы: на промежуточных ширинах — горизонтальный скролл вместо +// сплющивания колонок. На телефоне таблица становится карточками (медиазапрос ниже). +.matrixScroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + .matrix { width: 100%; + min-width: 480px; // колонки не сжимаются; при нехватке места .matrixScroll прокручивает border-collapse: collapse; font-size: $fs-sm; @@ -113,6 +121,26 @@ } } +// Строка, у которой ниже идёт комментарий: убираем нижнюю границу, чтобы строка +// и её комментарий читались как единый блок (граница переезжает на .commentCell). +.rowNoBorder td { + border-bottom: 0 !important; +} + +.commentCell { + padding: 0 8px 8px !important; + border-bottom: 1px solid $gray-200; + font-size: $fs-caption; + color: $gray-500; + line-height: 1.45; + white-space: pre-wrap; // сохранить переносы строк из комментария +} + +.commentLabel { + color: $gray-400; + font-weight: 500; +} + .matrixActions { display: flex; gap: $sp-1; @@ -158,3 +186,54 @@ .matrixPicker { 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; + } + + // Строка с комментарием: линия — на самом комментарии, не на строке. + .rowNoBorder { + border-bottom: 0 !important; + } + + .matrix td { + display: flex; + 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; + } + + .matrixActions { + justify-content: flex-start; + padding-top: $sp-2 !important; + } + + .commentCell { + display: block; + padding: 2px 0 6px !important; + } +} diff --git a/src/webparts/requestPortal/shared/attachments/attachments.service.ts b/src/webparts/requestPortal/shared/attachments/attachments.service.ts index 044f3fa..98ffa60 100644 --- a/src/webparts/requestPortal/shared/attachments/attachments.service.ts +++ b/src/webparts/requestPortal/shared/attachments/attachments.service.ts @@ -45,3 +45,16 @@ export async function uploadAttachments( await item.attachmentFiles.add(file.name, content); } } + +/** Удалить вложение элемента по имени файла (для правки заявки). */ +export async function deleteAttachment( + listTitle: string, + itemId: number, + fileName: string +): Promise { + await getSP() + .web.lists.getByTitle(listTitle) + .items.getById(itemId) + .attachmentFiles.getByName(fileName) + .delete(); +} diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index 930679e..1bd0980 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -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_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: }` — поток сам находит по + * строке согласующего, статус и заявку и уведомляет. (Паттерн из 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'; diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 09ae6e0..0ad9f05 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -20,7 +20,7 @@ import * as React from 'react'; // перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»), // и на её собственное событие, поэтому реагирует на оба способа смены URL. -export type PortalMode = 'submit' | 'approve' | 'create'; +export type PortalMode = 'submit' | 'approve' | 'create' | 'edit'; /** Раздел бокового меню. */ export type NavSection = 'new' | 'approvals' | 'own' | 'search'; @@ -59,6 +59,11 @@ export function parseRoute(search: string = window.location.search): IRoute { if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { return { mode: 'approve', nav, formKey, itemId }; } + // Редактирование существующей заявки (черновик/возвращена/отклонена) — открывает + // форму на правку. nav='new', т.к. правка живёт в области подачи. + if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { + return { mode: 'edit', nav: 'new', formKey, itemId }; + } // Создание производной заявки с предзаполнением из исходной. if (formKey && sourceId && !isNaN(sourceId)) { return { mode: 'create', nav: 'new', formKey, sourceId }; @@ -82,6 +87,10 @@ function buildQuery(route: IRoute): string { params.set('rpmode', 'approve'); params.set('rptype', route.formKey); params.set('rpid', String(route.itemId)); + } else if (route.mode === 'edit' && route.formKey && route.itemId) { + params.set('rpmode', 'edit'); + params.set('rptype', route.formKey); + params.set('rpid', String(route.itemId)); } else if (route.mode === 'create' && route.formKey && route.sourceId) { params.set('rptype', route.formKey); params.set('rpsource', String(route.sourceId)); diff --git a/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.module.scss b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.module.scss index c5adfd8..79da16f 100644 --- a/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.module.scss +++ b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.module.scss @@ -21,6 +21,8 @@ } .link { + flex: 1; + min-width: 0; color: $navy-700; 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 { color: $gray-500; font-size: $fs-sm; diff --git a/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.tsx b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.tsx index 95ab842..b303e3a 100644 --- a/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.tsx +++ b/src/webparts/requestPortal/shared/ui/AttachmentList/AttachmentList.tsx @@ -5,10 +5,12 @@ import { IAttachmentInfo } from '../../attachments/attachments.service'; interface IAttachmentListProps { items: IAttachmentInfo[]; + /** Если задан — рядом с файлом показывается кнопка удаления (режим правки). */ + onRemove?: (fileName: string) => void; } -/** Список вложений заявки как ссылки на скачивание (режим чтения/согласующего). */ -export const AttachmentList: React.FC = ({ items }) => { +/** Список вложений заявки как ссылки на скачивание; с onRemove — ещё и удаление. */ +export const AttachmentList: React.FC = ({ items, onRemove }) => { if (items.length === 0) { return

Вложений нет.

; } @@ -21,6 +23,17 @@ export const AttachmentList: React.FC = ({ items }) => { {attachment.fileName} + {onRemove && ( + + )} ))}