левая панель готова
This commit is contained in:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user