левая панель готова

This commit is contained in:
gorbenko.pavel
2026-08-03 18:00:19 +05:00
parent e6203e7093
commit 4b97745fec
8 changed files with 358 additions and 14 deletions
+3
View File
@@ -8,3 +8,6 @@ jest-output/
*.sppkg
.heft/
sharepoint/solution/
# JetBrains IDEA
.idea/
@@ -5,17 +5,10 @@ import { IconSprite } from '../shared/ui/Icon/IconSprite';
import { parseRoute } from '../shared/routing';
import SubmitFeature from '../features/submit/SubmitFeature';
import ApproveFeature from '../features/approve/ApproveFeature';
import MyRequestsPanel from '../features/myRequests/MyRequestsPanel';
// Оболочка портала. Логики фич тут нет — файл общий, меняется редко.
// Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача.
//
// Панель «Мои заявки» (features/myRequests) готова как компонент, но НЕ смонтирована —
// её подключает второй разработчик, когда наполнит. Для этого:
// 1) import MyRequestsPanel from '../features/myRequests/MyRequestsPanel';
// 2) обернуть контент подачи в <div className={styles.layout}> с двумя колонками:
// <main className={styles.main}><SubmitFeature .../></main>
// <div className={styles.side}><MyRequestsPanel context={context} /></div>
// (классы layout/main/side уже есть в RequestPortal.module.scss)
const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context }) => {
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
.filter(Boolean)
@@ -29,7 +22,14 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
{route.mode === 'approve' && route.formKey && route.itemId ? (
<ApproveFeature context={context} formKey={route.formKey} itemId={route.itemId} />
) : (
<SubmitFeature context={context} />
<div className={styles.layout}>
<div className={styles.side}>
<MyRequestsPanel context={context} />
</div>
<main className={styles.main}>
<SubmitFeature context={context} />
</main>
</div>
)}
</section>
);
@@ -28,6 +28,114 @@
flex: 1;
overflow-y: auto;
padding: $sp-5;
color: $gray-500;
font-size: $fs-sm;
}
.empty,
.error {
color: $gray-500;
}
.error {
color: $red-500;
}
.group + .group {
margin-top: $sp-5;
}
.groupTitle {
font-family: $font-serif;
font-size: $fs-label;
font-weight: 600;
color: $navy-800;
margin-bottom: $sp-2;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $gray-50;
}
.itemMain {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
overflow: hidden;
}
.itemTitle {
color: $gray-900;
font-size: $fs-sm;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemAuthor {
color: $gray-500;
font-size: $fs-caption;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemDate {
color: $gray-400;
font-size: $fs-caption;
}
.status {
flex-shrink: 0;
padding: 2px 8px;
border-radius: $r-full;
font-size: $fs-caption;
font-weight: 500;
white-space: nowrap;
}
.statusDraft {
background: $gray-100;
color: $gray-700;
}
.statusOnApproval {
background: $gold-100;
color: $gold-600;
}
.statusApproved {
background: $green-50;
color: $green-600;
}
.statusRejected {
background: $red-50;
color: $red-500;
}
.statusReturned {
background: $blue-50;
color: $blue-600;
}
.statusDefault {
background: $gray-100;
color: $gray-500;
}
@@ -1,23 +1,118 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getMyRequests } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS } from '../../shared/approval';
import styles from './MyRequestsPanel.module.scss';
export interface IMyRequestsPanelProps {
context: WebPartContext;
}
// Постоянная боковая панель «Мои заявки»: пользователь видит, что он отправлял
// и согласовывал. Всегда открыта (без кнопки/оверлея).
interface IGroup {
requestTypeName: string;
rows: IMyRequest[];
}
function groupByType(rows: IMyRequest[]): IGroup[] {
const order: string[] = [];
const map: Record<string, IMyRequest[]> = {};
rows.forEach((r) => {
if (!map[r.requestTypeName]) {
map[r.requestTypeName] = [];
order.push(r.requestTypeName);
}
map[r.requestTypeName].push(r);
});
return order.map((name) => ({ requestTypeName: name, rows: map[name] }));
}
function formatDate(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
}
function statusClass(status: string): string {
switch (status) {
case REQUEST_STATUS.approved:
return styles.statusApproved;
case REQUEST_STATUS.rejected:
return styles.statusRejected;
case REQUEST_STATUS.returned:
return styles.statusReturned;
case REQUEST_STATUS.onApproval:
return styles.statusOnApproval;
case REQUEST_STATUS.draft:
return styles.statusDraft;
default:
return styles.statusDefault;
}
}
// Постоянная боковая панель «Мои заявки»: заявки, которые создал текущий
// пользователь, по всем типам заявок, известным approval-route API.
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = (/* { context } */) => {
//
// Диагностика по типам заявок (listId/siteUrl/rowCount/error) в UI не выводится —
// см. console.table в devtools при загрузке панели.
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context }) => {
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [error, setError] = React.useState<string>('');
React.useEffect(() => {
let cancelled = false;
getMyRequests(context)
.then((result) => {
if (cancelled) return;
setRows(result.rows);
console.table(result.debug);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
}, [context]);
const groups = rows ? groupByType(rows) : [];
return (
<aside className={styles.panel} aria-label="Мои заявки">
<div className={styles.header}>
<span className={styles.title}>Мои заявки</span>
</div>
<div className={styles.body}>
Раздел в разработке: здесь будут заявки пользователя, статусы и согласования.
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
{!error && rows === undefined && <div className={styles.empty}>Загрузка…</div>}
{!error && rows && rows.length === 0 && (
<div className={styles.empty}>Вы ещё не создавали заявок.</div>
)}
{groups.map((group) => (
<div className={styles.group} key={group.requestTypeName}>
<div className={styles.groupTitle}>{group.requestTypeName}</div>
<ul className={styles.list}>
{group.rows.map((r) => (
<li className={styles.item} key={`${r.requestTypeId}-${r.id}`}>
<div className={styles.itemMain}>
<span className={styles.itemTitle}>{r.title || `#${r.id}`}</span>
<span className={styles.itemAuthor}>
{r.authorEmail || r.authorName || '— автор не определён —'}
</span>
{r.created && <span className={styles.itemDate}>{formatDate(r.created)}</span>}
</div>
<span className={`${styles.status} ${statusClass(r.status)}`}>
{r.status || '—'}
</span>
</li>
))}
</ul>
</div>
))}
</div>
</aside>
);
@@ -0,0 +1,14 @@
// Строка заявки пользователя для боковой панели «Мои заявки».
// Собирается из списка данных SharePoint конкретного типа заявки (см. myRequests.service.ts).
export interface IMyRequest {
id: number;
title: string;
status: string;
authorEmail: string;
authorName: string;
/** Дата создания (ISO-строка, колонка Created). */
created: string;
requestTypeId: number;
requestTypeName: string;
section: string;
}
@@ -0,0 +1,20 @@
import { IMyRequest } from './IMyRequest';
// Диагностика по одному типу заявки — для отладочного блока в панели «Мои заявки».
export interface IMyRequestsTypeDebug {
requestTypeId: number;
requestTypeName: string;
listId?: string;
siteUrl?: string;
/** Тип пропущен: у него нет listId/siteUrl в approval-route API. */
skipped: boolean;
/** Сколько строк текущего пользователя найдено (undefined, если не дошли до запроса). */
rowCount?: number;
/** Текст ошибки, если запрос к списку упал. */
error?: string;
}
export interface IMyRequestsResult {
rows: IMyRequest[];
debug: IMyRequestsTypeDebug[];
}
@@ -0,0 +1,99 @@
import { spfi, SPFx } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType } from '../../shared/approval';
import { IMyRequest } from './models/IMyRequest';
import { IMyRequestsResult, IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
// Заявки текущего пользователя по всем типам заявок, известным approval-route API.
// Для каждого типа с заполненными listId/siteUrl читаем список данных на его сайте
// и берём строки, где Author — текущий пользователь. Типы без listId/siteUrl
// (ещё не настроены на бэкенде) пропускаются. Ошибка по одному списку (нет прав,
// список удалён и т.п.) не должна ронять всю панель — такие типы просто дают
// пустой результат. debug возвращается всегда — панель показывает его как
// отладочную информацию (что пропущено и почему, что упало с ошибкой).
export async function getMyRequests(context: WebPartContext): Promise<IMyRequestsResult> {
const types = await getRequestTypes(context);
const perType = await Promise.all(
types.map((t) => (t.listId && t.siteUrl ? getMyRequestsForType(context, t) : skipType(t)))
);
return {
rows: perType.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]),
debug: perType.map((r) => r.debug),
};
}
function skipType(type: IRequestType): { rows: IMyRequest[]; debug: IMyRequestsTypeDebug } {
return {
rows: [],
debug: {
requestTypeId: type.id,
requestTypeName: type.name,
listId: type.listId,
siteUrl: type.siteUrl,
skipped: true,
},
};
}
async function getMyRequestsForType(
context: WebPartContext,
type: IRequestType
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
const debug: IMyRequestsTypeDebug = {
requestTypeId: type.id,
requestTypeName: type.name,
listId: type.listId,
siteUrl: type.siteUrl,
skipped: false,
};
try {
const sp = spfi(type.siteUrl as string).using(SPFx(context));
const list = sp.web.getList(type.listId as string);
const me = await sp.web.currentUser.select('Id')();
const rows = (await list.items
.filter(`AuthorId eq ${me.Id}`)
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.orderBy('Id', false)
.top(200)()) as Array<Record<string, unknown>>;
debug.rowCount = rows.length;
// Токен пользователя (context.pageContext.user.email) — источник правды для
// сравнения с Author/EMail списка. Не совпало (напр. Author/EMail пуст на
// профиле сайта) — строку не показываем, а не показываем что попало.
const tokenEmail = (context.pageContext.user.email || '').toLowerCase();
const mapped = rows
.map((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
return {
id: r.Id as number,
title: (r.Title as string) || '',
status: (r.Status as string) || '',
authorEmail: author?.EMail || '',
authorName: author?.Title || '',
created: (r.Created as string) || '',
requestTypeId: type.id,
requestTypeName: type.name,
section: type.section,
};
})
.filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail);
return { rows: mapped, debug };
} catch (e) {
debug.error = (e as Error).message;
console.error(`Мои заявки: не удалось прочитать список типа «${type.name}»:`, debug.error);
return { rows: [], debug };
}
}
@@ -8,6 +8,11 @@ export interface IRequestType {
id: number;
name: string;
pageUrl: string;
/** Серверный относительный URL списка данных (напр. "/sites/requests/Lists/List1"). undefined, пока не настроено. */
listId?: string;
/** Абсолютный URL сайта, где живёт этот список. undefined, пока не настроено. */
siteUrl?: string;
section: string;
}
// Обёртка ответа API (все ответы приходят в поле data).