левая панель готова
This commit is contained in:
@@ -8,3 +8,6 @@ jest-output/
|
|||||||
*.sppkg
|
*.sppkg
|
||||||
.heft/
|
.heft/
|
||||||
sharepoint/solution/
|
sharepoint/solution/
|
||||||
|
|
||||||
|
# JetBrains IDEA
|
||||||
|
.idea/
|
||||||
|
|||||||
@@ -5,17 +5,10 @@ import { IconSprite } from '../shared/ui/Icon/IconSprite';
|
|||||||
import { parseRoute } from '../shared/routing';
|
import { parseRoute } from '../shared/routing';
|
||||||
import SubmitFeature from '../features/submit/SubmitFeature';
|
import SubmitFeature from '../features/submit/SubmitFeature';
|
||||||
import ApproveFeature from '../features/approve/ApproveFeature';
|
import ApproveFeature from '../features/approve/ApproveFeature';
|
||||||
|
import MyRequestsPanel from '../features/myRequests/MyRequestsPanel';
|
||||||
|
|
||||||
// Оболочка портала. Логики фич тут нет — файл общий, меняется редко.
|
// Оболочка портала. Логики фич тут нет — файл общий, меняется редко.
|
||||||
// Роутинг по URL: mode=approve&type=&id= → фича согласования, иначе — подача.
|
// Роутинг по 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 RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context }) => {
|
||||||
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
|
const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : '']
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -29,7 +22,14 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
{route.mode === 'approve' && route.formKey && route.itemId ? (
|
{route.mode === 'approve' && route.formKey && route.itemId ? (
|
||||||
<ApproveFeature context={context} formKey={route.formKey} itemId={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>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,114 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: $sp-5;
|
padding: $sp-5;
|
||||||
color: $gray-500;
|
|
||||||
font-size: $fs-sm;
|
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 * as React from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
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';
|
import styles from './MyRequestsPanel.module.scss';
|
||||||
|
|
||||||
export interface IMyRequestsPanelProps {
|
export interface IMyRequestsPanelProps {
|
||||||
context: WebPartContext;
|
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 можно импортировать, но НЕ менять.
|
// Общий 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 (
|
return (
|
||||||
<aside className={styles.panel} aria-label="Мои заявки">
|
<aside className={styles.panel} aria-label="Мои заявки">
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<span className={styles.title}>Мои заявки</span>
|
<span className={styles.title}>Мои заявки</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.body}>
|
<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>
|
</div>
|
||||||
</aside>
|
</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;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
pageUrl: string;
|
pageUrl: string;
|
||||||
|
/** Серверный относительный URL списка данных (напр. "/sites/requests/Lists/List1"). undefined, пока не настроено. */
|
||||||
|
listId?: string;
|
||||||
|
/** Абсолютный URL сайта, где живёт этот список. undefined, пока не настроено. */
|
||||||
|
siteUrl?: string;
|
||||||
|
section: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обёртка ответа API (все ответы приходят в поле data).
|
// Обёртка ответа API (все ответы приходят в поле data).
|
||||||
|
|||||||
Reference in New Issue
Block a user