feat: чат по заявке через комментарии к строке списка
Tests / frontend-test (pull_request) Successful in 1m16s
Tests / frontend-test (pull_request) Successful in 1m16s
Обсуждение заявки поверх штатных комментариев SharePoint (PnPjs item.comments) — без новых колонок и провижининга, права на переписку раздаёт сам SharePoint по правам на элемент. Встроено в ApproveFeature и EditFeature (единственные входы в просмотр заявки для всех типов), поэтому доступно автору, всем согласующим (включая уже решивших и ещё не дошедших до своего шага) и исполнителям, а не только текущему шагу маршрута, как DecisionPanel. Автора комментария всегда ставит реальная учётка — отладочная подмена личности на запись в SharePoint не влияет, в UI есть предупреждение.
This commit is contained in:
@@ -3,6 +3,7 @@ import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
|||||||
import styles from './ApproveFeature.module.scss';
|
import styles from './ApproveFeature.module.scss';
|
||||||
import { getViewComponent } from '../../forms/viewRegistry';
|
import { getViewComponent } from '../../forms/viewRegistry';
|
||||||
import { isConfirmedStaleListId } from '../../shared/approval';
|
import { isConfirmedStaleListId } from '../../shared/approval';
|
||||||
|
import { RequestChat } from '../../shared/chat';
|
||||||
|
|
||||||
export interface IApproveFeatureProps {
|
export interface IApproveFeatureProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -14,8 +15,14 @@ export interface IApproveFeatureProps {
|
|||||||
listId?: string;
|
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 ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, itemId, listId }) => {
|
||||||
const ViewComponent = getViewComponent(formKey);
|
const ViewComponent = getViewComponent(formKey);
|
||||||
|
|
||||||
@@ -31,9 +38,15 @@ const ApproveFeature: React.FC<IApproveFeatureProps> = ({ context, formKey, item
|
|||||||
{partitionError ? (
|
{partitionError ? (
|
||||||
<div className={styles.state}>{partitionError}</div>
|
<div className={styles.state}>{partitionError}</div>
|
||||||
) : ViewComponent ? (
|
) : ViewComponent ? (
|
||||||
<React.Suspense fallback={<div className={styles.state}>Загрузка…</div>}>
|
<>
|
||||||
<ViewComponent context={context} itemId={itemId} formKey={formKey} listId={listId} />
|
<React.Suspense fallback={<div className={styles.state}>Загрузка…</div>}>
|
||||||
</React.Suspense>
|
<ViewComponent context={context} itemId={itemId} formKey={formKey} listId={listId} />
|
||||||
|
</React.Suspense>
|
||||||
|
{/* Доступно всем, кто может открыть заявку: автору, всем согласующим
|
||||||
|
(и уже решившим, и ещё ждущим своей очереди) и исполнителям —
|
||||||
|
в отличие от панели решения, которая видна только текущему шагу. */}
|
||||||
|
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div>
|
<div className={styles.state}>Для формы «{formKey}» режим согласования пока не настроен.</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getFormComponent } from '../../forms/registry';
|
|||||||
import { REQUEST_TYPES } from '../../data/requests';
|
import { REQUEST_TYPES } from '../../data/requests';
|
||||||
import { emitRequestsChanged } from '../../shared/requestEvents';
|
import { emitRequestsChanged } from '../../shared/requestEvents';
|
||||||
import { isConfirmedStaleListId } from '../../shared/approval';
|
import { isConfirmedStaleListId } from '../../shared/approval';
|
||||||
|
import { RequestChat } from '../../shared/chat';
|
||||||
|
|
||||||
export interface IEditFeatureProps {
|
export interface IEditFeatureProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -66,6 +67,10 @@ const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, li
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
|
{/* Обсуждение — то же самое, что видят согласующие (комментарии к строке
|
||||||
|
заявки). Здесь оно особенно нужно: заявку правят как раз после
|
||||||
|
возврата на доработку, и уточнить причину логично не уходя с формы. */}
|
||||||
|
<RequestChat context={context} formKey={formKey} itemId={itemId} listId={listId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ICommentSource, 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('Неизвестный автор');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<IChatMessage[]> {
|
||||||
|
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<void> {
|
||||||
|
await commentsOf(formKey, itemId, listId).add(text);
|
||||||
|
}
|
||||||
@@ -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<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поле ввода сообщения. Здесь НЕ ui-kit TextArea, а собственный textarea:
|
||||||
|
* чату нужен Enter → отправка, а TextArea из ui-kit обработчик клавиш не
|
||||||
|
* пробрасывает.
|
||||||
|
*/
|
||||||
|
export const ChatComposer: React.FC<IChatComposerProps> = ({ sending, onSend }) => {
|
||||||
|
const [text, setText] = React.useState<string>('');
|
||||||
|
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<HTMLTextAreaElement>): void => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<Button variant="accent" disabled={!canSend} onClick={send}>
|
||||||
|
<Icon name="send" size="sm" />
|
||||||
|
{sending ? 'Отправка…' : 'Отправить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styles from './RequestChat.module.scss';
|
||||||
|
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/> —
|
||||||
|
текст приходит от пользователя и в разметку его подставлять нельзя. */}
|
||||||
|
<div className={styles.text}>{message.text}</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,174 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,108 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Как часто перечитываем переписку, пока заявка открыта. Чат — переписка живая,
|
||||||
|
* поэтому обновляем сами, а не только по кнопке; 30 секунд — компромисс между
|
||||||
|
* «сообщение видно почти сразу» и лишними запросами к SharePoint.
|
||||||
|
*/
|
||||||
|
const REFRESH_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
|
export interface IRequestChat {
|
||||||
|
messages: IChatMessage[];
|
||||||
|
/** ПЕРВАЯ загрузка переписки; обновления идут фоном, без «Загрузка…». */
|
||||||
|
loading: boolean;
|
||||||
|
sending: boolean;
|
||||||
|
error?: string;
|
||||||
|
/**
|
||||||
|
* Отправить сообщение. Пустой текст игнорируется. Возвращает true, если
|
||||||
|
* сообщение записано: по false поле ввода НЕ очищается, иначе набранный
|
||||||
|
* текст пропал бы вместе с неудачной отправкой.
|
||||||
|
*/
|
||||||
|
send: (text: string) => 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): Promise<boolean> => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await addChatMessage(formKey, itemId, listId, trimmed);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user