From f775a9fdda8645747249995b8587d90cd6fe9cc6 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 09:42:11 +0500 Subject: [PATCH 1/2] =?UTF-8?q?=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D1=8F=D1=8E=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/config/package-solution.json | 4 ++ .../screens/ManageRequestsPhaseTable.tsx | 21 ++++++- .../screens/ManageRequestsScreen.tsx | 27 +++++++++ .../components/ui/Toast.module.scss | 6 ++ .../approvalAdmin/components/ui/Toast.tsx | 20 ++++--- .../approvalAdmin/models/requestManage.ts | 2 + .../services/requestManageService.ts | 55 ++++++++++++++++--- frontend/src/webparts/approvalAdmin/texts.ts | 4 ++ 8 files changed, 122 insertions(+), 17 deletions(-) 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: 'Решение уже принято — недоступно для правки.', -- 2.34.1 From 89b7b4ae5861173e3d6988efb3da90d36071b653 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 10 Aug 2026 09:50:03 +0500 Subject: [PATCH 2/2] =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=BB=D1=8C=D0=B7=D1=8F=20=D0=B4=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/ManageRequestsApproverModal.tsx | 14 +++++++++++++- .../screens/ManageRequestsPhaseTable.tsx | 9 ++++++++- .../components/screens/ManageRequestsScreen.tsx | 13 +++++++++++++ frontend/src/webparts/approvalAdmin/texts.ts | 1 + 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx index 93e14dc..36947ce 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx @@ -10,6 +10,8 @@ export interface IManageRequestsApproverModalProps { initialEmail?: string; employees: Employee[]; employeesLoading: boolean; + /** Email'ы (в любом регистре), уже занятые другими шагами той же фазы — этот человек не может встать в фазу второй раз. */ + excludedEmails: string[]; onSave: (email: string, position: string) => Promise; onClose: () => void; } @@ -27,18 +29,28 @@ export const ManageRequestsApproverModal: React.FC { const [email, setEmail] = React.useState(initialEmail); const submit = useAsyncSubmit(); + // Тот, кто уже занят в этой фазе, исключён из списка выбора — дубликат + // физически нельзя выбрать (см. excludedEmails в ManageRequestsScreen). + const excludedSet = new Set(excludedEmails.map(e => e.toLowerCase())); + const selectableEmployees = employees.filter(e => !excludedSet.has(e.email.toLowerCase())); + const handleSubmit = (): void => { const trimmedEmail = email.trim(); if (!trimmedEmail) { submit.fail(texts.manageRequests.validationApprover); return; } + if (excludedSet.has(trimmedEmail.toLowerCase())) { + submit.fail(texts.manageRequests.validationDuplicateApprover); + return; + } const position = findEmployeeByEmail(employees, trimmedEmail)?.position || ''; submit.run(() => onSave(trimmedEmail, position)); }; @@ -48,7 +60,7 @@ export const ManageRequestsApproverModal: React.FC // добавлять туда исполнителя всё равно бессмысленно). const phaseClosed = requestRejected || (rows.length > 0 && rows.every(r => !isEditableRowStatus(r.status))); + // Один и тот же человек не может стоять в фазе дважды (в т.ч. призрачной + // строкой, которая ещё не подтверждена сервером, — см. handleAddApprover в + // ManageRequestsScreen) — исключаем уже занятых из пикера, а не проверяем + // постфактум: так дубликат физически нельзя выбрать. + const usedEmails = new Set(rows.map(r => r.approverEmail.toLowerCase()).filter(Boolean)); + const addableEmployees = employees.filter(e => !usedEmails.has(e.email.toLowerCase())); + const applyReorder = (mutate: (rows: Row[]) => Row[]): void => { const renumbered = withOrdersFrom(mutate(withParallel), 0); const updates = diffOrders(rows, renumbered); @@ -322,7 +329,7 @@ export const ManageRequestsPhaseTable: React.FC {!readOnly && !phaseClosed && (
= ({ api const openReassign = (row: ManagedApprovalRow): void => setModal(row); const closeModal = (): void => setModal(undefined); + // Кого нельзя выбрать при переназначении — все остальные согласующие/ + // исполнители ТОЙ ЖЕ фазы (сама строка modal исключена, иначе нельзя было + // бы оставить текущего человека на месте). Один человек не может стоять в + // фазе дважды — та же гарантия, что и addableEmployees в + // ManageRequestsPhaseTable для добавления нового шага. + const reassignExcludedEmails = matrix && modal + ? matrix.rows + .filter(r => r.phase === modal.phase && r.id !== modal.id) + .map(r => r.approverEmail.toLowerCase()) + .filter(Boolean) + : []; + // Переназначение — тоже оптимистично: patch конкретной строки сразу по ответу // сервера, без похода за всей матрицей заново. approverName берём из уже // загруженного справочника сотрудников (тот же источник, что подставляет @@ -572,6 +584,7 @@ export const ManageRequestsScreen: React.FC = ({ api initialEmail={modal.approverEmail} employees={employees.items} employeesLoading={employees.isLoading} + excludedEmails={reassignExcludedEmails} onSave={saveModal} onClose={closeModal} /> diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index ebee780..7c22ef6 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -321,6 +321,7 @@ export const texts = { approverLabel: 'Сотрудник', approverPlaceholder: 'Выберите сотрудника', validationApprover: 'Выберите сотрудника.', + validationDuplicateApprover: 'Этот сотрудник уже стоит в этой фазе на другом шаге.', deleteTitle: 'Удалить шаг?', deleteText: (position: string): string => `Шаг «${position || 'без должности'}» будет удалён безвозвратно.`, orderFloorHint: (order: number): string => -- 2.34.1