diff --git a/frontend/config/package-solution.json b/frontend/config/package-solution.json index 201f77c..091c0cc 100644 --- a/frontend/config/package-solution.json +++ b/frontend/config/package-solution.json @@ -37,6 +37,10 @@ { "resource": "api://9232d9e2-9807-4753-808f-07250e5474b0", "scope": "user_impersonation" + }, + { + "resource": "Microsoft Flow Service", + "scope": "Flows.Read.All" } ] }, diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx index d343a09..02ad2ce 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import tableStyles from '../ui/DataTable.module.scss'; import styles from './ManageRequestsScreen.module.scss'; -import { Employee, ManagedApprovalRow, isEditableRowStatus, isMovableRowStatus } from '../../models'; +import { Employee, ManagedApprovalRow, REQUEST_STATUS, isEditableRowStatus, isMovableRowStatus } from '../../models'; import { texts } from '../../texts'; import { toEmployeeOption } from '../../utils/employee'; import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks'; @@ -27,6 +27,9 @@ export interface IManageRequestsPhaseTableProps { statusVariant: (status: string) => BadgeVariant; onReassign: (row: ManagedApprovalRow) => void; onRemove: (row: ManagedApprovalRow) => void; + onNotify: (row: ManagedApprovalRow) => void; + /** Id строки, для которой сейчас в процессе отправка уведомления — кнопка на ней отключена. */ + notifyingRowId?: number; employees: Employee[]; employeesLoading: boolean; /** Плейсхолдер плашки-пикера под таблицей — разный для согласования/исполнения. */ @@ -77,6 +80,8 @@ export const ManageRequestsPhaseTable: React.FC statusVariant, onReassign, onRemove, + onNotify, + notifyingRowId, employees, employeesLoading, addPlaceholder, @@ -186,6 +191,13 @@ export const ManageRequestsPhaseTable: React.FC const isBlockStart = block.start === i; const movable = blockIndex > lastLockedBlockIndex; const rowEditable = isEditableRowStatus(r.status); + // Уведомлять можно только активный шаг (флоу шлёт письмо тому, + // чей RowID мы передали, независимо от статуса — сам он не + // проверяет очередь). «Ждёт очереди» — шаг ещё не наступил, + // слать по нему нечего: согласующий/исполнитель пока не + // должен действовать (см. request-portal/notify.service.ts, + // где по той же причине уведомляется только активный шаг). + const notifiable = r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution; // Переключение параллельности — тоже перестановка (меняет order // относительно соседней строки), поэтому смотрим на isMovableRowStatus, // а не на rowEditable: у «Возвращена на доработку» кнопка @@ -261,6 +273,13 @@ export const ManageRequestsPhaseTable: React.FC disabled={!canToggle} onClick={() => toggleParallel(i)} /> + onNotify(r)} + /> = ({ api const [modal, setModal] = React.useState(); const [deleteRow, setDeleteRow] = React.useState(); const [saveError, setSaveError] = React.useState(); + const [notifyResult, setNotifyResult] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>(); + const [notifyingRowId, setNotifyingRowId] = React.useState(); // Отрицательные Id — «призрачные» строки: добавлены на клиенте, ответ сервера // (и настоящий Id из SharePoint) ещё не долетел. Реальные Id списка всегда // положительные, поэтому пересечься эти диапазоны не могут. @@ -459,6 +461,19 @@ export const ManageRequestsScreen: React.FC = ({ api }); }; + // Отправка уведомления — по явному клику (в отличие от request-portal, где + // это происходит автоматически после решения), поэтому здесь показываем + // пользователю результат, а не глушим ошибку молча. + const handleNotify = (row: ManagedApprovalRow): void => { + if (!matrix || !requestType) return; + setNotifyingRowId(row.id); + requestManage + .notifyRow(requestType.id, matrix, row.id) + .then(() => setNotifyResult({ message: texts.manageRequests.notifySuccess, kind: 'ok' })) + .catch((e: Error) => setNotifyResult({ message: reportError(e, 'handleNotify: notifyRow'), kind: 'err' })) + .finally(() => setNotifyingRowId(undefined)); + }; + return (
@@ -522,6 +537,8 @@ export const ManageRequestsScreen: React.FC = ({ api statusVariant={statusVariant} onReassign={openReassign} onRemove={askDelete} + onNotify={handleNotify} + notifyingRowId={notifyingRowId} employees={employees.items} employeesLoading={employees.isLoading} addPlaceholder={texts.manageRequests.addApproverPlaceholder} @@ -536,6 +553,8 @@ export const ManageRequestsScreen: React.FC = ({ api statusVariant={statusVariant} onReassign={openReassign} onRemove={askDelete} + onNotify={handleNotify} + notifyingRowId={notifyingRowId} employees={employees.items} employeesLoading={employees.isLoading} addPlaceholder={texts.manageRequests.addExecutorPlaceholder} @@ -571,6 +590,14 @@ export const ManageRequestsScreen: React.FC = ({ api {saveError && ( setSaveError(undefined)} /> )} + + {notifyResult && ( + setNotifyResult(undefined)} + /> + )}
); }; diff --git a/frontend/src/webparts/approvalAdmin/components/ui/Toast.module.scss b/frontend/src/webparts/approvalAdmin/components/ui/Toast.module.scss index 3a600e3..c431b47 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/Toast.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/ui/Toast.module.scss @@ -23,6 +23,12 @@ animation: toastIn .15s ease-out; } +.ok { + background: $green-50; + color: $green-600; + border-color: #BBF7D0; +} + .message { flex: 1; } diff --git a/frontend/src/webparts/approvalAdmin/components/ui/Toast.tsx b/frontend/src/webparts/approvalAdmin/components/ui/Toast.tsx index 3d9db79..d78f635 100644 --- a/frontend/src/webparts/approvalAdmin/components/ui/Toast.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ui/Toast.tsx @@ -4,28 +4,30 @@ import { Icon } from './Icon'; export interface IToastProps { message: string; + /** 'err' (по умолчанию) — красный, для ошибок; 'ok' — зелёный, для успешных действий (см. handleNotify в ManageRequestsScreen). */ + kind?: 'err' | 'ok'; onClose: () => void; } const AUTO_HIDE_MS = 6000; /** - * Всплывающее сообщение об ошибке в углу экрана — для действий, которые не - * ломают уже отрисованный экран (переназначение/перестановка/добавление шага): - * пользователь и так видит, что изменение не применилось (см. reloadMatrix - * после ошибки в ManageRequestsScreen), поэтому постоянный Alert-блок сверху - * страницы был бы избыточен. Автоматически прячется через AUTO_HIDE_MS, либо - * закрывается вручную. + * Всплывающее сообщение в углу экрана — для действий, которые не ломают уже + * отрисованный экран (переназначение/перестановка/добавление шага/отправка + * уведомления): пользователь и так видит, что изменение не применилось (см. + * reloadMatrix после ошибки в ManageRequestsScreen), поэтому постоянный + * Alert-блок сверху страницы был бы избыточен. Автоматически прячется через + * AUTO_HIDE_MS, либо закрывается вручную. */ -export const Toast: React.FC = ({ message, onClose }) => { +export const Toast: React.FC = ({ message, kind = 'err', onClose }) => { React.useEffect(() => { const timer = window.setTimeout(onClose, AUTO_HIDE_MS); return () => window.clearTimeout(timer); }, [message, onClose]); return ( -
- +
+ {message}
diff --git a/frontend/src/webparts/approvalAdmin/models/requestManage.ts b/frontend/src/webparts/approvalAdmin/models/requestManage.ts index 7b73348..5cba3dd 100644 --- a/frontend/src/webparts/approvalAdmin/models/requestManage.ts +++ b/frontend/src/webparts/approvalAdmin/models/requestManage.ts @@ -107,6 +107,8 @@ export interface ManagedRequestMatrix { request: ManagedRequestSummary; /** Абсолютный URL сайта, где лежат списки данных/согласования этого типа заявки. */ siteUrl: string; + /** Title списка «… — данные» (для тела запроса к потоку оповещения, см. RequestManageService.notifyRow). */ + dataListTitle: string; /** Title списка «… — согласование» (вычислен из Title списка данных). */ approvalListTitle: string; /** Обе фазы вместе, упорядочены по StepOrder. */ diff --git a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts index 44d8bf6..12b2fd1 100644 --- a/frontend/src/webparts/approvalAdmin/services/requestManageService.ts +++ b/frontend/src/webparts/approvalAdmin/services/requestManageService.ts @@ -4,6 +4,7 @@ import '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; import '@pnp/sp/search'; +import { AadHttpClient } from '@microsoft/sp-http'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { APPROVAL_PHASE, @@ -20,6 +21,15 @@ import { // списки SharePoint, только с другого клиента). const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; +// Тот же поток оповещения согласующего/исполнителя, что и в request-portal +// (src/webparts/requestPortal/shared/config.ts) — один и тот же список +// согласования пишут оба веб-парта, поток сам находит по RowID согласующего, +// статус и заявку и уведомляет письмом. Авторизация — AadHttpClient с +// ресурсом Flow Service (обычный fetch → 401 из-за Entra-триггера). +const FLOW_RESOURCE = 'https://service.flow.microsoft.com/'; +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'; + // Внутренние имена колонок списка согласования — 1:1 с FIELDS в // request-portal/src/webparts/requestPortal/shared/approval/approvalList.service.ts. // Это тот же самый список SharePoint, поэтому имена колонок должны совпадать @@ -102,10 +112,12 @@ export class RequestManageService { // Title списка данных → Title списка согласования. undefined, если Title не // оканчивается на «— данные» (список заведён не по конвенции request-portal, // управлять маршрутом для него отсюда нельзя). - private async approvalListTitle(sp: SPFI, listId: string): Promise { + private async listTitles(sp: SPFI, listId: string): Promise<{ dataListTitle: string; approvalListTitle: string | undefined }> { const info = (await sp.web.getList(listId).select('Title')()) as { Title: string }; - if (!DATA_LIST_SUFFIX.test(info.Title)) return undefined; - return `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`; + const approvalListTitle = DATA_LIST_SUFFIX.test(info.Title) + ? `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование` + : undefined; + return { dataListTitle: info.Title, approvalListTitle }; } /** @@ -168,16 +180,16 @@ export class RequestManageService { const sp = this.clientFor(requestType.siteUrl); const dataList = sp.web.getList(requestType.listId); - const [requestRow, approvalListTitle] = await Promise.all([ + const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([ dataList.items .getById(requestId) .select('Id', 'Title', 'Status', 'Created', 'Author/Title') .expand('Author')() as Promise, - this.approvalListTitle(sp, requestType.listId) + this.listTitles(sp, requestType.listId) ]); if (!approvalListTitle) { - return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, approvalListTitle: '', rows: [] }; + return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, approvalListTitle: '', rows: [] }; } const approvalList = sp.web.lists.getByTitle(approvalListTitle); @@ -217,7 +229,7 @@ export class RequestManageService { }; }); - return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, approvalListTitle, rows }; + return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, approvalListTitle, rows }; } /** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */ @@ -305,4 +317,33 @@ export class RequestManageService { }) ); } + + /** + * Отправить уведомление согласующему/исполнителю конкретной строки маршрута — + * тот же поток Power Automate, что и request-portal (см. FLOW_NOTIFY_URL + * выше): поток сам находит по RowID согласующего, статус и заявку и шлёт + * письмо. В отличие от request-portal (там вызывается автоматически после + * решения), здесь — по явному клику на кнопку в таблице маршрута, поэтому + * ошибку НЕ глушим — вызывающая сторона показывает её пользователю. + */ + public async notifyRow( + requestTypeId: number, + matrix: Pick, + rowId: number + ): Promise { + const client = await this.context.aadHttpClientFactory.getClient(FLOW_RESOURCE); + const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + RequestTypeId: requestTypeId, + RowID: rowId, + RequestID: matrix.request.id, + ListName: matrix.dataListTitle, + ApproveListName: matrix.approvalListTitle + }) + }); + if (!res.ok) { + throw new Error(`Поток оповещения: ${res.status} ${res.statusText}`); + } + } } diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 8419b67..ebee780 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -302,6 +302,10 @@ export const texts = { emptyMatrix: 'У этой заявки маршрут ещё не записан.', reassign: 'Переназначить', remove: 'Удалить шаг', + notify: 'Отправить уведомление', + notifyNotActive: 'Уведомить можно только текущий активный шаг (не «Ждёт очереди»)', + notifySuccess: 'Уведомление отправлено', + notifyError: 'Не удалось отправить уведомление', addApproverPlaceholder: 'Добавить согласующего: начните вводить ФИО…', addExecutorPlaceholder: 'Добавить исполнителя: начните вводить ФИО…', lockedHint: 'Решение уже принято — недоступно для правки.',