diff --git a/src/webparts/requestPortal/features/approve/ApproveFeature.tsx b/src/webparts/requestPortal/features/approve/ApproveFeature.tsx index 6df2bcd..88591ab 100644 --- a/src/webparts/requestPortal/features/approve/ApproveFeature.tsx +++ b/src/webparts/requestPortal/features/approve/ApproveFeature.tsx @@ -3,6 +3,7 @@ import type { WebPartContext } from '@microsoft/sp-webpart-base'; import styles from './ApproveFeature.module.scss'; import { getViewComponent } from '../../forms/viewRegistry'; import { isConfirmedStaleListId } from '../../shared/approval'; +import { RequestChat, isChatEnabledFor } from '../../shared/chat'; export interface IApproveFeatureProps { context: WebPartContext; @@ -14,8 +15,14 @@ export interface IApproveFeatureProps { listId?: string; } -// Фича «Согласование»: открывается по ссылке из потока (mode=approve&type=&id=). -// Показывает заявку в режиме чтения; панель решения добавим следующим шагом. +// Фича «Согласование»: открывается по ссылке из потока (mode=approve&type=&id=) +// и кликом по строке любой таблицы заявок. Показывает заявку в режиме чтения +// (панель решения — внутри самой формы просмотра, она зависит от её полей). +// +// Обсуждение (чат) добавлено ЗДЕСЬ, а не в девяти <Форма>View.tsx: оно ничего не +// знает о полях конкретной формы, а эта фича — единственный вход в просмотр +// заявки, общий для всех типов. Стоит вне .rp-print-root формы, поэтому в +// печатную версию заявки переписка не попадает. const ApproveFeature: React.FC = ({ context, formKey, itemId, listId }) => { const ViewComponent = getViewComponent(formKey); @@ -31,9 +38,18 @@ const ApproveFeature: React.FC = ({ context, formKey, item {partitionError ? (
{partitionError}
) : ViewComponent ? ( - Загрузка…}> - - + <> + Загрузка…}> + + + {/* Доступно всем, кто может открыть заявку: автору, всем согласующим + (и уже решившим, и ещё ждущим своей очереди) и исполнителям — + в отличие от панели решения, которая видна только текущему шагу. + Пока включено только для «Обходного листа» — см. isChatEnabledFor. */} + {isChatEnabledFor(formKey) && ( + + )} + ) : (
Для формы «{formKey}» режим согласования пока не настроен.
)} diff --git a/src/webparts/requestPortal/features/edit/EditFeature.tsx b/src/webparts/requestPortal/features/edit/EditFeature.tsx index df2a4c0..401d502 100644 --- a/src/webparts/requestPortal/features/edit/EditFeature.tsx +++ b/src/webparts/requestPortal/features/edit/EditFeature.tsx @@ -5,6 +5,7 @@ import { getFormComponent } from '../../forms/registry'; import { REQUEST_TYPES } from '../../data/requests'; import { emitRequestsChanged } from '../../shared/requestEvents'; import { isConfirmedStaleListId } from '../../shared/approval'; +import { RequestChat, isChatEnabledFor } from '../../shared/chat'; export interface IEditFeatureProps { context: WebPartContext; @@ -66,6 +67,13 @@ const EditFeature: React.FC = ({ context, formKey, itemId, li }} /> + {/* Обсуждение — то же самое, что видят согласующие (комментарии к строке + заявки). Здесь оно особенно нужно: заявку правят как раз после + возврата на доработку, и уточнить причину логично не уходя с формы. + Пока включено только для «Обходного листа» — см. isChatEnabledFor. */} + {isChatEnabledFor(formKey) && ( + + )} ); }; diff --git a/src/webparts/requestPortal/shared/chat/chatEnabled.test.ts b/src/webparts/requestPortal/shared/chat/chatEnabled.test.ts new file mode 100644 index 0000000..15088d1 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatEnabled.test.ts @@ -0,0 +1,17 @@ +import { isChatEnabledFor } from './chatEnabled'; + +describe('isChatEnabledFor', () => { + it('включён для «Обходного листа»', () => { + expect(isChatEnabledFor('clearance-form')).toBe(true); + }); + + it('выключен для остальных типов заявок', () => { + expect(isChatEnabledFor('hr-vacation')).toBe(false); + expect(isChatEnabledFor('it-1c-ticket')).toBe(false); + expect(isChatEnabledFor('recruiting-hire')).toBe(false); + }); + + it('незнакомый ключ — тоже выключен, а не бросает', () => { + expect(isChatEnabledFor('что-то-новое')).toBe(false); + }); +}); diff --git a/src/webparts/requestPortal/shared/chat/chatEnabled.ts b/src/webparts/requestPortal/shared/chat/chatEnabled.ts new file mode 100644 index 0000000..b3e6b6a --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatEnabled.ts @@ -0,0 +1,23 @@ +import { RequestTypeId } from '../../models/types'; + +/** + * Типы заявок, для которых показывается чат (shared/chat). + * + * Пилот: только «Обходной лист» (forms/Clearance, FORM_ID = 'clearance-form'). + * Остальным формам чат пока не нужен — решение сузить область до одного типа, + * а не включать сразу на все девять. + * + * Ключ — строковый литерал, а не импорт FORM_ID из forms/Clearance/model: + * shared/* не должен зависеть от forms/* (зависимость идёт в обратную сторону + * — формы берут общее из shared), а RequestTypeId — обычная строка, не enum, + * так что литерал здесь не менее надёжен, чем в LIST_TITLE_BASE + * (shared/listTitles.ts), где тем же приёмом перечислены все типы заявок. + */ +const CHAT_ENABLED_FORM_IDS: ReadonlySet = new Set(['clearance-form']); + +/** Показывать ли чат по заявке для этого типа. Проверяется ДО монтирования + * RequestChat — иначе хук useRequestChat опрашивал бы SharePoint (загрузка + + * автообновление раз в 17с) и для заявок, где чат не включён. */ +export function isChatEnabledFor(formKey: RequestTypeId): boolean { + return CHAT_ENABLED_FORM_IDS.has(formKey); +} diff --git a/src/webparts/requestPortal/shared/chat/chatFileNames.test.ts b/src/webparts/requestPortal/shared/chat/chatFileNames.test.ts new file mode 100644 index 0000000..fdcae55 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatFileNames.test.ts @@ -0,0 +1,48 @@ +import { displayFileName, fileStamp, sanitizeFileName, storedFileName } from './chatFileNames'; + +describe('sanitizeFileName', () => { + it('заменяет запрещённые SharePoint символы', () => { + expect(sanitizeFileName('от 24/08 №1: акт*.pdf')).toBe('от 24_08 №1_ акт_.pdf'); + }); + + it('убирает точки в конце — такое имя SharePoint не принимает', () => { + expect(sanitizeFileName('акт...')).toBe('акт'); + }); + + it('пустое имя заменяет заглушкой, а не пустой строкой', () => { + expect(sanitizeFileName(' ')).toBe('файл'); + }); + + it('длинное имя обрезает, сохраняя расширение', () => { + const long = `${'я'.repeat(200)}.pdf`; + const result = sanitizeFileName(long); + + expect(result.length).toBeLessThanOrEqual(120); + expect(result.endsWith('.pdf')).toBe(true); + }); +}); + +describe('storedFileName / displayFileName', () => { + it('складское имя разбирается обратно в исходное', () => { + const stored = storedFileName('20260824-153012', 1, 'акт приёмки.pdf'); + + expect(stored).toBe('20260824-153012-1__акт приёмки.pdf'); + expect(displayFileName(stored)).toBe('акт приёмки.pdf'); + }); + + it('двойное подчёркивание в исходном имени не ломает разбор', () => { + const stored = storedFileName('20260824-153012', 2, 'акт__итог.pdf'); + expect(displayFileName(stored)).toBe('акт__итог.pdf'); + }); + + it('файл, положенный в библиотеку не порталом, показывается как есть', () => { + expect(displayFileName('справка.pdf')).toBe('справка.pdf'); + }); + + it('файлы одного сообщения получают разные имена', () => { + const stamp = fileStamp(new Date(2026, 7, 24, 15, 30, 12)); + + expect(stamp).toBe('20260824-153012'); + expect(storedFileName(stamp, 1, 'скан.jpg')).not.toBe(storedFileName(stamp, 2, 'скан.jpg')); + }); +}); diff --git a/src/webparts/requestPortal/shared/chat/chatFileNames.ts b/src/webparts/requestPortal/shared/chat/chatFileNames.ts new file mode 100644 index 0000000..83eb650 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatFileNames.ts @@ -0,0 +1,77 @@ +/** + * Имена файлов чата. Модуль чистый (без SharePoint) и покрыт тестами: от + * формата имени зависит и то, что файл вообще запишется, и то, каким он + * покажется в переписке. + * + * Имя в библиотеке: «<метка времени>-<номер>__<исходное имя>», например + * «20260824-153012-1__акт приёмки.pdf». Префикс нужен, чтобы два человека + * (или один дважды) могли прикрепить к одной заявке файлы с одинаковым + * названием — без него второй файл перезаписал бы первый. + */ + +/** Разделитель служебного префикса и исходного имени. */ +const SEPARATOR = '__'; + +/** + * Запрещённые SharePoint символы в имени файла (" * : < > ? / \ |), плюс # % ~: + * формально они допустимы в SPO, но ломают прямые ссылки на файл. + */ +const FORBIDDEN_CHARS = /["*:<>?/\\|#%~]/g; + +/** + * Предел длины исходной части имени. Общий предел пути в SharePoint — 400 + * символов, и в него входят адрес сайта, имя библиотеки и папка заявки; + * 120 символов на само имя оставляют запас и при этом не режут нормальные + * названия документов. + */ +const MAX_NAME_LENGTH = 120; + +function splitExtension(name: string): { base: string; extension: string } { + const dot = name.lastIndexOf('.'); + // Точка в начале — это не расширение, а имя вида «.gitignore». + if (dot <= 0) return { base: name, extension: '' }; + return { base: name.slice(0, dot), extension: name.slice(dot) }; +} + +/** + * Привести имя файла к тому, что SharePoint примет: убрать запрещённые символы + * и обрезать слишком длинное имя, СОХРАНИВ расширение (без него файл не + * откроется нужным приложением). + */ +export function sanitizeFileName(name: string): string { + const cleaned = name.replace(FORBIDDEN_CHARS, '_').replace(/\s+/g, ' ').trim(); + // Имя, оканчивающееся точкой, SharePoint тоже не принимает. + const withoutTrailingDots = cleaned.replace(/\.+$/, ''); + if (!withoutTrailingDots) return 'файл'; + + if (withoutTrailingDots.length <= MAX_NAME_LENGTH) return withoutTrailingDots; + + const { base, extension } = splitExtension(withoutTrailingDots); + const room = MAX_NAME_LENGTH - extension.length; + // Расширение длиннее всего лимита — вырожденный случай, режем как есть. + if (room <= 0) return withoutTrailingDots.slice(0, MAX_NAME_LENGTH); + return `${base.slice(0, room)}${extension}`; +} + +function pad2(value: number): string { + return value < 10 ? `0${value}` : `${value}`; +} + +/** Метка времени для префикса: «20260824-153012». */ +export function fileStamp(date: Date): string { + const day = `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`; + const time = `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`; + return `${day}-${time}`; +} + +/** Имя, под которым файл ляжет в библиотеку. index — порядок файла в сообщении. */ +export function storedFileName(stamp: string, index: number, originalName: string): string { + return `${stamp}-${index}${SEPARATOR}${sanitizeFileName(originalName)}`; +} + +/** Исходное имя из складского: то, что видит пользователь в переписке. */ +export function displayFileName(storedName: string): string { + const at = storedName.indexOf(SEPARATOR); + // Разделителя нет — файл положили не порталом; показываем имя как есть. + return at < 0 ? storedName : storedName.slice(at + SEPARATOR.length); +} diff --git a/src/webparts/requestPortal/shared/chat/chatFiles.service.ts b/src/webparts/requestPortal/shared/chat/chatFiles.service.ts new file mode 100644 index 0000000..fd67f91 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatFiles.service.ts @@ -0,0 +1,174 @@ +import '@pnp/sp/webs'; +import '@pnp/sp/lists'; +import '@pnp/sp/items'; +import '@pnp/sp/folders'; +import '@pnp/sp/files'; +import type { IFolder } from '@pnp/sp/folders'; +import { getSP } from '../pnp/pnpjs'; +import { resolveDataList } from '../dataList'; +import { CHAT_FILES_LIBRARY_TITLE } from '../listTitles'; +import { RequestTypeId } from '../../models/types'; +import { fileStamp, sanitizeFileName, storedFileName } from './chatFileNames'; + +/** + * Файлы, прикреплённые к сообщениям чата, — библиотека «Файлы чата» на сайте + * данных, папка на каждую заявку: «Файлы чата/», например + * «Файлы чата/13-2608-42» — тот же код, что показан в заголовке заявки (см. + * FormCard subtitle в <Форма>View.tsx). Так папку легко узнать в самой + * библиотеке, не открывая портал. + * + * Почему не вложения самой заявки (shared/attachments): вложения — это + * документы заявки, они попадают в её карточку и печать. Файл из переписки — + * не документ заявки, а часть разговора; смешивать их значит засорять заявку. + * + * Связь «файл → сообщение» хранится в тексте комментария (см. FILE_MARKER в + * chatMessages.ts), поэтому чтение переписки НЕ обращается к библиотеке вовсе, + * пока в ней действительно нет файлов. + */ + +/** Больше 10 МБ через обычную загрузку слать не стоит — это переписка, а не файлообмен. */ +export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; + +/** Сколько файлов можно приложить к одному сообщению. */ +export const MAX_FILES_PER_MESSAGE = 20; + +// Адрес библиотеки в пределах сессии не меняется, а нужен при каждой загрузке +// переписки с файлами. Ошибку НЕ кешируем: не отвечавший на холодном старте +// запрос должен повториться, а не залипнуть до конца сессии (тот же приём, что +// в shared/approval/siteUser.ts). +let libraryRootPromise: Promise | undefined; + +/** + * Серверный относительный адрес корня библиотеки, например + * «/sites/requests/Файлы чата». + * + * Резолвим по ЗАГОЛОВКУ списка, а не собираем строкой из адреса сайта: url + * библиотеки может не совпадать с её названием (и меняться при переносе), а + * заголовок — то, что реально настраивал администратор. + */ +function chatLibraryRoot(): Promise { + if (!libraryRootPromise) { + libraryRootPromise = getSP() + .web.lists.getByTitle(CHAT_FILES_LIBRARY_TITLE) + .rootFolder.select('ServerRelativeUrl')() + .then((folder) => folder.ServerRelativeUrl as string) + .catch((e) => { + libraryRootPromise = undefined; + throw new Error( + `Библиотека «${CHAT_FILES_LIBRARY_TITLE}» недоступна на сайте данных: ${(e as Error).message}` + ); + }); + } + return libraryRootPromise; +} + +/** + * Имя папки заявки в библиотеке — RequestUniqueCode элемента. + * + * Код читаем из самого элемента, а не собираем строкой (formKey+itemId), чтобы + * папка называлась ровно тем номером, который видит пользователь на заявке. + * Код записывается сразу после создания элемента (см. saveRequestItem в + * shared/dataList.ts) и с этого момента не меняется, поэтому лишний раз его + * не кешируем: запрос — это одно select-поле, а не тяжёлая операция. + * + * Пустой код (гипотетическая гонка на только что созданной заявке) — откат на + * старую схему имени, чтобы папка всё равно нашлась однозначно. + */ +async function requestFolderName( + formKey: RequestTypeId, + itemId: number, + listId: string | undefined +): Promise { + const item = (await resolveDataList(formKey, listId) + .items.getById(itemId) + .select('RequestUniqueCode')()) as { RequestUniqueCode?: string }; + const code = (item.RequestUniqueCode || '').trim(); + return sanitizeFileName(code || `${formKey}-${itemId}`); +} + +/** Адрес папки заявки. Папки может ещё не существовать — для чтения ссылок это неважно. */ +export async function chatFilesFolderUrl( + formKey: RequestTypeId, + itemId: number, + listId?: string +): Promise { + const [root, name] = await Promise.all([ + chatLibraryRoot(), + requestFolderName(formKey, itemId, listId) + ]); + return `${root}/${name}`; +} + +/** + * Папка заявки, гарантированно существующая. + * + * Проверяем существование отдельным запросом, а не создаём «поверх»: + * addSubFolderUsingPath на существующей папке возвращает ошибку, а + * addUsingPath с overwrite=true СНОСИТ содержимое — то есть все ранее + * приложенные к заявке файлы. + */ +async function ensureRequestFolder( + formKey: RequestTypeId, + itemId: number, + listId: string | undefined +): Promise { + const [root, name] = await Promise.all([ + chatLibraryRoot(), + requestFolderName(formKey, itemId, listId) + ]); + const folder = getSP().web.getFolderByServerRelativePath(`${root}/${name}`); + + const exists = await folder + .select('Exists')() + .then((info) => !!info.Exists) + .catch(() => false); + + if (!exists) { + await getSP().web.getFolderByServerRelativePath(root).addSubFolderUsingPath(name); + } + return folder; +} + +/** + * Загрузить файлы сообщения в папку заявки. Возвращает СКЛАДСКИЕ имена — их + * сервис чата кладёт в текст комментария (см. buildCommentText). + * + * Вызывается ДО создания комментария: имена файлов нужны, чтобы его собрать. + * Если создать комментарий потом не удастся, файлы останутся в библиотеке + * ничьими — это лучше, чем сообщение со ссылками на незагруженные файлы. + */ +export async function uploadChatFiles( + formKey: RequestTypeId, + itemId: number, + listId: string | undefined, + files: File[] +): Promise { + if (files.length === 0) return []; + + const folder = await ensureRequestFolder(formKey, itemId, listId); + const stamp = fileStamp(new Date()); + const storedNames: string[] = []; + + // Последовательно, а не Promise.all: параллельная запись в одну папку чаще + // упирается в throttling SharePoint, а файлов здесь единицы. + for (let i = 0; i < files.length; i++) { + const name = storedFileName(stamp, i + 1, files[i].name); + // File — это Blob, поэтому читать его в ArrayBuffer заранее не нужно. + await folder.files.addUsingPath(name, files[i]); + storedNames.push(name); + } + return storedNames; +} + +/** Проверка файлов до отправки. Возвращает текст ошибки или undefined. */ +export function validateChatFiles(files: File[]): string | undefined { + if (files.length > MAX_FILES_PER_MESSAGE) { + return `К одному сообщению можно приложить не больше ${MAX_FILES_PER_MESSAGE} файлов.`; + } + const tooBig = files.filter((f) => f.size > MAX_FILE_SIZE_BYTES)[0]; + if (tooBig) { + const limitMb = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024)); + return `Файл «${tooBig.name}» больше ${limitMb} МБ — приложите его к самой заявке или дайте ссылку.`; + } + return undefined; +} diff --git a/src/webparts/requestPortal/shared/chat/chatMessages.test.ts b/src/webparts/requestPortal/shared/chat/chatMessages.test.ts new file mode 100644 index 0000000..066a07c --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatMessages.test.ts @@ -0,0 +1,156 @@ +import { + ICommentSource, + buildCommentText, + hasAttachments, + toChatMessages +} from './chatMessages'; + +const ME = 'ivan@tnsu.kz'; + +function comment(over: Partial): ICommentSource { + return { + id: '1', + text: 'Сообщение', + createdDate: '2026-08-21T09:00:00Z', + author: { name: 'Иванов Иван', email: ME }, + ...over + }; +} + +describe('toChatMessages', () => { + it('сортирует от старых к новым — свежие в чате внизу', () => { + const messages = toChatMessages( + [ + comment({ id: '2', createdDate: '2026-08-21T10:00:00Z', text: 'второе' }), + comment({ id: '1', createdDate: '2026-08-21T09:00:00Z', text: 'первое' }) + ], + ME + ); + + expect(messages.map((m) => m.text)).toEqual(['первое', 'второе']); + }); + + it('свои сообщения помечаются по email автора, регистр не важен', () => { + const messages = toChatMessages( + [ + comment({ id: '1', author: { name: 'Иванов Иван', email: 'IVAN@TNSU.KZ' } }), + comment({ id: '2', author: { name: 'Петров Пётр', email: 'petr@tnsu.kz' } }) + ], + ME + ); + + expect(messages[0].isOwn).toBe(true); + expect(messages[1].isOwn).toBe(false); + }); + + it('без текущего пользователя своих сообщений нет (а не все свои)', () => { + const messages = toChatMessages([comment({})], ''); + expect(messages[0].isOwn).toBe(false); + }); + + it('пустые комментарии отбрасываются — пустой «пузырь» показывать нечего', () => { + const messages = toChatMessages( + [comment({ id: '1', text: '' }), comment({ id: '2', text: ' ' }), comment({ id: '3' })], + ME + ); + + expect(messages).toHaveLength(1); + expect(messages[0].id).toBe('3'); + }); + + it('дата без часового пояса читается как UTC, а не как местное время', () => { + const withZone = toChatMessages([comment({ createdDate: '2026-08-21T09:00:00Z' })], ME); + const withoutZone = toChatMessages([comment({ createdDate: '2026-08-21T09:00:00' })], ME); + + expect(withoutZone[0].createdAt.getTime()).toBe(withZone[0].createdAt.getTime()); + }); + + it('автор без ФИО подписывается email, без email — заглушкой', () => { + const messages = toChatMessages( + [ + comment({ id: '1', author: { email: 'petr@tnsu.kz' } }), + comment({ id: '2', author: undefined }) + ], + ME + ); + + expect(messages[0].authorName).toBe('petr@tnsu.kz'); + expect(messages[1].authorName).toBe('Неизвестный автор'); + }); +}); + +describe('файлы в сообщении', () => { + const FOLDER = '/sites/requests/Файлы чата/hr-vacation-12'; + const FILE = '20260824-153012-1__акт.pdf'; + const SECOND_FILE = '20260824-153012-2__скан.jpg'; + + it('пометка о файлах не показывается в тексте сообщения', () => { + const messages = toChatMessages( + [comment({ text: `Посмотрите акт\n[rp-files]${FILE}` })], + ME, + FOLDER + ); + + expect(messages[0].text).toBe('Посмотрите акт'); + }); + + it('файл получает исходное имя и ссылку в папке заявки', () => { + const messages = toChatMessages( + [comment({ text: `[rp-files]${FILE}|${SECOND_FILE}` })], + ME, + FOLDER + ); + + expect(messages[0].text).toBe(''); + expect(messages[0].attachments).toEqual([ + { fileName: 'акт.pdf', url: `${FOLDER}/${FILE}` }, + { fileName: 'скан.jpg', url: `${FOLDER}/${SECOND_FILE}` } + ]); + }); + + it('сообщение из одних файлов не отбрасывается как пустое', () => { + const messages = toChatMessages([comment({ text: `[rp-files]${FILE}` })], ME, FOLDER); + expect(messages).toHaveLength(1); + }); + + it('пометка переживает перевод строки в формате Windows', () => { + const messages = toChatMessages([comment({ text: `Текст\r\n[rp-files]${FILE}` })], ME, FOLDER); + + expect(messages[0].text).toBe('Текст'); + expect(messages[0].attachments).toHaveLength(1); + }); + + it('обычное сообщение остаётся без вложений', () => { + const messages = toChatMessages([comment({ text: 'Просто текст' })], ME); + expect(messages[0].attachments).toEqual([]); + }); + + it('hasAttachments отличает переписку с файлами от переписки без них', () => { + expect(hasAttachments([comment({ text: 'Просто текст' })])).toBe(false); + expect(hasAttachments([comment({ text: `[rp-files]${FILE}` })])).toBe(true); + }); +}); + +describe('buildCommentText', () => { + it('без файлов отдаёт очищенный текст', () => { + expect(buildCommentText(' Привет ', [])).toBe('Привет'); + }); + + it('с файлами дописывает пометку отдельной строкой', () => { + expect(buildCommentText('Смотрите', ['a__акт.pdf', 'b__скан.jpg'])).toBe( + 'Смотрите\n[rp-files]a__акт.pdf|b__скан.jpg' + ); + }); + + it('без текста остаётся одна пометка — отправка «только файл»', () => { + expect(buildCommentText('', ['a__акт.pdf'])).toBe('[rp-files]a__акт.pdf'); + }); + + it('собранный текст читается обратно без потерь', () => { + const raw = buildCommentText('Смотрите акт', ['20260824-153012-1__акт.pdf']); + const messages = toChatMessages([comment({ text: raw })], ME, '/f'); + + expect(messages[0].text).toBe('Смотрите акт'); + expect(messages[0].attachments[0].fileName).toBe('акт.pdf'); + }); +}); diff --git a/src/webparts/requestPortal/shared/chat/chatMessages.ts b/src/webparts/requestPortal/shared/chat/chatMessages.ts new file mode 100644 index 0000000..d541ebb --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatMessages.ts @@ -0,0 +1,128 @@ +import { IChatAttachment, IChatMessage } from './models/IChatMessage'; +import { displayFileName } from './chatFileNames'; + +/** + * Часть комментария SharePoint, из которой строится сообщение чата. + * + * Объявлена своим типом, а не взята как ICommentInfo из @pnp/sp/comments: + * этот модуль намеренно НЕ знает про PnPjs (он чистый и покрыт тестами), а + * ICommentInfo структурно подходит под этот интерфейс — сервис передаёт его + * без приведения типов. + */ +export interface ICommentSource { + id: string; + text: string; + createdDate: string; + author?: { name?: string; email?: string }; +} + +/** + * Служебная пометка о прикреплённых файлах в КОНЦЕ текста комментария: + * Проверьте, пожалуйста, акт + * [rp-files]20260824-153012-1__акт.pdf|20260824-153012-2__скан.jpg + * + * Почему в тексте, а не отдельным хранилищем: у комментария SharePoint нет + * своих полей — только текст. Класть связь «сообщение → файлы» больше некуда, + * не заводя отдельный список и не превращая каждую загрузку чата в лишний + * запрос к библиотеке. Имена файлов складские (с префиксом), но не полные + * адреса — адрес библиотеки резолвится на лету, поэтому её можно переместить, + * и ссылки в старой переписке не сломаются. + * + * Разделитель имён — «|»: этот символ SharePoint в именах файлов запрещает + * (см. FORBIDDEN_CHARS), значит в имени он встретиться не может. + */ +const FILE_MARKER = '[rp-files]'; + +/** Пометка всегда последняя и всегда в одну строку — отсюда привязка к концу. */ +const FILE_MARKER_PATTERN = /\s*\[rp-files\]([^\r\n]*)$/; + +/** + * Собрать текст комментария из сообщения и складских имён файлов. + * Сообщение может быть пустым — тогда комментарий состоит из одной пометки + * (так выглядит отправка «только файл»). + */ +export function buildCommentText(text: string, storedFileNames: string[]): string { + const trimmed = text.trim(); + if (storedFileNames.length === 0) return trimmed; + + const marker = `${FILE_MARKER}${storedFileNames.join('|')}`; + return trimmed ? `${trimmed}\n${marker}` : marker; +} + +function parseStoredNames(raw: string): string[] { + const found = FILE_MARKER_PATTERN.exec(raw); + if (!found) return []; + return found[1] + .split('|') + .map((name) => name.trim()) + .filter((name) => !!name); +} + +/** Есть ли в переписке файлы — по этому признаку сервис решает, нужен ли адрес библиотеки. */ +export function hasAttachments(comments: ICommentSource[]): boolean { + return comments.some((c) => !!c.text && FILE_MARKER_PATTERN.test(c.text)); +} + +/** + * Дата комментария приходит строкой ISO. Если часового пояса в ней нет, + * new Date() трактует строку как МЕСТНОЕ время, а SharePoint отдаёт UTC — + * сообщения уехали бы на 5 часов (Астана — UTC+5). Поэтому при отсутствии + * пояса дописываем Z явно. + */ +function parseCommentDate(raw: string): Date { + const hasTimeZone = /(Z|[+-]\d{2}:\d{2})$/.test(raw); + return new Date(hasTimeZone ? raw : `${raw}Z`); +} + +function toAttachments(storedNames: string[], folderUrl: string): IChatAttachment[] { + return storedNames.map((storedName) => ({ + fileName: displayFileName(storedName), + url: folderUrl ? `${folderUrl}/${storedName}` : '' + })); +} + +function toMessage( + comment: ICommentSource, + currentUserEmail: string, + folderUrl: string +): IChatMessage { + const authorEmail = comment.author?.email || ''; + const authorName = comment.author?.name || authorEmail || 'Неизвестный автор'; + const storedNames = parseStoredNames(comment.text); + return { + id: comment.id, + text: comment.text.replace(FILE_MARKER_PATTERN, '').trim(), + authorName, + authorEmail, + createdAt: parseCommentDate(comment.createdDate), + isOwn: + !!authorEmail && + !!currentUserEmail && + authorEmail.toLowerCase() === currentUserEmail.toLowerCase(), + attachments: toAttachments(storedNames, folderUrl) + }; +} + +/** + * Комментарии SharePoint → сообщения чата, от старых к новым (как в любой + * переписке: свежее внизу). + * + * Порядок ответа API не документирован и мы на него не опираемся — сортируем + * по дате сами. Комментарии без текста и без файлов отбрасываем: показывать + * пустой «пузырь» нечего, а в списке они появляются, если кто-то отправил + * комментарий не из портала (например, из стандартной формы SharePoint). + * + * folderUrl — папка заявки в библиотеке «Файлы чата»; пустая строка допустима, + * когда в переписке файлов нет (тогда адрес библиотеки и не запрашивался). + */ +export function toChatMessages( + comments: ICommentSource[], + currentUserEmail: string, + folderUrl: string = '' +): IChatMessage[] { + return comments + .filter((c) => !!c.text && !!c.text.trim()) + .map((c) => toMessage(c, currentUserEmail, folderUrl)) + .filter((m) => !!m.text || m.attachments.length > 0) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); +} diff --git a/src/webparts/requestPortal/shared/chat/index.ts b/src/webparts/requestPortal/shared/chat/index.ts new file mode 100644 index 0000000..880fa18 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/index.ts @@ -0,0 +1,17 @@ +// Единая точка импорта чата по заявке. +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 { + uploadChatFiles, + chatFilesFolderUrl, + validateChatFiles, + MAX_FILES_PER_MESSAGE, + MAX_FILE_SIZE_BYTES +} from './chatFiles.service'; +export { toChatMessages, buildCommentText, hasAttachments } from './chatMessages'; +export type { ICommentSource } from './chatMessages'; +export { sanitizeFileName, storedFileName, displayFileName, fileStamp } from './chatFileNames'; +export type { IChatMessage, IChatAttachment } from './models/IChatMessage'; diff --git a/src/webparts/requestPortal/shared/chat/models/IChatMessage.ts b/src/webparts/requestPortal/shared/chat/models/IChatMessage.ts new file mode 100644 index 0000000..778167d --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/models/IChatMessage.ts @@ -0,0 +1,29 @@ +/** Файл, прикреплённый к сообщению чата (лежит в библиотеке «Файлы чата»). */ +export interface IChatAttachment { + /** Имя для показа — без служебного префикса (см. chatFileNames.ts). */ + fileName: string; + /** Ссылка на файл в библиотеке. */ + url: string; +} + +/** + * Сообщение чата заявки — комментарий к элементу списка данных, приведённый + * к виду, удобному интерфейсу (SharePoint отдаёт сырой ICommentInfo). + */ +export interface IChatMessage { + /** Id комментария в SharePoint. Строка, а не число — так его отдаёт API. */ + id: string; + /** Текст сообщения БЕЗ служебной пометки о файлах. */ + text: string; + /** ФИО автора; если SharePoint его не отдал — email или заглушка. */ + authorName: string; + authorEmail: string; + createdAt: Date; + /** + * Сообщение написано текущим пользователем — рисуется «своим» (справа). + * Считается по email автора, см. toChatMessages. + */ + isOwn: boolean; + /** Прикреплённые файлы; пустой массив — файлов нет. */ + attachments: IChatAttachment[]; +} diff --git a/src/webparts/requestPortal/shared/chat/requestChat.service.ts b/src/webparts/requestPortal/shared/chat/requestChat.service.ts new file mode 100644 index 0000000..371bbbc --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/requestChat.service.ts @@ -0,0 +1,78 @@ +import '@pnp/sp/items'; +import '@pnp/sp/comments'; +import type { ICommentInfo, IComments } from '@pnp/sp/comments'; +import { resolveDataList } from '../dataList'; +import { RequestTypeId } from '../../models/types'; +import { IChatMessage } from './models/IChatMessage'; +import { buildCommentText, hasAttachments, toChatMessages } from './chatMessages'; +import { chatFilesFolderUrl, uploadChatFiles } from './chatFiles.service'; + +/** + * Чат по заявке ПОВЕРХ штатных комментариев к строке списка. + * + * Почему комментарии, а не своя колонка/список переписки: комментарии к + * элементу — готовый механизм SharePoint. Он сам хранит автора и время, сам + * раздаёт права (кто видит заявку — видит переписку), не требует ни новой + * колонки в девяти списках, ни провижининга, и переписка не мешает + * согласованию (комментарий решения — отдельное поле строки маршрута). + * + * Единственное, чего у комментария нет, — вложений: у него есть только текст. + * Поэтому файлы лежат в отдельной библиотеке (chatFiles.service.ts), а в тексте + * комментария остаётся пометка с их именами (chatMessages.ts). + * + * Заявка живёт в КОНКРЕТНОЙ партиции (списки нумерованные, см. + * shared/approval/listPartitions.ts), поэтому список резолвим общим + * resolveDataList(formKey, listId) — тем же, которым формы пишут саму заявку. + * Иначе переписка архивной заявки читалась бы из текущей партиции, где под + * тем же Id лежит другая заявка. + */ + +/** Комментарии элемента списка данных заявки. */ +function commentsOf(formKey: RequestTypeId, itemId: number, listId?: string): IComments { + return resolveDataList(formKey, listId).items.getById(itemId).comments; +} + +/** + * Прочитать переписку по заявке. + * + * currentUserEmail нужен, чтобы отметить свои сообщения. Передаём РЕАЛЬНОГО + * пользователя (context.pageContext.user.email), а не подменённого: комментарий + * SharePoint пишет под настоящей учёткой, отладочная подмена личности + * (shared/impersonation.ts) на автора не влияет. + * + * SharePoint отдаёт комментарии страницей. Дочитывания страниц здесь нет: + * переписка по одной заявке — это единицы сообщений, и усложнять сервис под + * гипотетический случай смысла нет. Если объём вырастет, дочитывание добавится + * здесь, интерфейс менять не придётся. + */ +export async function getChatMessages( + formKey: RequestTypeId, + itemId: number, + listId: string | undefined, + currentUserEmail: string +): Promise { + const comments: ICommentInfo[] = await commentsOf(formKey, itemId, listId)(); + // Адрес библиотеки спрашиваем, только если в переписке ЕСТЬ файлы: у заявки + // без вложений чат тогда вообще не зависит от библиотеки — ни лишнего + // запроса, ни падения, если её переименуют. + const folderUrl = hasAttachments(comments) ? await chatFilesFolderUrl(formKey, itemId, listId) : ''; + return toChatMessages(comments, currentUserEmail, folderUrl); +} + +/** + * Отправить сообщение — добавить комментарий к строке заявки. Автора и время + * ставит сам SharePoint, поэтому передаём только текст. + * + * Файлы сначала уходят в библиотеку, и только потом создаётся комментарий с + * пометкой об их именах — см. uploadChatFiles о том, почему такой порядок. + */ +export async function addChatMessage( + formKey: RequestTypeId, + itemId: number, + listId: string | undefined, + text: string, + files: File[] = [] +): Promise { + const storedNames = await uploadChatFiles(formKey, itemId, listId, files); + await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames)); +} diff --git a/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx b/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx new file mode 100644 index 0000000..ba7dafc --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx @@ -0,0 +1,90 @@ +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'; + +interface IChatComposerProps { + sending: boolean; + /** Возвращает true, если сообщение записано — только тогда очищаем поле. */ + onSend: (text: string, files: File[]) => Promise; +} + +const MAX_FILE_SIZE_MB = Math.round(MAX_FILE_SIZE_BYTES / (1024 * 1024)); + +/** + * Поле ввода сообщения и выбор файлов. Здесь НЕ ui-kit TextArea, а собственный + * textarea: чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш + * не пробрасывает. + */ +export const ChatComposer: React.FC = ({ sending, onSend }) => { + const [text, setText] = React.useState(''); + const [files, setFiles] = React.useState([]); + const [filesOpen, setFilesOpen] = React.useState(false); + + // Сообщение из одних файлов — нормальный случай («вот скан»), поэтому пустой + // текст отправку не блокирует. + const canSend = (text.trim().length > 0 || files.length > 0) && !sending; + + const send = (): void => { + if (!canSend) return; + onSend(text, files) + .then((sent) => { + if (!sent) return; + setText(''); + setFiles([]); + setFilesOpen(false); + }) + .catch(() => undefined); + }; + + // Enter отправляет, Shift+Enter переносит строку — привычное поведение чата. + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }; + + return ( +
+ {filesOpen && ( +
+ +
+ До {MAX_FILES_PER_MESSAGE} файлов, каждый не больше {MAX_FILE_SIZE_MB} МБ. +
+
+ )} + +
+