Чат заявки: отметки «@» и оповещения через поток (#26)
Tests / frontend-test (push) Successful in 2m1s
Tests / frontend-test (push) Successful in 2m1s
## Что сделано **Чат заявки: отметки и оповещения** - В поле ввода «@» открывает подсказку с участниками заявки: инициатор, согласующие и исполнители из маршрута, а также те, кому открыты «Все заявки» этого типа (список из 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
This commit was merged in pull request #26.
This commit is contained in:
@@ -24,7 +24,8 @@ import {
|
|||||||
EDO_API_BASE_URL_STAGE,
|
EDO_API_BASE_URL_STAGE,
|
||||||
setApprovalApiBaseUrl,
|
setApprovalApiBaseUrl,
|
||||||
setEdoApiBaseUrl,
|
setEdoApiBaseUrl,
|
||||||
setNotificationSettings
|
setNotificationSettings,
|
||||||
|
setChatSelfNotify
|
||||||
} from './shared/config';
|
} from './shared/config';
|
||||||
import { initListPartitions } from './shared/approval';
|
import { initListPartitions } from './shared/approval';
|
||||||
import { ErrorOverlay } from './shared/ui';
|
import { ErrorOverlay } from './shared/ui';
|
||||||
@@ -54,6 +55,11 @@ export interface IRequestPortalWebPartProps {
|
|||||||
notificationsEnabled?: boolean;
|
notificationsEnabled?: boolean;
|
||||||
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
|
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
|
||||||
notifyOnlyTo?: string;
|
notifyOnlyTo?: string;
|
||||||
|
/**
|
||||||
|
* Для проверки чата: письма и о СВОИХ сообщениях, и можно отметить себя через «@».
|
||||||
|
* По умолчанию выключено (undefined — тоже «выкл»).
|
||||||
|
*/
|
||||||
|
chatSelfNotify?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
||||||
@@ -126,6 +132,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
|
|
||||||
private _applyNotificationSettings(): void {
|
private _applyNotificationSettings(): void {
|
||||||
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
|
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
|
||||||
|
setChatSelfNotify(this.properties.chatSelfNotify === true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getEnvironmentMessage(): Promise<string> {
|
private _getEnvironmentMessage(): Promise<string> {
|
||||||
@@ -259,6 +266,12 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
|
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
|
||||||
placeholder: 'user@tnsu.kz',
|
placeholder: 'user@tnsu.kz',
|
||||||
disabled: this.properties.notificationsEnabled === false
|
disabled: this.properties.notificationsEnabled === false
|
||||||
|
}),
|
||||||
|
PropertyPaneToggle('chatSelfNotify', {
|
||||||
|
label: 'Чат: уведомлять о своих сообщениях (для теста)',
|
||||||
|
onText: 'Да — и можно отметить себя через «@»',
|
||||||
|
offText: 'Нет',
|
||||||
|
checked: this.properties.chatSelfNotify === true
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, item
|
|||||||
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим
|
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим
|
||||||
(и уже решившим, и ещё ждущим своей очереди) и исполнителям —
|
(и уже решившим, и ещё ждущим своей очереди) и исполнителям —
|
||||||
в отличие от панели решения, которая видна только текущему шагу.
|
в отличие от панели решения, которая видна только текущему шагу.
|
||||||
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
|
Включено не для всех типов заявок — см. isChatEnabledFor. */}
|
||||||
{isChatEnabledFor(formKey) && (
|
{isChatEnabledFor(formKey) && (
|
||||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, li
|
|||||||
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
|
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
|
||||||
заявки). Здесь оно особенно нужно: заявку правят как раз после
|
заявки). Здесь оно особенно нужно: заявку правят как раз после
|
||||||
возврата на доработку, и уточнить причину логично не уходя с формы.
|
возврата на доработку, и уточнить причину логично не уходя с формы.
|
||||||
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
|
Включено не для всех типов заявок — см. isChatEnabledFor. */}
|
||||||
{isChatEnabledFor(formKey) && (
|
{isChatEnabledFor(formKey) && (
|
||||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function asDay(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Разобрать сохранённое значение; всё, что не прошло проверку, — как «не задано». */
|
/** Разобрать сохранённое значение; всё, что не прошло проверку, — как «не задано». */
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- так отдаёт localStorage.getItem, если ключа нет
|
||||||
export function parseStoredRefinement(raw: string | null): IStoredRefinement | undefined {
|
export function parseStoredRefinement(raw: string | null): IStoredRefinement | undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
let parsed: { filters?: Record<string, unknown>; sort?: Record<string, unknown> };
|
let parsed: { filters?: Record<string, unknown>; sort?: Record<string, unknown> };
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ import {
|
|||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
IUseMatrixEditor,
|
IUseMatrixEditor,
|
||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
resolveWriteListId
|
resolveWriteListId,
|
||||||
|
composeRemoveRowRule
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
@@ -244,7 +246,11 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
<ObligationSection {...sectionProps} />
|
<ObligationSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
<ApprovalMatrix editor={matrix} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix
|
||||||
|
editor={matrix}
|
||||||
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
|
/>
|
||||||
|
|
||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
@@ -253,6 +259,7 @@ const BusinessTripForm: React.FC<IRequestFormProps> = ({
|
|||||||
title="Исполнение (после согласования)"
|
title="Исполнение (после согласования)"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
emptyText="Исполнители не заданы по маршруту."
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
title="Исполнение (после согласования)"
|
title="Исполнение (после согласования)"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
emptyText="Исполнители не заданы по маршруту."
|
emptyText="Исполнители не заданы по маршруту."
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import {
|
|||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
IApproverToWrite,
|
||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
executorsToWrite
|
executorsToWrite,
|
||||||
|
composeRemoveRowRule
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import {
|
import {
|
||||||
uploadAttachments,
|
uploadAttachments,
|
||||||
@@ -444,6 +445,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={matrixEditor}
|
editor={matrixEditor}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
// Пустой маршрут — штатная ситуация: для части видов работ согласование
|
// Пустой маршрут — штатная ситуация: для части видов работ согласование
|
||||||
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
|
// не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута
|
||||||
// показывается отдельно (matrix.error) и блокирует отправку.
|
// показывается отдельно (matrix.error) и блокирует отправку.
|
||||||
@@ -457,6 +459,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
emptyText="Исполнители не назначены."
|
emptyText="Исполнители не назначены."
|
||||||
|
canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export {
|
|||||||
getRequestTypeAccessEmails,
|
getRequestTypeAccessEmails,
|
||||||
resolveRoute
|
resolveRoute
|
||||||
} from './approvalRoute.service';
|
} from './approvalRoute.service';
|
||||||
export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess';
|
export { hasRequestTypeAccess, accessibleAllRequestsTypes, emailsForType } from './requestTypeAccess';
|
||||||
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
|
export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys';
|
||||||
export {
|
export {
|
||||||
initListPartitions,
|
initListPartitions,
|
||||||
@@ -59,7 +59,7 @@ export { approvalServiceFor } from './approvalServiceFactory';
|
|||||||
export { ApproverService } from './approverDecision.service';
|
export { ApproverService } from './approverDecision.service';
|
||||||
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
export type { ApproverDecision, IPendingRow } from './approverDecision.service';
|
||||||
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service';
|
||||||
export { notifyActiveStep } from './notify.service';
|
export { notifyActiveStep, postToFlow } from './notify.service';
|
||||||
export type { INotifyTarget } from './notify.service';
|
export type { INotifyTarget } from './notify.service';
|
||||||
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor';
|
||||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ export interface INotifyTarget {
|
|||||||
approvalListName: string; // ApproveListName (список согласования)
|
approvalListName: string; // ApproveListName (список согласования)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST в поток оповещения (Entra-триггер) через AadHttpClient — обычный fetch → 401,
|
// POST в поток Power Automate (Entra-триггер) через AadHttpClient — обычный fetch → 401,
|
||||||
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS).
|
// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS). Общий для всех
|
||||||
async function postNotify(context: WebPartContext, body: Record<string, unknown>): Promise<void> {
|
// потоков оповещения портала (согласование, чат заявки — shared/chat/chatNotify.service.ts).
|
||||||
if (!FLOW_NOTIFY_URL) {
|
export async function postToFlow(context: WebPartContext, flowUrl: string, body: object): Promise<void> {
|
||||||
|
if (!flowUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
const client = await context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
|
||||||
const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, {
|
const res = await client.post(flowUrl, AadHttpClient.configurations.v1, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
@@ -68,7 +69,7 @@ async function notifyRow(
|
|||||||
if (!(await isNotifyAllowed(approval, row.approverId))) {
|
if (!(await isNotifyAllowed(approval, row.approverId))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await postNotify(context, {
|
await postToFlow(context, FLOW_NOTIFY_URL, {
|
||||||
RequestTypeId: target.requestTypeId,
|
RequestTypeId: target.requestTypeId,
|
||||||
RowID: row.id,
|
RowID: row.id,
|
||||||
RequestID: requestId,
|
RequestID: requestId,
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { effectiveUserEmail } from '../impersonation';
|
|||||||
// кешируется, чтобы временный сбой сети не «залип» на весь сеанс.
|
// кешируется, чтобы временный сбой сети не «залип» на весь сеанс.
|
||||||
const cache = new Map<number, Promise<string[]>>();
|
const cache = new Map<number, Promise<string[]>>();
|
||||||
|
|
||||||
function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
|
/**
|
||||||
|
* Кому выдан доступ видеть ВСЕ заявки этого типа (раздел «Все заявки»). Кешируется
|
||||||
|
* на сеанс. Нужен не только для проверки доступа: эти люди могут писать в чат
|
||||||
|
* любой заявки типа, значит, их можно и отмечать в нём (см. shared/chat).
|
||||||
|
*/
|
||||||
|
export function emailsForType(context: WebPartContext, requestTypeId: number): Promise<string[]> {
|
||||||
let pending = cache.get(requestTypeId);
|
let pending = cache.get(requestTypeId);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => {
|
pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe('linkify', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('не-http схемы ссылкой не становятся', () => {
|
it('не-http схемы ссылкой не становятся', () => {
|
||||||
|
// eslint-disable-next-line no-script-url -- проверяем как раз то, что такая строка ссылкой не станет
|
||||||
expect(linkify('javascript:alert(1)')).toEqual([{ text: 'javascript:alert(1)' }]);
|
expect(linkify('javascript:alert(1)')).toEqual([{ text: 'javascript:alert(1)' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { isChatEnabledFor } from './chatEnabled';
|
import { isChatEnabledFor } from './chatEnabled';
|
||||||
|
|
||||||
describe('isChatEnabledFor', () => {
|
describe('isChatEnabledFor', () => {
|
||||||
it('включён для «Обходного листа»', () => {
|
it('включён для Обходного листа, подбора, приёма и заявки в 1С', () => {
|
||||||
expect(isChatEnabledFor('clearance-form')).toBe(true);
|
expect(isChatEnabledFor('clearance-form')).toBe(true);
|
||||||
|
expect(isChatEnabledFor('recruiting-selection')).toBe(true);
|
||||||
|
expect(isChatEnabledFor('recruiting-hire')).toBe(true);
|
||||||
|
expect(isChatEnabledFor('it-1c-ticket')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('выключен для остальных типов заявок', () => {
|
it('выключен для остальных типов заявок', () => {
|
||||||
expect(isChatEnabledFor('hr-vacation')).toBe(false);
|
expect(isChatEnabledFor('hr-vacation')).toBe(false);
|
||||||
expect(isChatEnabledFor('it-1c-ticket')).toBe(false);
|
expect(isChatEnabledFor('recruiting-referral')).toBe(false);
|
||||||
expect(isChatEnabledFor('recruiting-hire')).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('незнакомый ключ — тоже выключен, а не бросает', () => {
|
it('незнакомый ключ — тоже выключен, а не бросает', () => {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { RequestTypeId } from '../../models/types';
|
|||||||
/**
|
/**
|
||||||
* Типы заявок, для которых показывается чат (shared/chat).
|
* Типы заявок, для которых показывается чат (shared/chat).
|
||||||
*
|
*
|
||||||
* Пилот: только «Обходной лист» (forms/Clearance, FORM_ID = 'clearance-form').
|
* Включаем по одному типу, а не сразу на все: «Обходной лист» — первый пилот,
|
||||||
* Остальным формам чат пока не нужен — решение сузить область до одного типа,
|
* с 2026-10-14 добавлены «Заявка на подбор», «Заявка на приём» и «Заявка в
|
||||||
* а не включать сразу на все девять.
|
* отдел 1С».
|
||||||
*
|
*
|
||||||
* Ключ — строковый литерал, а не импорт FORM_ID из forms/Clearance/model:
|
* Ключ — строковый литерал, а не импорт FORM_ID из forms/Clearance/model:
|
||||||
* shared/* не должен зависеть от forms/* (зависимость идёт в обратную сторону
|
* shared/* не должен зависеть от forms/* (зависимость идёт в обратную сторону
|
||||||
@@ -13,7 +13,12 @@ import { RequestTypeId } from '../../models/types';
|
|||||||
* так что литерал здесь не менее надёжен, чем в LIST_TITLE_BASE
|
* так что литерал здесь не менее надёжен, чем в LIST_TITLE_BASE
|
||||||
* (shared/listTitles.ts), где тем же приёмом перечислены все типы заявок.
|
* (shared/listTitles.ts), где тем же приёмом перечислены все типы заявок.
|
||||||
*/
|
*/
|
||||||
const CHAT_ENABLED_FORM_IDS: ReadonlySet<RequestTypeId> = new Set<RequestTypeId>(['clearance-form']);
|
const CHAT_ENABLED_FORM_IDS: ReadonlySet<RequestTypeId> = new Set<RequestTypeId>([
|
||||||
|
'clearance-form',
|
||||||
|
'recruiting-selection',
|
||||||
|
'recruiting-hire',
|
||||||
|
'it-1c-ticket'
|
||||||
|
]);
|
||||||
|
|
||||||
/** Показывать ли чат по заявке для этого типа. Проверяется ДО монтирования
|
/** Показывать ли чат по заявке для этого типа. Проверяется ДО монтирования
|
||||||
* RequestChat — иначе хук useRequestChat опрашивал бы SharePoint (загрузка +
|
* RequestChat — иначе хук useRequestChat опрашивал бы SharePoint (загрузка +
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
buildParticipants,
|
||||||
|
extractMentionedEmails,
|
||||||
|
filterParticipants,
|
||||||
|
findMentionQuery,
|
||||||
|
insertMention
|
||||||
|
} from './chatMentions';
|
||||||
|
|
||||||
|
const ivanov = { name: 'Иван Иванов', email: 'ivanov@tnsu.kz' };
|
||||||
|
const petrova = { name: 'Анна Петрова', email: 'petrova@tnsu.kz' };
|
||||||
|
|
||||||
|
describe('buildParticipants', () => {
|
||||||
|
it('убирает повторы, пустые адреса и самого пишущего', () => {
|
||||||
|
const result = buildParticipants(
|
||||||
|
[ivanov, { name: 'Иван Иванов', email: 'IVANOV@tnsu.kz' }, { name: 'Без почты', email: '' }, undefined, petrova],
|
||||||
|
'petrova@tnsu.kz'
|
||||||
|
);
|
||||||
|
expect(result).toEqual([ivanov]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без ФИО показывает почту', () => {
|
||||||
|
expect(buildParticipants([{ email: 'x@tnsu.kz' }], '')).toEqual([{ name: 'x@tnsu.kz', email: 'x@tnsu.kz' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findMentionQuery', () => {
|
||||||
|
it('находит отметку в начале и после пробела', () => {
|
||||||
|
expect(findMentionQuery('@Ив', 3)).toEqual({ start: 0, query: 'Ив' });
|
||||||
|
expect(findMentionQuery('Привет @Иван Ив', 15)).toEqual({ start: 7, query: 'Иван Ив' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не считает отметкой адрес почты и перенос строки', () => {
|
||||||
|
expect(findMentionQuery('mail@tnsu', 9)).toBeUndefined();
|
||||||
|
expect(findMentionQuery('@Иван\nдалее', 11)).toBeUndefined();
|
||||||
|
expect(findMentionQuery('без отметки', 5)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filterParticipants', () => {
|
||||||
|
it('ищет по началу слов ФИО и по почте', () => {
|
||||||
|
expect(filterParticipants([ivanov, petrova], 'пет')).toEqual([petrova]);
|
||||||
|
expect(filterParticipants([ivanov, petrova], 'иван ив')).toEqual([ivanov]);
|
||||||
|
expect(filterParticipants([ivanov, petrova], 'petrova@')).toEqual([petrova]);
|
||||||
|
expect(filterParticipants([ivanov, petrova], '')).toEqual([ivanov, petrova]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('insertMention', () => {
|
||||||
|
it('заменяет набранное на ФИО и ставит курсор за пробелом', () => {
|
||||||
|
const text = 'Привет @Ив, глянь';
|
||||||
|
const result = insertMention(text, { start: 7, query: 'Ив' }, 10, ivanov);
|
||||||
|
expect(result.text).toBe('Привет @Иван Иванов , глянь');
|
||||||
|
expect(result.caret).toBe('Привет @Иван Иванов '.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractMentionedEmails', () => {
|
||||||
|
it('берёт отметки из текста без повторов', () => {
|
||||||
|
expect(extractMentionedEmails('@Иван Иванов и @Анна Петрова, @Иван Иванов', [ivanov, petrova])).toEqual([
|
||||||
|
'ivanov@tnsu.kz',
|
||||||
|
'petrova@tnsu.kz'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('стёртая отметка не считается, короткое ФИО не цепляется за длинное', () => {
|
||||||
|
const ivan = { name: 'Иван', email: 'ivan@tnsu.kz' };
|
||||||
|
expect(extractMentionedEmails('@Иван Иванов, привет', [ivan, ivanov])).toEqual(['ivanov@tnsu.kz']);
|
||||||
|
expect(extractMentionedEmails('Иван Иванов, привет', [ivanov])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Отметки участников заявки в чате через «@».
|
||||||
|
*
|
||||||
|
* Отметка — ОБЫЧНЫЙ текст «@ФИО» в сообщении, а не штатное упоминание
|
||||||
|
* SharePoint (параметр mentions у комментария): штатное упоминание SharePoint
|
||||||
|
* сопровождает своим письмом, а письма по чату шлёт наш поток (chatNotify.ts) —
|
||||||
|
* с номером заявки и ссылкой на портал, а не на строку списка. Два письма об
|
||||||
|
* одном сообщении — лишнее.
|
||||||
|
*
|
||||||
|
* Модуль чистый (без React и PnPjs) — покрыт тестами.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Участник заявки, которого можно отметить в чате. */
|
||||||
|
export interface IChatParticipant {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Активная отметка, которую пользователь сейчас набирает: «@Ива|». */
|
||||||
|
export interface IMentionQuery {
|
||||||
|
/** Позиция символа «@» в тексте. */
|
||||||
|
start: number;
|
||||||
|
/** Набранное после «@» до курсора. */
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Предел длины набираемой отметки: ФИО с отчеством укладывается, а «@» в
|
||||||
|
* середине обычного текста (адрес почты, «@всем, смотрите…») не держит
|
||||||
|
* подсказку открытой до конца абзаца.
|
||||||
|
*/
|
||||||
|
const MAX_QUERY_LENGTH = 40;
|
||||||
|
|
||||||
|
const sameEmail = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список тех, кого можно отметить: инициатор и все из маршрута согласования
|
||||||
|
* (согласующие и исполнители), без повторов и без самого пишущего — отмечать
|
||||||
|
* себя незачем. Порядок — как в маршруте, инициатор первым.
|
||||||
|
*/
|
||||||
|
export function buildParticipants(
|
||||||
|
people: Array<Partial<IChatParticipant> | undefined>,
|
||||||
|
currentUserEmail: string
|
||||||
|
): IChatParticipant[] {
|
||||||
|
const result: IChatParticipant[] = [];
|
||||||
|
for (const p of people) {
|
||||||
|
const email = (p?.email || '').trim();
|
||||||
|
if (!email) continue;
|
||||||
|
if (currentUserEmail && sameEmail(email, currentUserEmail)) continue;
|
||||||
|
if (result.some((r) => sameEmail(r.email, email))) continue;
|
||||||
|
result.push({ name: (p?.name || '').trim() || email, email });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Набирается ли сейчас отметка: «@» в начале текста или после пробела/переноса,
|
||||||
|
* и между ним и курсором нет переноса строки. Пробелы внутри допустимы — ФИО
|
||||||
|
* из нескольких слов.
|
||||||
|
*/
|
||||||
|
export function findMentionQuery(text: string, caret: number): IMentionQuery | undefined {
|
||||||
|
const before = text.slice(0, caret);
|
||||||
|
const start = before.lastIndexOf('@');
|
||||||
|
if (start < 0) return undefined;
|
||||||
|
if (start > 0 && !/\s/.test(before[start - 1])) return undefined;
|
||||||
|
|
||||||
|
const query = before.slice(start + 1);
|
||||||
|
if (query.length > MAX_QUERY_LENGTH || /[\r\n@]/.test(query)) return undefined;
|
||||||
|
return { start, query };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Участники, подходящие под набранное: каждое слово запроса — начало слова в ФИО или часть почты. */
|
||||||
|
export function filterParticipants(participants: IChatParticipant[], query: string): IChatParticipant[] {
|
||||||
|
const words = query.toLowerCase().split(/\s+/).filter((w) => !!w);
|
||||||
|
if (words.length === 0) return participants;
|
||||||
|
return participants.filter((p) => {
|
||||||
|
const nameWords = p.name.toLowerCase().split(/\s+/);
|
||||||
|
const email = p.email.toLowerCase();
|
||||||
|
return words.every((w) => nameWords.some((n) => n.startsWith(w)) || email.includes(w));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заменить набранное «@запрос» на «@ФИО » и вернуть новый текст и позицию курсора. */
|
||||||
|
export function insertMention(
|
||||||
|
text: string,
|
||||||
|
mention: IMentionQuery,
|
||||||
|
caret: number,
|
||||||
|
participant: IChatParticipant
|
||||||
|
): { text: string; caret: number } {
|
||||||
|
const inserted = `@${participant.name} `;
|
||||||
|
const head = text.slice(0, mention.start);
|
||||||
|
// Пробел сразу за курсором уже есть — второй не нужен.
|
||||||
|
const tail = text.slice(caret).replace(/^ /, '');
|
||||||
|
return { text: head + inserted + tail, caret: head.length + inserted.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кого в итоге отметили — по тексту, а не по истории выбора в подсказке:
|
||||||
|
* отметку могли стереть или вписать руками целиком. Длинные ФИО проверяются
|
||||||
|
* первыми, чтобы «@Иван Иванов» не засчитал заодно участника «Иван».
|
||||||
|
*/
|
||||||
|
export function extractMentionedEmails(text: string, participants: IChatParticipant[]): string[] {
|
||||||
|
// Найденную отметку затираем пробелами той же длины — позиции остальных не
|
||||||
|
// сдвигаются, и результат идёт в порядке появления в тексте.
|
||||||
|
let rest = text;
|
||||||
|
const found: Array<{ email: string; at: number }> = [];
|
||||||
|
const byLength = [...participants].sort((a, b) => b.name.length - a.name.length);
|
||||||
|
for (const p of byLength) {
|
||||||
|
const tag = `@${p.name}`;
|
||||||
|
const at = rest.indexOf(tag);
|
||||||
|
if (at < 0) continue;
|
||||||
|
rest = rest.split(tag).join(' '.repeat(tag.length));
|
||||||
|
if (!found.some((f) => sameEmail(f.email, p.email))) found.push({ email: p.email, at });
|
||||||
|
}
|
||||||
|
return found.sort((a, b) => a.at - b.at).map((f) => f.email);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { FLOW_CHAT_NOTIFY_URL } from '../config';
|
||||||
|
import { postToFlow } from '../approval/notify.service';
|
||||||
|
import { IChatNotifyBody } from './chatNotify';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить оповещение о сообщении в чате. Некритично: сообщение уже записано,
|
||||||
|
* и сбой письма не должен выглядеть как сбой отправки — ошибку только пишем в
|
||||||
|
* консоль, как и прочие оповещения портала (approval/notify.service.ts).
|
||||||
|
*/
|
||||||
|
export async function notifyChatMessage(context: WebPartContext, body: IChatNotifyBody): Promise<void> {
|
||||||
|
try {
|
||||||
|
await postToFlow(context, FLOW_CHAT_NOTIFY_URL, body);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Чат] Оповещение о сообщении не отправлено', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { buildChatNotifyBody, IChatNotifyInput } from './chatNotify';
|
||||||
|
|
||||||
|
const initiator = { name: 'Инициатор Заявки', email: 'init@tnsu.kz' };
|
||||||
|
const approver = { name: 'Анна Петрова', email: 'petrova@tnsu.kz' };
|
||||||
|
|
||||||
|
const base: IChatNotifyInput = {
|
||||||
|
initiatorEmail: initiator.email,
|
||||||
|
participants: [initiator, approver],
|
||||||
|
authorName: 'Анна Петрова',
|
||||||
|
authorEmail: approver.email,
|
||||||
|
messageText: 'Текст',
|
||||||
|
requestCode: '13-2608-42',
|
||||||
|
requestUrl: 'https://portal/x',
|
||||||
|
requestTitle: 'Обходной лист',
|
||||||
|
selfNotify: false
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildChatNotifyBody', () => {
|
||||||
|
it('пишет согласующий без отметок — письмо инициатору, отмеченных нет', () => {
|
||||||
|
expect(buildChatNotifyBody(base)).toEqual({
|
||||||
|
initiatorEmail: 'init@tnsu.kz',
|
||||||
|
mentionedEmails: [],
|
||||||
|
authorName: 'Анна Петрова',
|
||||||
|
messageText: 'Текст',
|
||||||
|
RequestUniqueCode: '13-2608-42',
|
||||||
|
requestUrl: 'https://portal/x',
|
||||||
|
requestTitle: 'Обходной лист'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('инициатор пишет без отметок — поток не вызываем', () => {
|
||||||
|
expect(buildChatNotifyBody({ ...base, authorEmail: 'INIT@tnsu.kz' })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('инициатор отметил согласующего — инициатор подменён отмеченным, чтобы не писать самому себе', () => {
|
||||||
|
const body = buildChatNotifyBody({ ...base, authorEmail: initiator.email, messageText: '@Анна Петрова глянь' });
|
||||||
|
expect(body?.initiatorEmail).toBe('petrova@tnsu.kz');
|
||||||
|
expect(body?.mentionedEmails).toEqual(['petrova@tnsu.kz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отметка самого себя без режима проверки не считается', () => {
|
||||||
|
const body = buildChatNotifyBody({ ...base, messageText: '@Анна Петрова @Инициатор Заявки' });
|
||||||
|
expect(body?.mentionedEmails).toEqual(['init@tnsu.kz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('в режиме проверки письма и о своих сообщениях, и себе по отметке', () => {
|
||||||
|
const own = buildChatNotifyBody({ ...base, authorEmail: initiator.email, selfNotify: true });
|
||||||
|
expect(own?.initiatorEmail).toBe('init@tnsu.kz');
|
||||||
|
const self = buildChatNotifyBody({ ...base, messageText: '@Анна Петрова', selfNotify: true });
|
||||||
|
expect(self?.mentionedEmails).toEqual(['petrova@tnsu.kz']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('файлы — строкой в тексте письма, и при пустом сообщении тоже', () => {
|
||||||
|
expect(buildChatNotifyBody({ ...base, fileNames: ['акт.pdf', 'скан.jpg'] })?.messageText).toBe(
|
||||||
|
'Текст\n\nПрикреплены файлы: акт.pdf, скан.jpg'
|
||||||
|
);
|
||||||
|
expect(buildChatNotifyBody({ ...base, messageText: '', fileNames: ['акт.pdf'] })?.messageText).toBe(
|
||||||
|
'Прикреплены файлы: акт.pdf'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет ни инициатора, ни отметок — поток не вызываем', () => {
|
||||||
|
expect(buildChatNotifyBody({ ...base, initiatorEmail: '' })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { IChatParticipant, extractMentionedEmails } from './chatMentions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тело запроса к потоку оповещения о сообщении в чате (FLOW_CHAT_NOTIFY_URL).
|
||||||
|
* Контракт потока: все поля — строки, кроме mentionedEmails; никого не
|
||||||
|
* отметили — пустой массив, а не null и не пропущенное поле.
|
||||||
|
*/
|
||||||
|
export interface IChatNotifyBody {
|
||||||
|
initiatorEmail: string;
|
||||||
|
mentionedEmails: string[];
|
||||||
|
authorName: string;
|
||||||
|
messageText: string;
|
||||||
|
RequestUniqueCode: string;
|
||||||
|
requestUrl: string;
|
||||||
|
requestTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IChatNotifyInput {
|
||||||
|
/** Инициатор заявки (автор строки списка данных). */
|
||||||
|
initiatorEmail: string;
|
||||||
|
/** Кого можно было отметить — по ним ищутся «@ФИО» в тексте. */
|
||||||
|
participants: IChatParticipant[];
|
||||||
|
authorName: string;
|
||||||
|
authorEmail: string;
|
||||||
|
messageText: string;
|
||||||
|
/** Имена приложенных файлов, как их выбрал пользователь (без складского префикса). */
|
||||||
|
fileNames?: string[];
|
||||||
|
requestCode: string;
|
||||||
|
requestUrl: string;
|
||||||
|
requestTitle: string;
|
||||||
|
/** Режим проверки (свойство веб-части chatSelfNotify): письма и о своих сообщениях. */
|
||||||
|
selfNotify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameEmail = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст для письма: сообщение и строка о файлах. Без неё сообщение из одного
|
||||||
|
* файла пришло бы письмом с пустым текстом. Сами файлы в письмо не кладём —
|
||||||
|
* они в библиотеке, открываются по ссылке на заявку.
|
||||||
|
*/
|
||||||
|
function letterText(text: string, fileNames: string[]): string {
|
||||||
|
if (fileNames.length === 0) return text;
|
||||||
|
const files = `Прикреплены файлы: ${fileNames.join(', ')}`;
|
||||||
|
return text ? `${text}\n\n${files}` : files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собрать тело запроса к потоку или undefined, если писать некому.
|
||||||
|
*
|
||||||
|
* Поток шлёт инициатору письмо ВСЕГДА, когда его нет среди отмеченных. Значит,
|
||||||
|
* когда пишет сам инициатор (а режим проверки выключен), письмо «новое сообщение
|
||||||
|
* по вашей заявке» ушло бы ему же. Отключить его в контракте потока нечем,
|
||||||
|
* поэтому:
|
||||||
|
* — никого не отметил → поток не вызываем вовсе;
|
||||||
|
* — кого-то отметил → в initiatorEmail кладём первого отмеченного: по
|
||||||
|
* контракту отмеченный инициатор получает только «Вас упомянули», и
|
||||||
|
* лишнего письма никому не уходит.
|
||||||
|
*/
|
||||||
|
export function buildChatNotifyBody(input: IChatNotifyInput): IChatNotifyBody | undefined {
|
||||||
|
const mentioned = extractMentionedEmails(input.messageText, input.participants).filter(
|
||||||
|
(email) => input.selfNotify || !sameEmail(email, input.authorEmail)
|
||||||
|
);
|
||||||
|
|
||||||
|
let initiatorEmail = input.initiatorEmail;
|
||||||
|
const authorIsInitiator =
|
||||||
|
!!initiatorEmail && !!input.authorEmail && sameEmail(initiatorEmail, input.authorEmail);
|
||||||
|
if (authorIsInitiator && !input.selfNotify) {
|
||||||
|
if (mentioned.length === 0) return undefined;
|
||||||
|
initiatorEmail = mentioned[0];
|
||||||
|
}
|
||||||
|
if (!initiatorEmail && mentioned.length === 0) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
initiatorEmail,
|
||||||
|
mentionedEmails: mentioned,
|
||||||
|
authorName: input.authorName,
|
||||||
|
messageText: letterText(input.messageText, input.fileNames || []),
|
||||||
|
RequestUniqueCode: input.requestCode,
|
||||||
|
requestUrl: input.requestUrl,
|
||||||
|
requestTitle: input.requestTitle
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,19 @@ export { RequestChat } from './ui/RequestChat';
|
|||||||
export { isChatEnabledFor } from './chatEnabled';
|
export { isChatEnabledFor } from './chatEnabled';
|
||||||
export { useRequestChat } from './useRequestChat';
|
export { useRequestChat } from './useRequestChat';
|
||||||
export type { IRequestChat } from './useRequestChat';
|
export type { IRequestChat } from './useRequestChat';
|
||||||
export { getChatMessages, addChatMessage } from './requestChat.service';
|
export { getChatMessages, addChatMessage, getChatRequestInfo } from './requestChat.service';
|
||||||
|
export type { IChatRequestInfo } from './requestChat.service';
|
||||||
|
export {
|
||||||
|
buildParticipants,
|
||||||
|
findMentionQuery,
|
||||||
|
filterParticipants,
|
||||||
|
insertMention,
|
||||||
|
extractMentionedEmails
|
||||||
|
} from './chatMentions';
|
||||||
|
export type { IChatParticipant, IMentionQuery } from './chatMentions';
|
||||||
|
export { buildChatNotifyBody } from './chatNotify';
|
||||||
|
export type { IChatNotifyBody } from './chatNotify';
|
||||||
|
export { notifyChatMessage } from './chatNotify.service';
|
||||||
export {
|
export {
|
||||||
uploadChatFiles,
|
uploadChatFiles,
|
||||||
chatFilesFolderUrl,
|
chatFilesFolderUrl,
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { RequestTypeId } from '../../models/types';
|
|||||||
import { IChatMessage } from './models/IChatMessage';
|
import { IChatMessage } from './models/IChatMessage';
|
||||||
import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages';
|
import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages';
|
||||||
import { chatFilesFolderUrl, uploadChatFiles } from './chatFiles.service';
|
import { chatFilesFolderUrl, uploadChatFiles } from './chatFiles.service';
|
||||||
|
import { IChatParticipant } from './chatMentions';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { approvalServiceFor } from '../approval/approvalServiceFactory';
|
||||||
|
import { getRequestTypeIdByPageUrl } from '../approval/approvalRoute.service';
|
||||||
|
import { emailsForType } from '../approval/requestTypeAccess';
|
||||||
|
import { getEmployeeOrganizations } from '../dictionary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Чат по заявке ПОВЕРХ штатных комментариев к строке списка.
|
* Чат по заявке ПОВЕРХ штатных комментариев к строке списка.
|
||||||
@@ -76,3 +82,68 @@ export async function addChatMessage(
|
|||||||
const storedNames = await uploadChatFiles(formKey, itemId, listId, files);
|
const storedNames = await uploadChatFiles(formKey, itemId, listId, files);
|
||||||
await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames));
|
await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сведения о заявке, нужные чату помимо переписки: кого отмечать и что писать в письме. */
|
||||||
|
export interface IChatRequestInfo {
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
initiator: IChatParticipant;
|
||||||
|
/**
|
||||||
|
* Инициатор, все из маршрута (согласующие и исполнители) и те, кому открыты
|
||||||
|
* «Все заявки» этого типа, — как есть, повторы убирает buildParticipants.
|
||||||
|
*/
|
||||||
|
people: IChatParticipant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Люди с доступом ко «Всем заявкам» этого типа (админка approval-route): они
|
||||||
|
* открывают любую заявку типа и пишут в её чат, значит, их можно и отметить.
|
||||||
|
* ФИО — из кадрового справочника, не нашлось — показываем почту. Любой сбой —
|
||||||
|
* просто без них: отметки участников маршрута от этого не страдают.
|
||||||
|
*/
|
||||||
|
async function allRequestsViewers(context: WebPartContext, formKey: RequestTypeId): Promise<IChatParticipant[]> {
|
||||||
|
try {
|
||||||
|
const typeId = await getRequestTypeIdByPageUrl(context, formKey);
|
||||||
|
const emails = await emailsForType(context, typeId);
|
||||||
|
return await Promise.all(
|
||||||
|
emails.map(async (email) => {
|
||||||
|
const records = await getEmployeeOrganizations(context, email).catch(() => []);
|
||||||
|
return { name: records[0]?.fio || email, email };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициатор — автор строки списка данных: заявку создаёт он сам (подмена
|
||||||
|
* личности на автора записи не влияет, см. RequestChat). Участники — строки
|
||||||
|
* маршрута; если список согласования не прочитался, чат работает и так —
|
||||||
|
* просто отметить можно только инициатора.
|
||||||
|
*/
|
||||||
|
export async function getChatRequestInfo(
|
||||||
|
context: WebPartContext,
|
||||||
|
formKey: RequestTypeId,
|
||||||
|
itemId: number,
|
||||||
|
listId: string | undefined
|
||||||
|
): Promise<IChatRequestInfo> {
|
||||||
|
const [item, matrix, viewers] = await Promise.all([
|
||||||
|
resolveDataList(formKey, listId)
|
||||||
|
.items.getById(itemId)
|
||||||
|
.select('Title', 'RequestUniqueCode', 'Author/Title', 'Author/EMail')
|
||||||
|
.expand('Author')() as Promise<Record<string, unknown>>,
|
||||||
|
approvalServiceFor(formKey, listId)
|
||||||
|
.getMatrixForRequest(itemId)
|
||||||
|
.catch(() => []),
|
||||||
|
allRequestsViewers(context, formKey)
|
||||||
|
]);
|
||||||
|
const author = item.Author as { Title?: string; EMail?: string } | undefined;
|
||||||
|
const initiator = { name: author?.Title || '', email: author?.EMail || '' };
|
||||||
|
return {
|
||||||
|
code: (item.RequestUniqueCode as string) || '',
|
||||||
|
title: (item.Title as string) || '',
|
||||||
|
initiator,
|
||||||
|
people: [initiator, ...matrix.map((r) => ({ name: r.who, email: r.email })), ...viewers]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,22 +2,33 @@ import * as React from 'react';
|
|||||||
import styles from './RequestChat.module.scss';
|
import styles from './RequestChat.module.scss';
|
||||||
import { Button, FileDropzone, Icon } from '../../ui';
|
import { Button, FileDropzone, Icon } from '../../ui';
|
||||||
import { MAX_FILES_PER_MESSAGE, MAX_FILE_SIZE_BYTES } from '../chatFiles.service';
|
import { MAX_FILES_PER_MESSAGE, MAX_FILE_SIZE_BYTES } from '../chatFiles.service';
|
||||||
|
import { filterParticipants, findMentionQuery, IChatParticipant, insertMention } from '../chatMentions';
|
||||||
|
|
||||||
interface IChatComposerProps {
|
interface IChatComposerProps {
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
|
/** Кого можно отметить через «@» (см. chatMentions.ts). */
|
||||||
|
participants: IChatParticipant[];
|
||||||
/** Возвращает true, если сообщение записано — только тогда очищаем поле. */
|
/** Возвращает true, если сообщение записано — только тогда очищаем поле. */
|
||||||
onSend: (text: string, files: File[]) => Promise<boolean>;
|
onSend: (text: string, files: File[]) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024));
|
const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024));
|
||||||
|
|
||||||
|
/** Сколько вариантов показывать в подсказке «@» — дальше пусть уточняют набором. */
|
||||||
|
const MAX_SUGGESTIONS = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный
|
* Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный
|
||||||
* textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш
|
* textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш
|
||||||
* не пробрасывает.
|
* не пробрасывает.
|
||||||
*/
|
*/
|
||||||
export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend }) => {
|
export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, participants, onSend }) => {
|
||||||
const [text, setText] = React.useState<string>('');
|
const [text, setText] = React.useState<string>('');
|
||||||
|
const [caret, setCaret] = React.useState<number>(0);
|
||||||
|
const [highlighted, setHighlighted] = React.useState<number>(0);
|
||||||
|
// Esc закрывает подсказку до следующего набора — иначе её не убрать, не стерев «@».
|
||||||
|
const [mentionDismissed, setMentionDismissed] = React.useState<boolean>(false);
|
||||||
|
const inputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
const [files, setFiles] = React.useState<File[]>([]);
|
const [files, setFiles] = React.useState<File[]>([]);
|
||||||
const [filesOpen, setFilesOpen] = React.useState<boolean>(false);
|
const [filesOpen, setFilesOpen] = React.useState<boolean>(false);
|
||||||
|
|
||||||
@@ -25,6 +36,30 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
|
|||||||
// текст отправку не блокирует.
|
// текст отправку не блокирует.
|
||||||
const canSend = (text.trim().length > 0 || files.length > 0) && !sending;
|
const canSend = (text.trim().length > 0 || files.length > 0) && !sending;
|
||||||
|
|
||||||
|
const mention = mentionDismissed ? undefined : findMentionQuery(text, caret);
|
||||||
|
const suggestions = mention ? filterParticipants(participants, mention.query).slice(0, MAX_SUGGESTIONS) : [];
|
||||||
|
const suggestOpen = suggestions.length > 0;
|
||||||
|
const active = Math.min(highlighted, suggestions.length - 1);
|
||||||
|
|
||||||
|
const pickMention = (participant: IChatParticipant): void => {
|
||||||
|
if (!mention) return;
|
||||||
|
const next = insertMention(text, mention, caret, participant);
|
||||||
|
setText(next.text);
|
||||||
|
setCaret(next.caret);
|
||||||
|
setHighlighted(0);
|
||||||
|
// Курсор ставим за вставленным ФИО — после перерисовки, когда текст уже в поле.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(next.caret, next.caret);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackCaret = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
|
||||||
|
setCaret(e.currentTarget.selectionStart || 0);
|
||||||
|
};
|
||||||
|
|
||||||
const send = (): void => {
|
const send = (): void => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
onSend(text, files)
|
onSend(text, files)
|
||||||
@@ -38,7 +73,26 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Enter отправляет, Shift+Enter переносит строку — привычное поведение чата.
|
// Enter отправляет, Shift+Enter переносит строку — привычное поведение чата.
|
||||||
|
// Пока открыта подсказка «@», стрелки ходят по ней, а Enter/Tab выбирают.
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||||
|
if (suggestOpen) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const step = e.key === 'ArrowDown' ? 1 : -1;
|
||||||
|
setHighlighted((active + step + suggestions.length) % suggestions.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
pickMention(suggestions[active]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setMentionDismissed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
send();
|
send();
|
||||||
@@ -57,16 +111,49 @@ export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend })
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.composer}>
|
<div className={styles.composer}>
|
||||||
<textarea
|
<div className={styles.inputWrap}>
|
||||||
className={styles.input}
|
<textarea
|
||||||
value={text}
|
ref={inputRef}
|
||||||
rows={2}
|
className={styles.input}
|
||||||
disabled={sending}
|
value={text}
|
||||||
placeholder="Сообщение… (Enter — отправить, Shift+Enter — новая строка)"
|
rows={2}
|
||||||
aria-label="Сообщение в обсуждение заявки"
|
disabled={sending}
|
||||||
onChange={(e) => setText(e.target.value)}
|
placeholder="Сообщение… (@ — отметить участника, Enter — отправить, Shift+Enter — новая строка)"
|
||||||
onKeyDown={handleKeyDown}
|
aria-label="Сообщение в обсуждение заявки"
|
||||||
/>
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={suggestOpen}
|
||||||
|
onChange={(e) => {
|
||||||
|
setText(e.target.value);
|
||||||
|
setCaret(e.target.selectionStart || 0);
|
||||||
|
setHighlighted(0);
|
||||||
|
setMentionDismissed(false);
|
||||||
|
}}
|
||||||
|
onSelect={trackCaret}
|
||||||
|
onClick={trackCaret}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
{suggestOpen && (
|
||||||
|
<ul className={styles.mentions} role="listbox" aria-label="Участники заявки">
|
||||||
|
{suggestions.map((p, i) => (
|
||||||
|
<li
|
||||||
|
key={p.email}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === active}
|
||||||
|
className={`${styles.mentionItem} ${i === active ? styles.mentionActive : ''}`}
|
||||||
|
// mousedown, а не click: иначе поле теряет фокус раньше, чем выбор сработает.
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pickMention(p);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlighted(i)}
|
||||||
|
>
|
||||||
|
<span className={styles.mentionName}>{p.name}</span>
|
||||||
|
<span className={styles.mentionEmail}>{p.email}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className={styles.composerActions}>
|
<div className={styles.composerActions}>
|
||||||
{/* Пока файлы выбраны, панель не сворачиваем: иначе прикреплённое
|
{/* Пока файлы выбраны, панель не сворачиваем: иначе прикреплённое
|
||||||
пропадает с глаз, и легко отправить не то, что собирался. */}
|
пропадает с глаз, и легко отправить не то, что собирался. */}
|
||||||
|
|||||||
@@ -167,6 +167,59 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inputWrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсказка «@» — НАД полем: панель заявки прокручивается, и под полем список
|
||||||
|
// уходил бы за нижний край.
|
||||||
|
.mentions {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
z-index: 10;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
border-radius: $r-md;
|
||||||
|
box-shadow: $sh-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentionItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentionActive {
|
||||||
|
background: $navy-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentionName {
|
||||||
|
font-size: $fs-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $navy-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mentionEmail {
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -27,11 +27,7 @@ interface IRequestChatProps {
|
|||||||
* Сообщения — штатные комментарии к строке списка (см. requestChat.service.ts).
|
* Сообщения — штатные комментарии к строке списка (см. requestChat.service.ts).
|
||||||
*/
|
*/
|
||||||
export const RequestChat: React.FC<IRequestChatProps> = ({ context, formKey, itemId, listId }) => {
|
export const RequestChat: React.FC<IRequestChatProps> = ({ context, formKey, itemId, listId }) => {
|
||||||
// Автора комментария SharePoint подписывает РЕАЛЬНОЙ учёткой — отладочная
|
const chat = useRequestChat(context, formKey, itemId, listId);
|
||||||
// подмена личности (shared/impersonation.ts) права доступа и автора записи не
|
|
||||||
// меняет. Поэтому «свои» сообщения ищем по настоящему пользователю.
|
|
||||||
const currentUserEmail = context.pageContext.user.email || '';
|
|
||||||
const chat = useRequestChat(formKey, itemId, listId, currentUserEmail);
|
|
||||||
const impersonated = getImpersonatedEmail();
|
const impersonated = getImpersonatedEmail();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +46,7 @@ export const RequestChat: React.FC<IRequestChatProps> = ({ context, formKey, ite
|
|||||||
{chat.error && <Alert variant="err">{chat.error}</Alert>}
|
{chat.error && <Alert variant="err">{chat.error}</Alert>}
|
||||||
|
|
||||||
<ChatMessageList messages={chat.messages} loading={chat.loading} />
|
<ChatMessageList messages={chat.messages} loading={chat.loading} />
|
||||||
<ChatComposer sending={chat.sending} onSend={chat.send} />
|
<ChatComposer sending={chat.sending} participants={chat.participants} onSend={chat.send} />
|
||||||
|
|
||||||
{/* Подмена личности не распространяется на комментарии — предупреждаем,
|
{/* Подмена личности не распространяется на комментарии — предупреждаем,
|
||||||
иначе при отладке непонятно, почему сообщение подписано не тем. */}
|
иначе при отладке непонятно, почему сообщение подписано не тем. */}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { friendlyErrorMessage } from '../diagnostics';
|
import { friendlyErrorMessage } from '../diagnostics';
|
||||||
|
import { getChatSelfNotify, PORTAL_PAGE_URL } from '../config';
|
||||||
|
import { requestLinkOnPage } from '../routing';
|
||||||
import { RequestTypeId } from '../../models/types';
|
import { RequestTypeId } from '../../models/types';
|
||||||
import { IChatMessage } from './models/IChatMessage';
|
import { IChatMessage } from './models/IChatMessage';
|
||||||
import { addChatMessage, getChatMessages } from './requestChat.service';
|
import { addChatMessage, getChatMessages, getChatRequestInfo, IChatRequestInfo } from './requestChat.service';
|
||||||
import { validateChatFiles } from './chatFiles.service';
|
import { validateChatFiles } from './chatFiles.service';
|
||||||
|
import { buildParticipants, IChatParticipant } from './chatMentions';
|
||||||
|
import { buildChatNotifyBody } from './chatNotify';
|
||||||
|
import { notifyChatMessage } from './chatNotify.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Как часто перечитываем переписку, пока заявка открыта. Чат — переписка живая,
|
* Как часто перечитываем переписку, пока заявка открыта. Чат — переписка живая,
|
||||||
@@ -18,6 +24,8 @@ export interface IRequestChat {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** Кого можно отметить через «@». Пусто, пока сведения о заявке не загрузились. */
|
||||||
|
participants: IChatParticipant[];
|
||||||
/**
|
/**
|
||||||
* Отправить сообщение с файлами (файлы необязательны). Пустое сообщение —
|
* Отправить сообщение с файлами (файлы необязательны). Пустое сообщение —
|
||||||
* без текста и без файлов — игнорируется. Возвращает true, если сообщение
|
* без текста и без файлов — игнорируется. Возвращает true, если сообщение
|
||||||
@@ -34,11 +42,17 @@ export interface IRequestChat {
|
|||||||
* Логика вынесена из компонента, чтобы RequestChat остался разметкой.
|
* Логика вынесена из компонента, чтобы RequestChat остался разметкой.
|
||||||
*/
|
*/
|
||||||
export function useRequestChat(
|
export function useRequestChat(
|
||||||
|
context: WebPartContext,
|
||||||
formKey: RequestTypeId,
|
formKey: RequestTypeId,
|
||||||
itemId: number,
|
itemId: number,
|
||||||
listId: string | undefined,
|
listId: string | undefined
|
||||||
currentUserEmail: string
|
|
||||||
): IRequestChat {
|
): IRequestChat {
|
||||||
|
// Автора комментария SharePoint подписывает РЕАЛЬНОЙ учёткой — отладочная
|
||||||
|
// подмена личности (shared/impersonation.ts) права доступа и автора записи не
|
||||||
|
// меняет. Поэтому «свои» сообщения ищем по настоящему пользователю.
|
||||||
|
const currentUserEmail = context.pageContext.user.email || '';
|
||||||
|
const currentUserName = context.pageContext.user.displayName || currentUserEmail;
|
||||||
|
const selfNotify = getChatSelfNotify();
|
||||||
const [messages, setMessages] = React.useState<IChatMessage[]>([]);
|
const [messages, setMessages] = React.useState<IChatMessage[]>([]);
|
||||||
const [loading, setLoading] = React.useState<boolean>(true);
|
const [loading, setLoading] = React.useState<boolean>(true);
|
||||||
const [sending, setSending] = React.useState<boolean>(false);
|
const [sending, setSending] = React.useState<boolean>(false);
|
||||||
@@ -48,6 +62,29 @@ export function useRequestChat(
|
|||||||
const [reloadSeq, setReloadSeq] = React.useState<number>(0);
|
const [reloadSeq, setReloadSeq] = React.useState<number>(0);
|
||||||
const reload = React.useCallback((): void => setReloadSeq((n) => n + 1), []);
|
const reload = React.useCallback((): void => setReloadSeq((n) => n + 1), []);
|
||||||
|
|
||||||
|
// Сведения о заявке (инициатор, маршрут, код) — один раз на заявку, без
|
||||||
|
// автообновления: маршрут за время открытой панели почти не меняется. Не
|
||||||
|
// загрузились — чат работает, только без отметок и без писем.
|
||||||
|
const [info, setInfo] = React.useState<IChatRequestInfo | undefined>(undefined);
|
||||||
|
React.useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setInfo(undefined);
|
||||||
|
getChatRequestInfo(context, formKey, itemId, listId)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (alive) setInfo(loaded);
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn('[Чат] Не удалось загрузить участников заявки', e));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context, formKey, itemId, listId]);
|
||||||
|
|
||||||
|
// В режиме проверки себя тоже можно отметить — отсекаем «себя» по пустому адресу.
|
||||||
|
const participants = React.useMemo(
|
||||||
|
() => (info ? buildParticipants(info.people, selfNotify ? '' : currentUserEmail) : []),
|
||||||
|
[info, selfNotify, currentUserEmail]
|
||||||
|
);
|
||||||
|
|
||||||
// Панель заявки не размонтируется при переходе на ДРУГУЮ заявку (меняются
|
// Панель заявки не размонтируется при переходе на ДРУГУЮ заявку (меняются
|
||||||
// только пропсы), поэтому прежнюю переписку убираем явно — иначе на экране
|
// только пропсы), поэтому прежнюю переписку убираем явно — иначе на экране
|
||||||
// на миг оставались бы чужие сообщения.
|
// на миг оставались бы чужие сообщения.
|
||||||
@@ -86,6 +123,29 @@ export function useRequestChat(
|
|||||||
};
|
};
|
||||||
}, [formKey, itemId, listId, currentUserEmail, reloadSeq]);
|
}, [formKey, itemId, listId, currentUserEmail, reloadSeq]);
|
||||||
|
|
||||||
|
// Письмо о сообщении — после записи комментария и без ожидания: сбой потока
|
||||||
|
// не должен выглядеть как неудачная отправка (см. notifyChatMessage).
|
||||||
|
const notifyAboutMessage = React.useCallback(
|
||||||
|
(text: string, fileNames: string[]): void => {
|
||||||
|
if (!info) return;
|
||||||
|
const body = buildChatNotifyBody({
|
||||||
|
initiatorEmail: info.initiator.email,
|
||||||
|
participants,
|
||||||
|
authorName: currentUserName,
|
||||||
|
authorEmail: currentUserEmail,
|
||||||
|
messageText: text,
|
||||||
|
fileNames,
|
||||||
|
requestCode: info.code,
|
||||||
|
requestUrl: requestLinkOnPage(PORTAL_PAGE_URL, formKey, itemId, listId),
|
||||||
|
requestTitle: info.title || info.code,
|
||||||
|
selfNotify
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-void -- письмо не ждём, ошибки notifyChatMessage глотает сам
|
||||||
|
if (body) void notifyChatMessage(context, body);
|
||||||
|
},
|
||||||
|
[info, participants, currentUserName, currentUserEmail, formKey, itemId, listId, selfNotify, context]
|
||||||
|
);
|
||||||
|
|
||||||
const send = React.useCallback(
|
const send = React.useCallback(
|
||||||
async (text: string, files: File[]): Promise<boolean> => {
|
async (text: string, files: File[]): Promise<boolean> => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
@@ -104,6 +164,10 @@ export function useRequestChat(
|
|||||||
await addChatMessage(formKey, itemId, listId, trimmed, files);
|
await addChatMessage(formKey, itemId, listId, trimmed, files);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
reload();
|
reload();
|
||||||
|
notifyAboutMessage(
|
||||||
|
trimmed,
|
||||||
|
files.map((f) => f.name)
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(`Не удалось отправить сообщение: ${friendlyErrorMessage(e)}`);
|
setError(`Не удалось отправить сообщение: ${friendlyErrorMessage(e)}`);
|
||||||
@@ -112,8 +176,8 @@ export function useRequestChat(
|
|||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[formKey, itemId, listId, reload]
|
[formKey, itemId, listId, reload, notifyAboutMessage]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { messages, loading, sending, error, send, reload };
|
return { messages, loading, sending, error, participants, send, reload };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,30 @@ export const FLOW_RESOURCE = 'https://service.flow.microsoft.com/';
|
|||||||
export const FLOW_NOTIFY_URL =
|
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';
|
'https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/07/workflows/b21516edf8a14287a65a39e4116dfe4f/triggers/manual/paths/invoke?api-version=1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Power Automate: поток оповещения о новом сообщении в чате заявки (shared/chat).
|
||||||
|
* Тот же механизм вызова, что у FLOW_NOTIFY_URL (Entra-триггер, AadHttpClient с
|
||||||
|
* ресурсом FLOW_RESOURCE), но тело другое — получателей поток НЕ ищет сам, их
|
||||||
|
* передаёт портал (см. shared/chat/chatNotify.ts):
|
||||||
|
* отмеченным через «@» — «Вас упомянули в чате по заявке №…»,
|
||||||
|
* инициатору (если его не отметили) — «Новое сообщение в чате по вашей заявке №…».
|
||||||
|
*/
|
||||||
|
export const FLOW_CHAT_NOTIFY_URL =
|
||||||
|
'https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/07/workflows/325d735d32de4e6989f4f06fc347e833/triggers/manual/paths/invoke?api-version=1';
|
||||||
|
|
||||||
|
// Чат заявки, режим проверки (свойство веб-части chatSelfNotify): письма уходят и
|
||||||
|
// о СВОИХ сообщениях, и себя можно отметить через «@». По умолчанию выключен —
|
||||||
|
// писать самому себе в обычной работе незачем.
|
||||||
|
let _chatSelfNotify = false;
|
||||||
|
|
||||||
|
export function setChatSelfNotify(enabled: boolean): void {
|
||||||
|
_chatSelfNotify = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatSelfNotify(): boolean {
|
||||||
|
return _chatSelfNotify;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Microsoft Dataverse (Power Platform) — рабочая среда «По умолчанию»
|
* Microsoft Dataverse (Power Platform) — рабочая среда «По умолчанию»
|
||||||
* (org95ca17d6.crm4.dynamics.com). NB: таблица cree9_demandline существует и в
|
* (org95ca17d6.crm4.dynamics.com). NB: таблица cree9_demandline существует и в
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function parseFieldChanges(raw: unknown): IFieldChange[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Журнал → JSON для колонки (null — журнал пуст, колонку очищаем). */
|
/** Журнал → JSON для колонки (null — журнал пуст, колонку очищаем). */
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- null очищает колонку SharePoint, undefined поле бы не тронул
|
||||||
export function serializeFieldChanges(changes: IFieldChange[]): string | null {
|
export function serializeFieldChanges(changes: IFieldChange[]): string | null {
|
||||||
return changes.length > 0 ? JSON.stringify(changes) : null;
|
return changes.length > 0 ? JSON.stringify(changes) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -535,6 +535,16 @@ export function routeUrl(route: IRoute): string {
|
|||||||
* другого способа узнать её путь у клиентского кода нет.
|
* другого способа узнать её путь у клиентского кода нет.
|
||||||
*/
|
*/
|
||||||
export function requestLinkUrl(formKey: string, itemId: number, listId?: string): string {
|
export function requestLinkUrl(formKey: string, itemId: number, listId?: string): string {
|
||||||
|
return requestLinkOnPage(`${window.location.origin}${window.location.pathname}`, formKey, itemId, listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* То же, что requestLinkUrl, но на ЗАДАННОЙ странице портала, а не на текущей.
|
||||||
|
* Нужна там, где текущий адрес может оказаться не страницей портала: внутри
|
||||||
|
* Teams это служебная страница вкладки, и ссылка из неё в письме не откроется
|
||||||
|
* (см. PORTAL_PAGE_URL в config.ts).
|
||||||
|
*/
|
||||||
|
export function requestLinkOnPage(pageUrl: string, formKey: string, itemId: number, listId?: string): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('rpmode', 'approve');
|
params.set('rpmode', 'approve');
|
||||||
params.set('rptype', formKey);
|
params.set('rptype', formKey);
|
||||||
@@ -542,7 +552,7 @@ export function requestLinkUrl(formKey: string, itemId: number, listId?: string)
|
|||||||
if (listId) {
|
if (listId) {
|
||||||
params.set('rplist', listId);
|
params.set('rplist', listId);
|
||||||
}
|
}
|
||||||
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
return `${pageUrl}?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user