diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 6890ea6..988eb37 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -24,7 +24,8 @@ import { EDO_API_BASE_URL_STAGE, setApprovalApiBaseUrl, setEdoApiBaseUrl, - setNotificationSettings + setNotificationSettings, + setChatSelfNotify } from './shared/config'; import { initListPartitions } from './shared/approval'; import { ErrorOverlay } from './shared/ui'; @@ -54,6 +55,11 @@ export interface IRequestPortalWebPartProps { notificationsEnabled?: boolean; /** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */ notifyOnlyTo?: string; + /** + * Для проверки чата: письма и о СВОИХ сообщениях, и можно отметить себя через «@». + * По умолчанию выключено (undefined — тоже «выкл»). + */ + chatSelfNotify?: boolean; } export default class RequestPortalWebPart extends BaseClientSideWebPart { @@ -126,6 +132,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart { @@ -259,6 +266,12 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart = ({ context, formKey, item {/* Доступно всем, кто может открыть заявку: автору, всем согласующим (и уже решившим, и ещё ждущим своей очереди) и исполнителям — в отличие от панели решения, которая видна только текущему шагу. - Пока включено только для «Обходного листа» — см. isChatEnabledFor. */} + Включено не для всех типов заявок — см. isChatEnabledFor. */} {isChatEnabledFor(formKey) && ( )} diff --git a/src/webparts/requestPortal/features/edit/EditFeature.tsx b/src/webparts/requestPortal/features/edit/EditFeature.tsx index f2b20a9..bec5f1e 100644 --- a/src/webparts/requestPortal/features/edit/EditFeature.tsx +++ b/src/webparts/requestPortal/features/edit/EditFeature.tsx @@ -73,7 +73,7 @@ const EditFeature: React.FC = ({ context, formKey, itemId, li {/* Обсуждение — то же самое, что видят согласующие (комментарии к строке заявки). Здесь оно особенно нужно: заявку правят как раз после возврата на доработку, и уточнить причину логично не уходя с формы. - Пока включено только для «Обходного листа» — см. isChatEnabledFor. */} + Включено не для всех типов заявок — см. isChatEnabledFor. */} {isChatEnabledFor(formKey) && ( )} diff --git a/src/webparts/requestPortal/features/myRequests/filtersStorage.ts b/src/webparts/requestPortal/features/myRequests/filtersStorage.ts index 40e36ad..22ec998 100644 --- a/src/webparts/requestPortal/features/myRequests/filtersStorage.ts +++ b/src/webparts/requestPortal/features/myRequests/filtersStorage.ts @@ -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 { if (!raw) return undefined; let parsed: { filters?: Record; sort?: Record }; diff --git a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx index 1fe105b..335b6d8 100644 --- a/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx +++ b/src/webparts/requestPortal/forms/BusinessTrip/BusinessTripForm.tsx @@ -27,8 +27,10 @@ import { IApproverToWrite, IUseMatrixEditor, unassignedStepMessage, - resolveWriteListId + resolveWriteListId, + composeRemoveRowRule } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { @@ -244,7 +246,11 @@ const BusinessTripForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> {!executors.loading && executors.rows.length > 0 && ( = ({ title="Исполнение (после согласования)" roleLabel="Исполнитель" emptyText="Исполнители не заданы по маршруту." + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> )} diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index ff883da..da65523 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -342,6 +342,7 @@ const ClearanceForm: React.FC = ({ title="Исполнение (после согласования)" roleLabel="Исполнитель" emptyText="Исполнители не заданы по маршруту." + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> )} diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index 39d8871..cc98ac2 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -31,7 +31,8 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - executorsToWrite + executorsToWrite, + composeRemoveRowRule } from '../../shared/approval'; import { uploadAttachments, @@ -444,6 +445,7 @@ const Ticket1CForm: React.FC = ({ approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} // Пустой маршрут — штатная ситуация: для части видов работ согласование // не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута // показывается отдельно (matrix.error) и блокирует отправку. @@ -457,6 +459,7 @@ const Ticket1CForm: React.FC = ({ title="Исполнение" roleLabel="Исполнитель" emptyText="Исполнители не назначены." + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> )} diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 95aa876..57e6731 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -34,7 +34,7 @@ export { getRequestTypeAccessEmails, resolveRoute } from './approvalRoute.service'; -export { hasRequestTypeAccess, accessibleAllRequestsTypes } from './requestTypeAccess'; +export { hasRequestTypeAccess, accessibleAllRequestsTypes, emailsForType } from './requestTypeAccess'; export { useAccessibleAllRequestsFormKeys } from './useAccessibleAllRequestsFormKeys'; export { initListPartitions, @@ -59,7 +59,7 @@ export { approvalServiceFor } from './approvalServiceFactory'; export { ApproverService } from './approverDecision.service'; export type { ApproverDecision, IPendingRow } from './approverDecision.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 { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor'; diff --git a/src/webparts/requestPortal/shared/approval/notify.service.ts b/src/webparts/requestPortal/shared/approval/notify.service.ts index bc328f2..0955e2e 100644 --- a/src/webparts/requestPortal/shared/approval/notify.service.ts +++ b/src/webparts/requestPortal/shared/approval/notify.service.ts @@ -12,14 +12,15 @@ export interface INotifyTarget { approvalListName: string; // ApproveListName (список согласования) } -// POST в поток оповещения (Entra-триггер) через AadHttpClient — обычный fetch → 401, -// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS). -async function postNotify(context: WebPartContext, body: Record): Promise { - if (!FLOW_NOTIFY_URL) { +// POST в поток Power Automate (Entra-триггер) через AadHttpClient — обычный fetch → 401, +// AadHttpClient даёт токен Flow Service (и снимает вопрос CORS). Общий для всех +// потоков оповещения портала (согласование, чат заявки — shared/chat/chatNotify.service.ts). +export async function postToFlow(context: WebPartContext, flowUrl: string, body: object): Promise { + if (!flowUrl) { return; } 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' }, body: JSON.stringify(body), }); @@ -68,7 +69,7 @@ async function notifyRow( if (!(await isNotifyAllowed(approval, row.approverId))) { return; } - await postNotify(context, { + await postToFlow(context, FLOW_NOTIFY_URL, { RequestTypeId: target.requestTypeId, RowID: row.id, RequestID: requestId, diff --git a/src/webparts/requestPortal/shared/approval/requestTypeAccess.ts b/src/webparts/requestPortal/shared/approval/requestTypeAccess.ts index 6719428..1a7a35d 100644 --- a/src/webparts/requestPortal/shared/approval/requestTypeAccess.ts +++ b/src/webparts/requestPortal/shared/approval/requestTypeAccess.ts @@ -9,7 +9,12 @@ import { effectiveUserEmail } from '../impersonation'; // кешируется, чтобы временный сбой сети не «залип» на весь сеанс. const cache = new Map>(); -function emailsForType(context: WebPartContext, requestTypeId: number): Promise { +/** + * Кому выдан доступ видеть ВСЕ заявки этого типа (раздел «Все заявки»). Кешируется + * на сеанс. Нужен не только для проверки доступа: эти люди могут писать в чат + * любой заявки типа, значит, их можно и отмечать в нём (см. shared/chat). + */ +export function emailsForType(context: WebPartContext, requestTypeId: number): Promise { let pending = cache.get(requestTypeId); if (!pending) { pending = getRequestTypeAccessEmails(context, requestTypeId).catch((e) => { diff --git a/src/webparts/requestPortal/shared/approverNote/linkify.test.ts b/src/webparts/requestPortal/shared/approverNote/linkify.test.ts index 963f909..9213d24 100644 --- a/src/webparts/requestPortal/shared/approverNote/linkify.test.ts +++ b/src/webparts/requestPortal/shared/approverNote/linkify.test.ts @@ -23,6 +23,7 @@ describe('linkify', () => { }); it('не-http схемы ссылкой не становятся', () => { + // eslint-disable-next-line no-script-url -- проверяем как раз то, что такая строка ссылкой не станет expect(linkify('javascript:alert(1)')).toEqual([{ text: 'javascript:alert(1)' }]); }); diff --git a/src/webparts/requestPortal/shared/chat/chatEnabled.test.ts b/src/webparts/requestPortal/shared/chat/chatEnabled.test.ts index 15088d1..ca6c555 100644 --- a/src/webparts/requestPortal/shared/chat/chatEnabled.test.ts +++ b/src/webparts/requestPortal/shared/chat/chatEnabled.test.ts @@ -1,14 +1,16 @@ import { isChatEnabledFor } from './chatEnabled'; describe('isChatEnabledFor', () => { - it('включён для «Обходного листа»', () => { + it('включён для Обходного листа, подбора, приёма и заявки в 1С', () => { 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('выключен для остальных типов заявок', () => { expect(isChatEnabledFor('hr-vacation')).toBe(false); - expect(isChatEnabledFor('it-1c-ticket')).toBe(false); - expect(isChatEnabledFor('recruiting-hire')).toBe(false); + expect(isChatEnabledFor('recruiting-referral')).toBe(false); }); it('незнакомый ключ — тоже выключен, а не бросает', () => { diff --git a/src/webparts/requestPortal/shared/chat/chatEnabled.ts b/src/webparts/requestPortal/shared/chat/chatEnabled.ts index b3e6b6a..ef5cf19 100644 --- a/src/webparts/requestPortal/shared/chat/chatEnabled.ts +++ b/src/webparts/requestPortal/shared/chat/chatEnabled.ts @@ -3,9 +3,9 @@ import { RequestTypeId } from '../../models/types'; /** * Типы заявок, для которых показывается чат (shared/chat). * - * Пилот: только «Обходной лист» (forms/Clearance, FORM_ID = 'clearance-form'). - * Остальным формам чат пока не нужен — решение сузить область до одного типа, - * а не включать сразу на все девять. + * Включаем по одному типу, а не сразу на все: «Обходной лист» — первый пилот, + * с 2026-10-14 добавлены «Заявка на подбор», «Заявка на приём» и «Заявка в + * отдел 1С». * * Ключ — строковый литерал, а не импорт FORM_ID из forms/Clearance/model: * shared/* не должен зависеть от forms/* (зависимость идёт в обратную сторону @@ -13,7 +13,12 @@ import { RequestTypeId } from '../../models/types'; * так что литерал здесь не менее надёжен, чем в LIST_TITLE_BASE * (shared/listTitles.ts), где тем же приёмом перечислены все типы заявок. */ -const CHAT_ENABLED_FORM_IDS: ReadonlySet = new Set(['clearance-form']); +const CHAT_ENABLED_FORM_IDS: ReadonlySet = new Set([ + 'clearance-form', + 'recruiting-selection', + 'recruiting-hire', + 'it-1c-ticket' +]); /** Показывать ли чат по заявке для этого типа. Проверяется ДО монтирования * RequestChat — иначе хук useRequestChat опрашивал бы SharePoint (загрузка + diff --git a/src/webparts/requestPortal/shared/chat/chatMentions.test.ts b/src/webparts/requestPortal/shared/chat/chatMentions.test.ts new file mode 100644 index 0000000..2dcd6c2 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatMentions.test.ts @@ -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([]); + }); +}); diff --git a/src/webparts/requestPortal/shared/chat/chatMentions.ts b/src/webparts/requestPortal/shared/chat/chatMentions.ts new file mode 100644 index 0000000..702f8e6 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatMentions.ts @@ -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 | 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); +} diff --git a/src/webparts/requestPortal/shared/chat/chatNotify.service.ts b/src/webparts/requestPortal/shared/chat/chatNotify.service.ts new file mode 100644 index 0000000..9d35f62 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatNotify.service.ts @@ -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 { + try { + await postToFlow(context, FLOW_CHAT_NOTIFY_URL, body); + } catch (e) { + console.warn('[Чат] Оповещение о сообщении не отправлено', e); + } +} diff --git a/src/webparts/requestPortal/shared/chat/chatNotify.test.ts b/src/webparts/requestPortal/shared/chat/chatNotify.test.ts new file mode 100644 index 0000000..ffc622f --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatNotify.test.ts @@ -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(); + }); +}); diff --git a/src/webparts/requestPortal/shared/chat/chatNotify.ts b/src/webparts/requestPortal/shared/chat/chatNotify.ts new file mode 100644 index 0000000..209adaf --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatNotify.ts @@ -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 + }; +} diff --git a/src/webparts/requestPortal/shared/chat/index.ts b/src/webparts/requestPortal/shared/chat/index.ts index 880fa18..9a1250d 100644 --- a/src/webparts/requestPortal/shared/chat/index.ts +++ b/src/webparts/requestPortal/shared/chat/index.ts @@ -3,7 +3,19 @@ export { RequestChat } from './ui/RequestChat'; export { isChatEnabledFor } from './chatEnabled'; export { useRequestChat } 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 { uploadChatFiles, chatFilesFolderUrl, diff --git a/src/webparts/requestPortal/shared/chat/requestChat.service.ts b/src/webparts/requestPortal/shared/chat/requestChat.service.ts index 371bbbc..a67d6e9 100644 --- a/src/webparts/requestPortal/shared/chat/requestChat.service.ts +++ b/src/webparts/requestPortal/shared/chat/requestChat.service.ts @@ -6,6 +6,12 @@ import { RequestTypeId } from '../../models/types'; import { IChatMessage } from './models/IChatMessage'; import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages'; 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); 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 { + 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 { + const [item, matrix, viewers] = await Promise.all([ + resolveDataList(formKey, listId) + .items.getById(itemId) + .select('Title', 'RequestUniqueCode', 'Author/Title', 'Author/EMail') + .expand('Author')() as Promise>, + 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] + }; +} diff --git a/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx b/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx index ba7dafc..266f052 100644 --- a/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx +++ b/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx @@ -2,22 +2,33 @@ import * as React from 'react'; import styles from './RequestChat.module.scss'; import { Button, FileDropzone, Icon } from '../../ui'; import { MAX_FILES_PER_MESSAGE, MAX_FILE_SIZE_BYTES } from '../chatFiles.service'; +import { filterParticipants, findMentionQuery, IChatParticipant, insertMention } from '../chatMentions'; interface IChatComposerProps { sending: boolean; + /** Кого можно отметить через «@» (см. chatMentions.ts). */ + participants: IChatParticipant[]; /** Возвращает true, если сообщение записано — только тогда очищаем поле. */ onSend: (text: string, files: File[]) => Promise; } const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024)); +/** Сколько вариантов показывать в подсказке «@» — дальше пусть уточняют набором. */ +const MAX_SUGGESTIONS = 8; + /** * Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный * textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш * не пробрасывает. */ -export const ChatComposer: React.FC = ({ sending, onSend }) => { +export const ChatComposer: React.FC = ({ sending, participants, onSend }) => { const [text, setText] = React.useState(''); + const [caret, setCaret] = React.useState(0); + const [highlighted, setHighlighted] = React.useState(0); + // Esc закрывает подсказку до следующего набора — иначе её не убрать, не стерев «@». + const [mentionDismissed, setMentionDismissed] = React.useState(false); + const inputRef = React.useRef(null); const [files, setFiles] = React.useState([]); const [filesOpen, setFilesOpen] = React.useState(false); @@ -25,6 +36,30 @@ export const ChatComposer: React.FC = ({ sending, onSend }) // текст отправку не блокирует. 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): void => { + setCaret(e.currentTarget.selectionStart || 0); + }; + const send = (): void => { if (!canSend) return; onSend(text, files) @@ -38,7 +73,26 @@ export const ChatComposer: React.FC = ({ sending, onSend }) }; // Enter отправляет, Shift+Enter переносит строку — привычное поведение чата. + // Пока открыта подсказка «@», стрелки ходят по ней, а Enter/Tab выбирают. const handleKeyDown = (e: React.KeyboardEvent): 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) { e.preventDefault(); send(); @@ -57,16 +111,49 @@ export const ChatComposer: React.FC = ({ sending, onSend }) )}
-