Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
Tests / frontend-test (push) Successful in 4m14s
Tests / frontend-test (push) Successful in 4m14s
This commit is contained in:
@@ -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<IApproveFeatureProps> = ({ context, formKey, itemId, listId }) => {
|
||||
const ViewComponent = getViewComponent(formKey);
|
||||
|
||||
@@ -31,9 +38,18 @@ const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, item
|
||||
{partitionError ? (
|
||||
<div className={styles.state}>{partitionError}</div>
|
||||
) : ViewComponent ? (
|
||||
<>
|
||||
<React.Suspense fallback={<div className={styles.state}>Загрузка…</div>}>
|
||||
<ViewComponent context={context} itemId={itemId} formKey={formKey} listId={listId} />
|
||||
</React.Suspense>
|
||||
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим
|
||||
(и уже решившим, и ещё ждущим своей очереди) и исполнителям —
|
||||
в отличие от панели решения, которая видна только текущему шагу.
|
||||
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
|
||||
{isChatEnabledFor(formKey) && (
|
||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div>
|
||||
)}
|
||||
|
||||
@@ -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<IEditFeatureProps> = ({ context, formKey, itemId, li
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
|
||||
заявки). Здесь оно особенно нужно: заявку правят как раз после
|
||||
возврата на доработку, и уточнить причину логично не уходя с формы.
|
||||
Пока включено только для «Обходного листа» — см. isChatEnabledFor. */}
|
||||
{isChatEnabledFor(formKey) && (
|
||||
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<RequestTypeId> = new Set<RequestTypeId>(['clearance-form']);
|
||||
|
||||
/** Показывать ли чат по заявке для этого типа. Проверяется ДО монтирования
|
||||
* RequestChat — иначе хук useRequestChat опрашивал бы SharePoint (загрузка +
|
||||
* автообновление раз в 17с) и для заявок, где чат не включён. */
|
||||
export function isChatEnabledFor(formKey: RequestTypeId): boolean {
|
||||
return CHAT_ENABLED_FORM_IDS.has(formKey);
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* Файлы, прикреплённые к сообщениям чата, — библиотека «Файлы чата» на сайте
|
||||
* данных, папка на каждую заявку: «Файлы чата/<RequestUniqueCode>», например
|
||||
* «Файлы чата/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<string> | undefined;
|
||||
|
||||
/**
|
||||
* Серверный относительный адрес корня библиотеки, например
|
||||
* «/sites/requests/Файлы чата».
|
||||
*
|
||||
* Резолвим по ЗАГОЛОВКУ списка, а не собираем строкой из адреса сайта: url
|
||||
* библиотеки может не совпадать с её названием (и меняться при переносе), а
|
||||
* заголовок — то, что реально настраивал администратор.
|
||||
*/
|
||||
function chatLibraryRoot(): Promise<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<IFolder> {
|
||||
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<string[]> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
ICommentSource,
|
||||
buildCommentText,
|
||||
hasAttachments,
|
||||
toChatMessages
|
||||
} from './chatMessages';
|
||||
|
||||
const ME = 'ivan@tnsu.kz';
|
||||
|
||||
function comment(over: Partial<ICommentSource>): 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');
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<IChatMessage[]> {
|
||||
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<void> {
|
||||
const storedNames = await uploadChatFiles(formKey, itemId, listId, files);
|
||||
await commentsOf(formKey, itemId, listId).add(buildCommentText(text, storedNames));
|
||||
}
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
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<IChatComposerProps> = ({ sending, onSend }) => {
|
||||
const [text, setText] = React.useState<string>('');
|
||||
const [files, setFiles] = React.useState<File[]>([]);
|
||||
const [filesOpen, setFilesOpen] = React.useState<boolean>(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<HTMLTextAreaElement>): void => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.composerBlock}>
|
||||
{filesOpen && (
|
||||
<div className={styles.dropzone}>
|
||||
<FileDropzone files={files} onChange={setFiles} />
|
||||
<div className={styles.dropzoneHint}>
|
||||
До {MAX_FILES_PER_MESSAGE} файлов, каждый не больше {MAX_FILE_SIZE_MB} МБ.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.composer}>
|
||||
<textarea
|
||||
className={styles.input}
|
||||
value={text}
|
||||
rows={2}
|
||||
disabled={sending}
|
||||
placeholder="Сообщение… (Enter — отправить, Shift+Enter — новая строка)"
|
||||
aria-label="Сообщение в обсуждение заявки"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div className={styles.composerActions}>
|
||||
{/* Пока файлы выбраны, панель не сворачиваем: иначе прикреплённое
|
||||
пропадает с глаз, и легко отправить не то, что собирался. */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={sending}
|
||||
aria-label="Прикрепить файлы"
|
||||
onClick={() => setFilesOpen((open) => !open || files.length > 0)}
|
||||
>
|
||||
<Icon name="upload" size="sm" />
|
||||
{files.length > 0 ? `Файлы (${files.length})` : 'Файл'}
|
||||
</Button>
|
||||
<Button variant="accent" disabled={!canSend} onClick={send}>
|
||||
<Icon name="send" size="sm" />
|
||||
{sending ? 'Отправка…' : 'Отправить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RequestChat.module.scss';
|
||||
import { AttachmentList } from '../../ui';
|
||||
import { IChatMessage } from '../models/IChatMessage';
|
||||
|
||||
interface IChatMessageProps {
|
||||
message: IChatMessage;
|
||||
}
|
||||
|
||||
function pad2(value: number): string {
|
||||
return value < 10 ? `0${value}` : `${value}`;
|
||||
}
|
||||
|
||||
/** «21.08.2026 14:32» — формат даты как в таблицах заявок, плюс время. */
|
||||
function formatSentAt(date: Date): string {
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const day = `${pad2(date.getDate())}.${pad2(date.getMonth() + 1)}.${date.getFullYear()}`;
|
||||
return `${day} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** Одно сообщение переписки. Свои — справа, чужие — слева. */
|
||||
export const ChatMessage: React.FC<IChatMessageProps> = ({ message }) => (
|
||||
<li className={`${styles.message} ${message.isOwn ? styles.own : ''}`}>
|
||||
<div className={styles.bubble}>
|
||||
<div className={styles.meta}>
|
||||
<span className={styles.author} title={message.authorEmail}>
|
||||
{message.isOwn ? 'Вы' : message.authorName}
|
||||
</span>
|
||||
<span className={styles.sentAt}>{formatSentAt(message.createdAt)}</span>
|
||||
</div>
|
||||
{/* Переносы строк сохраняем стилем (white-space: pre-wrap), а не <br/> —
|
||||
текст приходит от пользователя и в разметку его подставлять нельзя.
|
||||
Текста может не быть вовсе: сообщение из одних файлов. */}
|
||||
{message.text && <div className={styles.text}>{message.text}</div>}
|
||||
{message.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
<AttachmentList items={message.attachments} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import styles from './RequestChat.module.scss';
|
||||
import { IChatMessage } from '../models/IChatMessage';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
|
||||
interface IChatMessageListProps {
|
||||
messages: IChatMessage[];
|
||||
/** Первая загрузка переписки. */
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** Лента сообщений с состояниями «загрузка» и «пусто». */
|
||||
export const ChatMessageList: React.FC<IChatMessageListProps> = ({ messages, loading }) => {
|
||||
const listRef = React.useRef<HTMLUListElement>(null);
|
||||
|
||||
// Свежие сообщения внизу, поэтому прокрутку держим у конца ленты. Скроллим
|
||||
// только сам контейнер, страницу под панелью не трогаем.
|
||||
React.useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка обсуждения…</div>;
|
||||
}
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
Сообщений пока нет. Напишите первым — переписку увидят все, у кого есть доступ к заявке.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={styles.list} ref={listRef}>
|
||||
{messages.map((m) => (
|
||||
<ChatMessage key={m.id} message={m} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Панель обсуждения — тот же «карточный» вид, что у панели решения
|
||||
// (DecisionPanel.module.scss): она стоит рядом с ней в панели заявки.
|
||||
.panel {
|
||||
margin-top: 16px;
|
||||
padding: 24px;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-1;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: $font-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: $r-full;
|
||||
background: $navy-100;
|
||||
color: $navy-800;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-caption;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Лента сообщений. Высота ограничена: переписка не должна растягивать панель
|
||||
// заявки на несколько экранов — прокручивается сама, свежие внизу.
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: $gray-50;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
// Чужие сообщения слева, свои справа (см. .own) — как в мессенджере.
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
// 78% — «пузырь» заметно уже колонки, чтобы сторона автора читалась сразу,
|
||||
// но длинный текст не превращался в узкий столбик.
|
||||
max-width: 78%;
|
||||
padding: 8px 12px;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
box-shadow: $sh-1;
|
||||
}
|
||||
|
||||
.own {
|
||||
justify-content: flex-end;
|
||||
|
||||
.bubble {
|
||||
background: $gold-50;
|
||||
border-color: $gold-300;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: $fs-label;
|
||||
font-weight: 600;
|
||||
color: $navy-800;
|
||||
}
|
||||
|
||||
.sentAt {
|
||||
font-size: $fs-caption;
|
||||
color: $gray-400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: $fs-sm;
|
||||
line-height: 1.5;
|
||||
color: $gray-900;
|
||||
// Переносы строк и пробелы сохраняем как ввёл автор; длинное «слово»
|
||||
// (ссылка, код) рвём, иначе оно распирает пузырь.
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 20px;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
text-align: center;
|
||||
background: $gray-50;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
}
|
||||
|
||||
// Вложения сообщения — отделяем от текста тонкой линией, чтобы список файлов
|
||||
// не сливался с самим сообщением.
|
||||
.attachments {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.composerBlock {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
// Зона выбора файлов — над строкой ввода: то, что уйдёт с сообщением, видно
|
||||
// раньше, чем кнопка «Отправить».
|
||||
.dropzone {
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
background: $gray-50;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
}
|
||||
|
||||
.dropzoneHint {
|
||||
margin-top: 8px;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.composerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 9px 12px;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-body;
|
||||
line-height: 1.5;
|
||||
color: $gray-900;
|
||||
background: $white;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
resize: vertical;
|
||||
@include input-focus-gold;
|
||||
|
||||
&:disabled {
|
||||
background: $gray-50;
|
||||
color: $gray-500;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
// Узкая панель заявки (мобильный): кнопка отправки под полем во всю ширину —
|
||||
// рядом с полем ей уже не хватает места.
|
||||
@media (max-width: 520px) {
|
||||
.composer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.composerActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './RequestChat.module.scss';
|
||||
import { Alert, Button, Icon } from '../../ui';
|
||||
import { getImpersonatedEmail } from '../../impersonation';
|
||||
import { RequestTypeId } from '../../../models/types';
|
||||
import { useRequestChat } from '../useRequestChat';
|
||||
import { ChatMessageList } from './ChatMessageList';
|
||||
import { ChatComposer } from './ChatComposer';
|
||||
|
||||
interface IRequestChatProps {
|
||||
context: WebPartContext;
|
||||
/** Ключ формы (тип заявки) — по нему резолвится список данных. */
|
||||
formKey: RequestTypeId;
|
||||
/** Id заявки в списке данных. */
|
||||
itemId: number;
|
||||
/** Партиция, где лежит ЭТА заявка (см. shared/approval/listPartitions.ts). */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Чат по заявке. Показывается ВСЕМ, кто может открыть заявку — автору, всем
|
||||
* согласующим (в том числе уже принявшим решение и ещё не дошедшим до своего
|
||||
* шага), исполнителям, — поэтому и стоит в общей панели заявки, а не в панели
|
||||
* решения: право переписываться не зависит от того, чья сейчас очередь.
|
||||
*
|
||||
* Сообщения — штатные комментарии к строке списка (см. requestChat.service.ts).
|
||||
*/
|
||||
export const RequestChat: React.FC<IRequestChatProps> = ({ context, formKey, itemId, listId }) => {
|
||||
// Автора комментария SharePoint подписывает РЕАЛЬНОЙ учёткой — отладочная
|
||||
// подмена личности (shared/impersonation.ts) права доступа и автора записи не
|
||||
// меняет. Поэтому «свои» сообщения ищем по настоящему пользователю.
|
||||
const currentUserEmail = context.pageContext.user.email || '';
|
||||
const chat = useRequestChat(formKey, itemId, listId, currentUserEmail);
|
||||
const impersonated = getImpersonatedEmail();
|
||||
|
||||
return (
|
||||
<section className={styles.panel}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>
|
||||
<Icon name="chat" size="sm" />
|
||||
Обсуждение
|
||||
{chat.messages.length > 0 && <span className={styles.count}>{chat.messages.length}</span>}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" disabled={chat.loading} onClick={chat.reload}>
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{chat.error && <Alert variant="err">{chat.error}</Alert>}
|
||||
|
||||
<ChatMessageList messages={chat.messages} loading={chat.loading} />
|
||||
<ChatComposer sending={chat.sending} onSend={chat.send} />
|
||||
|
||||
{/* Подмена личности не распространяется на комментарии — предупреждаем,
|
||||
иначе при отладке непонятно, почему сообщение подписано не тем. */}
|
||||
{impersonated && (
|
||||
<div className={styles.hint}>
|
||||
Включена отладочная подмена личности, но сообщение уйдёт от вашей реальной учётной
|
||||
записи: автора комментария SharePoint ставит сам.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as React from 'react';
|
||||
import { friendlyErrorMessage } from '../diagnostics';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
import { IChatMessage } from './models/IChatMessage';
|
||||
import { addChatMessage, getChatMessages } from './requestChat.service';
|
||||
import { validateChatFiles } from './chatFiles.service';
|
||||
|
||||
/**
|
||||
* Как часто перечитываем переписку, пока заявка открыта. Чат — переписка живая,
|
||||
* поэтому обновляем сами, а не только по кнопке; 17 секунд — компромисс между
|
||||
* «сообщение видно почти сразу» и лишними запросами к SharePoint.
|
||||
*/
|
||||
const REFRESH_INTERVAL_MS = 17000;
|
||||
|
||||
export interface IRequestChat {
|
||||
messages: IChatMessage[];
|
||||
/** ПЕРВАЯ загрузка переписки; обновления идут фоном, без «Загрузка…». */
|
||||
loading: boolean;
|
||||
sending: boolean;
|
||||
error?: string;
|
||||
/**
|
||||
* Отправить сообщение с файлами (файлы необязательны). Пустое сообщение —
|
||||
* без текста и без файлов — игнорируется. Возвращает true, если сообщение
|
||||
* записано: по false поле ввода и выбранные файлы НЕ сбрасываются, иначе
|
||||
* набранное пропало бы вместе с неудачной отправкой.
|
||||
*/
|
||||
send: (text: string, files: File[]) => Promise<boolean>;
|
||||
/** Перечитать переписку (кнопка «Обновить»). */
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние чата заявки: загрузка переписки, автообновление, отправка.
|
||||
* Логика вынесена из компонента, чтобы RequestChat остался разметкой.
|
||||
*/
|
||||
export function useRequestChat(
|
||||
formKey: RequestTypeId,
|
||||
itemId: number,
|
||||
listId: string | undefined,
|
||||
currentUserEmail: string
|
||||
): IRequestChat {
|
||||
const [messages, setMessages] = React.useState<IChatMessage[]>([]);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [sending, setSending] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | undefined>(undefined);
|
||||
// Смена ключа перезапускает эффект загрузки — тем же приёмом, что reloadKey у
|
||||
// useMatrixEditor: после отправки переписку нужно перечитать.
|
||||
const [reloadSeq, setReloadSeq] = React.useState<number>(0);
|
||||
const reload = React.useCallback((): void => setReloadSeq((n) => n + 1), []);
|
||||
|
||||
// Панель заявки не размонтируется при переходе на ДРУГУЮ заявку (меняются
|
||||
// только пропсы), поэтому прежнюю переписку убираем явно — иначе на экране
|
||||
// на миг оставались бы чужие сообщения.
|
||||
React.useEffect(() => {
|
||||
setMessages([]);
|
||||
setLoading(true);
|
||||
}, [formKey, itemId, listId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
const load = (): void => {
|
||||
getChatMessages(formKey, itemId, listId, currentUserEmail)
|
||||
.then((loaded) => {
|
||||
if (!alive) return;
|
||||
setMessages(loaded);
|
||||
setError(undefined);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(`Не удалось загрузить обсуждение: ${friendlyErrorMessage(e)}`);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
load();
|
||||
const timer = window.setInterval(() => {
|
||||
// Вкладка не на экране — обновлять нечего, только греть SharePoint.
|
||||
if (!document.hidden) load();
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [formKey, itemId, listId, currentUserEmail, reloadSeq]);
|
||||
|
||||
const send = React.useCallback(
|
||||
async (text: string, files: File[]): Promise<boolean> => {
|
||||
const trimmed = text.trim();
|
||||
// Сообщение из одних файлов — нормальный случай («вот скан»), поэтому
|
||||
// пустой текст сам по себе отправку не отменяет.
|
||||
if (!trimmed && files.length === 0) return false;
|
||||
|
||||
const filesError = validateChatFiles(files);
|
||||
if (filesError) {
|
||||
setError(filesError);
|
||||
return false;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await addChatMessage(formKey, itemId, listId, trimmed, files);
|
||||
setError(undefined);
|
||||
reload();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(`Не удалось отправить сообщение: ${friendlyErrorMessage(e)}`);
|
||||
return false;
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[formKey, itemId, listId, reload]
|
||||
);
|
||||
|
||||
return { messages, loading, sending, error, send, reload };
|
||||
}
|
||||
@@ -142,3 +142,14 @@ export function approvalListTitleForListId(id: RequestTypeId, listId: string): s
|
||||
);
|
||||
return `${listTitleBase(id)} ${partition?.number ?? FALLBACK_LIST_NUMBER} — согласование`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Библиотека документов, куда складываются файлы, прикреплённые к сообщениям
|
||||
* чата заявки (shared/chat). Живёт на том же сайте данных (DATA_SITE_URL), что
|
||||
* и списки заявок, и создана вручную — провижинер её не заводит.
|
||||
*
|
||||
* Не партиционируется в отличие от списков заявок: файлы лежат в подпапке на
|
||||
* заявку (см. chatFiles.service.ts), доступ к ним идёт по прямому пути, а не
|
||||
* через представление, поэтому порог в 5000 элементов на такой доступ не влияет.
|
||||
*/
|
||||
export const CHAT_FILES_LIBRARY_TITLE = 'Файлы чата';
|
||||
|
||||
Reference in New Issue
Block a user