Files
request-portal/src/webparts/requestPortal/RequestPortalWebPart.ts
T
gorbenko.pavel 5d2abaf430
Tests / frontend-test (push) Successful in 2m1s
Чат заявки: отметки «@» и оповещения через поток (#26)
## Что сделано

**Чат заявки: отметки и оповещения**
- В поле ввода «@» открывает подсказку с участниками заявки: инициатор, согласующие и исполнители из маршрута, а также те, кому открыты «Все заявки» этого типа (список из request-type-access).
- Отметка — обычный текст «@ФИО», без штатного упоминания SharePoint, чтобы не уходило его письмо.
- После отправки сообщения портал вызывает поток Power Automate (`FLOW_CHAT_NOTIFY_URL`): отмеченным — «Вас упомянули», инициатору — «Новое сообщение по вашей заявке». Сбой потока не мешает отправке.
- В письмо добавляется строка «Прикреплены файлы: …», чтобы сообщение из одного файла не приходило пустым.
- Свойство веб-части «Чат: уведомлять о своих сообщениях (для теста)», по умолчанию выключено: письма и о своих сообщениях, можно отметить себя.
- Когда пишет сам инициатор с отметками, в `initiatorEmail` уходит первый отмеченный — поток шлёт инициатору всегда, если его нет среди отмеченных, и иначе он получил бы письмо о своём же сообщении.

**Чат включён для:** Обходной лист, заявка на подбор, заявка на приём, заявка в отдел 1С.

**Исправление:** редактор маршрута может удалять исполнителей в Обходном листе, Командировке и заявке в 1С (не передавалось `canRemoveRow`).

**Прочее:** убраны 4 предупреждения линтера (`no-new-null`, `no-script-url`).

## Не проверено на тенанте
- Ответ потока 200 через AadHttpClient и доставка писем.
- Права согласующих на комментарии и на запись в библиотеку «Файлы чата».
- Папки в «Файлы чата» наследуют права библиотеки, а не закрыты на заявку — для подбора и приёма это важно решить до выкладки.
- Если поток вставляет `messageText` в HTML-письмо, перенос строки перед «Прикреплены файлы» может не отобразиться.

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/26
2026-10-14 10:05:45 +00:00

284 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
type IPropertyPaneConfiguration,
PropertyPaneDropdown,
PropertyPaneTextField,
PropertyPaneToggle
} from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import * as strings from 'RequestPortalWebPartStrings';
import RequestPortal from './components/RequestPortal';
import { IRequestPortalProps } from './components/IRequestPortalProps';
import { initSP } from './shared/pnp/pnpjs';
import { installErrorLogging } from './shared/diagnostics';
import { setImpersonatedIdentity } from './shared/impersonation';
import {
APPROVAL_API_BASE_URL_LOCAL,
APPROVAL_API_BASE_URL_PROD,
EDO_API_BASE_URL_LOCAL,
EDO_API_BASE_URL_PROD,
EDO_API_BASE_URL_STAGE,
setApprovalApiBaseUrl,
setEdoApiBaseUrl,
setNotificationSettings,
setChatSelfNotify
} from './shared/config';
import { initListPartitions } from './shared/approval';
import { ErrorOverlay } from './shared/ui';
import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser';
export interface IRequestPortalWebPartProps {
/**
* Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто.
* Одно поле на оба случая: e-mail, введённый напрямую, хранится как есть;
* ФИО, найденное поиском по имени (см. propertyPane/DebugUserField), после
* резолва заменяется собой же на найденный e-mail — отдельное поле под
* «сырой» ввод не нужно, DebugUserField ведёт его в своём локальном состоянии.
*/
debugUserQuery: string;
debugUserDisplayName: string;
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
approvalApiBaseUrl: string;
/**
* С каким ЭДО работаем при отправке документов на подписание — локальным или
* стейджинговым. У веб-частей, добавленных до появления свойства, оно пустое — это стейдж.
*/
edoApiBaseUrl: string;
/**
* Уведомления согласующим (поток Power Automate). По умолчанию включены:
* у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл».
*/
notificationsEnabled?: boolean;
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
notifyOnlyTo?: string;
/**
* Для проверки чата: письма и о СВОИХ сообщениях, и можно отметить себя через «@».
* По умолчанию выключено (undefined — тоже «выкл»).
*/
chatSelfNotify?: boolean;
}
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
private _isDarkTheme: boolean = false;
private _environmentMessage: string = '';
public render(): void {
// Именно здесь, а не в onInit: свойство меняется в панели без перезапуска
// веб-части, и render вызывается на каждое такое изменение.
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_PROD;
setEdoApiBaseUrl(this.properties.edoApiBaseUrl);
this._applyNotificationSettings();
const element: React.ReactElement = React.createElement(
ErrorOverlay,
undefined,
React.createElement<IRequestPortalProps>(RequestPortal, {
// key на подменённом e-mail: смена подмены в property pane должна
// пересобрать раздел заново — иначе счётчики в боковом меню и открытые
// таблицы остались бы от прежнего пользователя (их эффекты завязаны на
// монтирование/context, а не на identity подмены).
key: this.properties.debugUserQuery || '__self__',
isDarkTheme: this._isDarkTheme,
environmentMessage: this._environmentMessage,
hasTeamsContext: !!this.context.sdks.microsoftTeams,
userDisplayName: this.context.pageContext.user.displayName,
context: this.context
})
);
ReactDom.render(element, this.domElement);
}
protected onInit(): Promise<void> {
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
initSP(this.context); // единый клиент PnPjs для записи в списки
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они
// берутся именно оттуда. В render() он ставится снова (свойство меняется в
// панели без перезапуска), но render случается позже onInit.
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_PROD;
setEdoApiBaseUrl(this.properties.edoApiBaseUrl);
this._applyNotificationSettings();
// Партиции грузим здесь, а не по требованию из форм: имена списков нужны
// формам синхронно (см. shared/listTitles.ts), а формы — лениво подгружаемые
// чанки, монтируются заведомо позже onInit. initListPartitions не бросает:
// при недоступном approval-route портал работает по конвенции имён.
//
// НЕ ждём эту загрузку здесь (не Promise.all, а просто вызов) — onInit
// блокирует ПЕРВЫЙ render() всего портала (SPFx не рисует веб-часть, пока
// его промис не разрешится), а два новых AAD-запроса к внешнему api.tnsu.kz
// не обязаны попадать в этот путь: раньше onInit резолвился почти мгновенно
// (только _getEnvironmentMessage, без сети), и весь портал — включая
// «Мои заявки» и «Новая заявка», которым партиции вообще не нужны сразу —
// ждал открытия форм. Партиции при этом гарантированно успевают раньше,
// чем смонтируется первая форма (ленивый чанк грузится дольше).
// eslint-disable-next-line no-void -- без void это «висячий» промис, а no-floating-promises здесь уровня error
void initListPartitions(this.context);
return this._getEnvironmentMessage().then(message => {
this._environmentMessage = message;
});
}
private _applyNotificationSettings(): void {
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
setChatSelfNotify(this.properties.chatSelfNotify === true);
}
private _getEnvironmentMessage(): Promise<string> {
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
.then(context => {
let environmentMessage: string;
switch (context.app.host.name) {
case 'Office': // running in Office
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
break;
case 'Outlook': // running in Outlook
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOutlook : strings.AppOutlookEnvironment;
break;
case 'Teams': // running in Teams
case 'TeamsModern':
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentTeams : strings.AppTeamsTabEnvironment;
break;
default:
environmentMessage = strings.UnknownEnvironment;
}
return environmentMessage;
});
}
return Promise.resolve(this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentSharePoint : strings.AppSharePointEnvironment);
}
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
if (!currentTheme) {
return;
}
this._isDarkTheme = !!currentTheme.isInverted;
const {
semanticColors
} = currentTheme;
if (semanticColors) {
this.domElement.style.setProperty('--bodyText', semanticColors.bodyText || null);
this.domElement.style.setProperty('--link', semanticColors.link || null);
this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered || null);
}
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
return {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneDebugUser('debugUserQuery', {
label: strings.DebugUserFieldLabel,
description: strings.DebugUserFieldDescription,
context: this.context,
initialQuery: this.properties.debugUserQuery,
onResolved: (query, email, displayName) => {
// Сохраняем ТОЛЬКО e-mail (введённый напрямую или найденный по
// ФИО) — свойство одно и то же используется как e-mail для
// подмены (см. render() выше), так что писать в него сырое ФИО
// нельзя. Пустой query — поле очистили, подмену выключаем.
// А вот «email пуст, но query не пуст» — ФИО ещё не нашлось
// (недописано/не существует): ничего не трогаем, иначе неудачный
// промежуточный поиск молча выключил бы уже работающую подмену.
if (email) {
this.properties.debugUserQuery = email;
this.properties.debugUserDisplayName = displayName;
this.render();
} else if (!query) {
this.properties.debugUserQuery = '';
this.properties.debugUserDisplayName = '';
this.render();
}
}
})
]
},
{
groupName: 'Approval Route API',
groupFields: [
PropertyPaneDropdown('approvalApiBaseUrl', {
label: 'Api Base Url',
selectedKey: this.properties.approvalApiBaseUrl,
options: [
{ key: APPROVAL_API_BASE_URL_PROD, text: `Прод (${APPROVAL_API_BASE_URL_PROD})` },
{ key: APPROVAL_API_BASE_URL_LOCAL, text: `Локально (${APPROVAL_API_BASE_URL_LOCAL})` }
]
})
]
},
{
groupName: 'ЭДО (подписание)',
groupFields: [
PropertyPaneDropdown('edoApiBaseUrl', {
label: 'С каким ЭДО работать',
selectedKey: this.properties.edoApiBaseUrl || EDO_API_BASE_URL_PROD,
options: [
{ key: EDO_API_BASE_URL_PROD, text: `Прод (${EDO_API_BASE_URL_PROD})` },
{ key: EDO_API_BASE_URL_STAGE, text: `Стейдж (${EDO_API_BASE_URL_STAGE})` },
{ key: EDO_API_BASE_URL_LOCAL, text: `Локально (${EDO_API_BASE_URL_LOCAL})` }
]
})
]
},
{
groupName: 'Уведомления',
groupFields: [
PropertyPaneToggle('notificationsEnabled', {
label: 'Уведомления согласующим',
onText: 'Включены',
offText: 'Выключены',
checked: this.properties.notificationsEnabled !== false
}),
PropertyPaneTextField('notifyOnlyTo', {
label: 'Слать только на адрес',
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
placeholder: 'user@tnsu.kz',
disabled: this.properties.notificationsEnabled === false
}),
PropertyPaneToggle('chatSelfNotify', {
label: 'Чат: уведомлять о своих сообщениях (для теста)',
onText: 'Да — и можно отметить себя через «@»',
offText: 'Нет',
checked: this.properties.chatSelfNotify === true
})
]
}
]
}
]
};
}
}