чат по заявке через комментарии к строке списка (#14)
Tests / frontend-test (push) Successful in 1m15s

Обсуждение заявки поверх штатных комментариев SharePoint (PnPjs
item.comments) — без новых колонок и провижининга, права на переписку
раздаёт сам SharePoint по правам на элемент.

Встроено в ApproveFeature и EditFeature (единственные входы в просмотр
заявки для всех типов), поэтому доступно автору, всем согласующим
(включая уже решивших и ещё не дошедших до своего шага) и исполнителям,
а не только текущему шагу маршрута, как DecisionPanel.

Автора комментария всегда ставит реальная учётка — отладочная подмена
личности на запись в SharePoint не влияет, в UI есть предупреждение.

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
2026-08-24 11:37:34 +00:00
parent a146877327
commit 0ecb1ded78
19 changed files with 1357 additions and 5 deletions
@@ -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);
}