From 2c5935b72de79b8f7936da741bd7cc0528669f27 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 24 Aug 2026 10:10:18 +0500 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=D1=87=D0=B0=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B5=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=BA=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обсуждение заявки поверх штатных комментариев SharePoint (PnPjs item.comments) — без новых колонок и провижининга, права на переписку раздаёт сам SharePoint по правам на элемент. Встроено в ApproveFeature и EditFeature (единственные входы в просмотр заявки для всех типов), поэтому доступно автору, всем согласующим (включая уже решивших и ещё не дошедших до своего шага) и исполнителям, а не только текущему шагу маршрута, как DecisionPanel. Автора комментария всегда ставит реальная учётка — отладочная подмена личности на запись в SharePoint не влияет, в UI есть предупреждение. --- .../features/approve/ApproveFeature.tsx | 23 ++- .../features/edit/EditFeature.tsx | 5 + .../shared/chat/chatMessages.test.ts | 75 ++++++++ .../requestPortal/shared/chat/chatMessages.ts | 62 +++++++ .../requestPortal/shared/chat/index.ts | 8 + .../shared/chat/models/IChatMessage.ts | 18 ++ .../shared/chat/requestChat.service.ts | 64 +++++++ .../shared/chat/ui/ChatComposer.tsx | 55 ++++++ .../shared/chat/ui/ChatMessage.tsx | 35 ++++ .../shared/chat/ui/ChatMessageList.tsx | 41 +++++ .../shared/chat/ui/RequestChat.module.scss | 174 ++++++++++++++++++ .../shared/chat/ui/RequestChat.tsx | 65 +++++++ .../shared/chat/useRequestChat.ts | 108 +++++++++++ 13 files changed, 728 insertions(+), 5 deletions(-) create mode 100644 src/webparts/requestPortal/shared/chat/chatMessages.test.ts create mode 100644 src/webparts/requestPortal/shared/chat/chatMessages.ts create mode 100644 src/webparts/requestPortal/shared/chat/index.ts create mode 100644 src/webparts/requestPortal/shared/chat/models/IChatMessage.ts create mode 100644 src/webparts/requestPortal/shared/chat/requestChat.service.ts create mode 100644 src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx create mode 100644 src/webparts/requestPortal/shared/chat/ui/ChatMessage.tsx create mode 100644 src/webparts/requestPortal/shared/chat/ui/ChatMessageList.tsx create mode 100644 src/webparts/requestPortal/shared/chat/ui/RequestChat.module.scss create mode 100644 src/webparts/requestPortal/shared/chat/ui/RequestChat.tsx create mode 100644 src/webparts/requestPortal/shared/chat/useRequestChat.ts diff --git a/src/webparts/requestPortal/features/approve/ApproveFeature.tsx b/src/webparts/requestPortal/features/approve/ApproveFeature.tsx index 6df2bcd..e814209 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 } 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,15 @@ const ApproveFeature: React.FC = ({ context, formKey, item {partitionError ? (
{partitionError}
) : ViewComponent ? ( - Загрузка…}> - - + <> + Загрузка…}> + + + {/* Доступно всем, кто может открыть заявку: автору, всем согласующим + (и уже решившим, и ещё ждущим своей очереди) и исполнителям — + в отличие от панели решения, которая видна только текущему шагу. */} + + ) : (
Для формы «{formKey}» режим согласования пока не настроен.
)} diff --git a/src/webparts/requestPortal/features/edit/EditFeature.tsx b/src/webparts/requestPortal/features/edit/EditFeature.tsx index df2a4c0..bcab503 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 } from '../../shared/chat'; export interface IEditFeatureProps { context: WebPartContext; @@ -66,6 +67,10 @@ const EditFeature: React.FC = ({ context, formKey, itemId, li }} /> + {/* Обсуждение — то же самое, что видят согласующие (комментарии к строке + заявки). Здесь оно особенно нужно: заявку правят как раз после + возврата на доработку, и уточнить причину логично не уходя с формы. */} + ); }; 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..1b954e2 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatMessages.test.ts @@ -0,0 +1,75 @@ +import { ICommentSource, 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('Неизвестный автор'); + }); +}); diff --git a/src/webparts/requestPortal/shared/chat/chatMessages.ts b/src/webparts/requestPortal/shared/chat/chatMessages.ts new file mode 100644 index 0000000..91bcc57 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/chatMessages.ts @@ -0,0 +1,62 @@ +import { IChatMessage } from './models/IChatMessage'; + +/** + * Часть комментария SharePoint, из которой строится сообщение чата. + * + * Объявлена своим типом, а не взята как ICommentInfo из @pnp/sp/comments: + * этот модуль намеренно НЕ знает про PnPjs (он чистый и покрыт тестами), а + * ICommentInfo структурно подходит под этот интерфейс — сервис передаёт его + * без приведения типов. + */ +export interface ICommentSource { + id: string; + text: string; + createdDate: string; + author?: { name?: string; email?: string }; +} + +/** + * Дата комментария приходит строкой 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 toMessage(comment: ICommentSource, currentUserEmail: string): IChatMessage { + const authorEmail = comment.author?.email || ''; + const authorName = comment.author?.name || authorEmail || 'Неизвестный автор'; + return { + id: comment.id, + text: comment.text, + authorName, + authorEmail, + createdAt: parseCommentDate(comment.createdDate), + isOwn: + !!authorEmail && + !!currentUserEmail && + authorEmail.toLowerCase() === currentUserEmail.toLowerCase() + }; +} + +/** + * Комментарии SharePoint → сообщения чата, от старых к новым (как в любой + * переписке: свежее внизу). + * + * Порядок ответа API не документирован и мы на него не опираемся — сортируем + * по дате сами. Пустые комментарии отбрасываем: показывать пустой «пузырь» + * нечего, а в списке они появляются, если кто-то отправил комментарий не из + * портала (например, из стандартной формы SharePoint). + */ +export function toChatMessages( + comments: ICommentSource[], + currentUserEmail: string +): IChatMessage[] { + return comments + .filter((c) => !!c.text && !!c.text.trim()) + .map((c) => toMessage(c, currentUserEmail)) + .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..0a080d5 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/index.ts @@ -0,0 +1,8 @@ +// Единая точка импорта чата по заявке. +export { RequestChat } from './ui/RequestChat'; +export { useRequestChat } from './useRequestChat'; +export type { IRequestChat } from './useRequestChat'; +export { getChatMessages, addChatMessage } from './requestChat.service'; +export { toChatMessages } from './chatMessages'; +export type { ICommentSource } from './chatMessages'; +export type { IChatMessage } 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..b82cc26 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/models/IChatMessage.ts @@ -0,0 +1,18 @@ +/** + * Сообщение чата заявки — комментарий к элементу списка данных, приведённый + * к виду, удобному интерфейсу (SharePoint отдаёт сырой ICommentInfo). + */ +export interface IChatMessage { + /** Id комментария в SharePoint. Строка, а не число — так его отдаёт API. */ + id: string; + text: string; + /** ФИО автора; если SharePoint его не отдал — email или заглушка. */ + authorName: string; + authorEmail: string; + createdAt: Date; + /** + * Сообщение написано текущим пользователем — рисуется «своим» (справа). + * Считается по email автора, см. toChatMessages. + */ + isOwn: boolean; +} 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..7a1c199 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/requestChat.service.ts @@ -0,0 +1,64 @@ +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 { toChatMessages } from './chatMessages'; + +/** + * Чат по заявке ПОВЕРХ штатных комментариев к строке списка. + * + * Почему комментарии, а не своя колонка/список переписки: комментарии к + * элементу — готовый механизм SharePoint. Он сам хранит автора и время, сам + * раздаёт права (кто видит заявку — видит переписку), не требует ни новой + * колонки в девяти списках, ни провижининга, и переписка не мешает + * согласованию (комментарий решения — отдельное поле строки маршрута). + * + * Заявка живёт в КОНКРЕТНОЙ партиции (списки нумерованные, см. + * 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)(); + return toChatMessages(comments, currentUserEmail); +} + +/** + * Отправить сообщение — добавить комментарий к строке заявки. Автора и время + * ставит сам SharePoint, поэтому передаём только текст. + */ +export async function addChatMessage( + formKey: RequestTypeId, + itemId: number, + listId: string | undefined, + text: string +): Promise { + await commentsOf(formKey, itemId, listId).add(text); +} 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..69b4da0 --- /dev/null +++ b/src/webparts/requestPortal/shared/chat/ui/ChatComposer.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import styles from './RequestChat.module.scss'; +import { Button, Icon } from '../../ui'; + +interface IChatComposerProps { + sending: boolean; + /** Возвращает true, если сообщение записано — только тогда очищаем поле. */ + onSend: (text: string) => Promise; +} + +/** + * Поле ввода сообщения. Здесь НЕ ui-kit TextArea, а собственный textarea: + * чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш не + * пробрасывает. + */ +export const ChatComposer: React.FC = ({ sending, onSend }) => { + const [text, setText] = React.useState(''); + const canSend = text.trim().length > 0 && !sending; + + const send = (): void => { + if (!canSend) return; + onSend(text) + .then((sent) => { + if (sent) setText(''); + }) + .catch(() => undefined); + }; + + // Enter отправляет, Shift+Enter переносит строку — привычное поведение чата. + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + send(); + } + }; + + return ( +
+