Панель со списками заявок #1

Merged
timur.gorodetskiy merged 12 commits from left-panel into main 2026-08-04 12:02:11 +00:00
5 changed files with 276 additions and 81 deletions
Showing only changes of commit 687ba5a753 - Show all commits
@@ -40,6 +40,22 @@
color: $red-500;
}
.section + .section {
margin-top: $sp-6;
padding-top: $sp-6;
border-top: 1px solid $gray-200;
}
.sectionTitle {
font-family: $font-sans;
font-size: $fs-caption;
font-weight: 600;
color: $gray-500;
text-transform: uppercase;
letter-spacing: .04em;
margin-bottom: $sp-4;
}
.group + .group {
margin-top: $sp-5;
}
@@ -53,8 +53,52 @@ function statusClass(status: string): string {
}
}
// Постоянная боковая панель «Мои заявки»: заявки, которые создал текущий
// пользователь, по всем типам заявок, известным approval-route API.
const RequestItem: React.FC<{ r: IMyRequest }> = ({ r }) => (
<li>
<a
className={styles.item}
href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
onClick={(e) => {
// Обычный левый клик — SPA-переход (без перезагрузки страницы).
// Ctrl/Cmd/Shift/среднюю кнопку — не трогаем, пусть браузер сам
// откроет в новой вкладке по href.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
navigate({ mode: 'approve', formKey: r.formKey, itemId: 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>
</a>
</li>
);
const RequestGroups: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => (
<>
{groupByType(rows).map((group) => (
<div className={styles.group} key={group.requestTypeName}>
<div className={styles.groupTitle}>{group.requestTypeName}</div>
<ul className={styles.list}>
{group.rows.map((r) => (
<RequestItem r={r} key={`${r.requestTypeId}-${r.id}`} />
))}
</ul>
</div>
))}
</>
);
// Постоянная боковая панель «Текущие заявки»: заявки, созданные текущим
// пользователем, и заявки, ждущие сейчас его решения как согласующего —
// по всем типам заявок, известным approval-route API.
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
//
@@ -80,55 +124,34 @@ const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context }) => {
};
}, [context]);
const groups = rows ? groupByType(rows) : [];
const pendingApproval = rows ? rows.filter((r) => r.kind === 'approval') : [];
const own = rows ? rows.filter((r) => r.kind === 'own') : [];
return (
<aside className={styles.panel} aria-label="Мои заявки">
<aside className={styles.panel} aria-label="Текущие заявки">
<div className={styles.header}>
<span className={styles.title}>Мои заявки</span>
<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>
<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 key={`${r.requestTypeId}-${r.id}`}>
<a
className={styles.item}
href={routeUrl({ mode: 'approve', formKey: r.formKey, itemId: r.id })}
onClick={(e) => {
// Обычный левый клик — SPA-переход (без перезагрузки страницы).
// Ctrl/Cmd/Shift/среднюю кнопку — не трогаем, пусть браузер сам
// откроет в новой вкладке по href.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
navigate({ mode: 'approve', formKey: r.formKey, itemId: 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>
</a>
</li>
))}
</ul>
{pendingApproval.length > 0 && (
<div className={styles.section}>
<div className={styles.sectionTitle}>Ждёт вашего решения</div>
<RequestGroups rows={pendingApproval} />
</div>
))}
)}
{own.length > 0 && (
<div className={styles.section}>
<div className={styles.sectionTitle}>Созданы вами</div>
<RequestGroups rows={own} />
</div>
)}
</div>
</aside>
);
@@ -13,4 +13,6 @@ export interface IMyRequest {
section: string;
/** Ключ формы (rptype в URL режима согласования) — поле pageUrl из approval-route API. */
formKey: string;
/** 'own' — заявка, созданная текущим пользователем; 'approval' — ждёт его решения. */
kind: 'own' | 'approval';
}
@@ -12,6 +12,17 @@ export interface IMyRequestsTypeDebug {
rowCount?: number;
/** Текст ошибки, если запрос к списку упал. */
error?: string;
/** Заголовок списка согласования, вычисленный из имени списка данных. */
approvalListTitle?: string;
/** Список данных не похож на «... — данные» — список согласования не искали. */
approvalSkippedReason?: string;
/** Сколько строк маршрута (фаза «Согласование») прочитано из списка согласования. */
approvalRowCount?: number;
/** Сколько заявок сейчас ждут решения текущего пользователя. */
approvalPendingTickets?: number;
/** Текст ошибки, если запрос к списку согласования упал. */
approvalError?: string;
}
export interface IMyRequestsResult {
@@ -1,20 +1,32 @@
import { spfi, SPFx } from '@pnp/sp';
import { spfi, SPFI, SPFx } from '@pnp/sp';
import { IList } from '@pnp/sp/lists';
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 { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
import { IMyRequest } from './models/IMyRequest';
import { IMyRequestsResult, IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
// Заявки текущего пользователя по всем типам заявок, известным approval-route API.
// Для каждого типа с заполненными listId/siteUrl читаем список данных на его сайте
// и берём строки, где Author — текущий пользователь. Типы без listId/siteUrl
// (ещё не настроены на бэкенде) пропускаются. Ошибка по одному списку (нет прав,
// список удалён и т.п.) не должна ронять всю панель — такие типы просто дают
// пустой результат. debug возвращается всегда — панель показывает его как
// отладочную информацию (что пропущено и почему, что упало с ошибкой).
// Список данных «... — данные» → список согласования «... — согласование»
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
interface IApprovalStep {
status: string;
order?: number;
approverEmail: string;
}
// Заявки текущего пользователя по всем типам заявок, известным approval-route API:
// созданные им самим (kind: 'own') и ждущие сейчас его решения как согласующего
// (kind: 'approval'). Для каждого типа с заполненными listId/siteUrl читаем список
// данных на его сайте; список согласования ищем по имени списка данных (суффикс
// «— данные» → «— согласование»). Типы без listId/siteUrl (ещё не настроены на
// бэкенде) пропускаются. Ошибка по одному списку не должна ронять всю панель —
// такой тип просто даёт пустой результат. debug возвращается всегда — панель
// не показывает его в UI, но пишет в console.table при загрузке.
export async function getMyRequests(context: WebPartContext): Promise<IMyRequestsResult> {
const types = await getRequestTypes(context);
@@ -58,43 +70,174 @@ async function getMyRequestsForType(
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,
formKey: type.pageUrl,
};
})
.filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail);
const ownRows = await getOwnRows(list, type, me.Id, tokenEmail, debug);
const pendingRows = await getApprovalPendingRows(sp, list, type, tokenEmail, debug);
return { rows: mapped, debug };
return { rows: ownRows.concat(pendingRows), debug };
} catch (e) {
debug.error = (e as Error).message;
console.error(`Мои заявки: не удалось прочитать список типа «${type.name}»:`, debug.error);
return { rows: [], debug };
}
}
// Заявки, созданные текущим пользователем (Author = токен).
async function getOwnRows(
list: IList,
type: IRequestType,
myId: number,
tokenEmail: string,
debug: IMyRequestsTypeDebug
): Promise<IMyRequest[]> {
const rows = (await list.items
.filter(`AuthorId eq ${myId}`)
.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;
// Токен пользователя — источник правды для сравнения с Author/EMail списка.
// Не совпало (напр. Author/EMail пуст на профиле сайта) — строку не показываем.
return rows
.map((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
return toMyRequest(r, author, type, 'own');
})
.filter((r) => !!tokenEmail && r.authorEmail.toLowerCase() === tokenEmail);
}
// Заявки, где сейчас очередь текущего пользователя как согласующего.
async function getApprovalPendingRows(
sp: SPFI,
list: IList,
type: IRequestType,
tokenEmail: string,
debug: IMyRequestsTypeDebug
): Promise<IMyRequest[]> {
try {
const dataListInfo = await list.select('Title')();
const dataTitle = (dataListInfo.Title as string) || '';
if (!DATA_LIST_SUFFIX.test(dataTitle)) {
debug.approvalSkippedReason = `«${dataTitle}» не оканчивается на «— данные» — список согласования не ищем`;
return [];
}
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
debug.approvalListTitle = approvalListTitle;
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
// Phase не смотрим вообще — работаем только по Status.
const stepRows = (await approvalList.items
.select('Id', 'StepOrder', 'Status', 'TicketId', 'Approver/EMail')
.expand('Approver')
.top(2000)()) as Array<Record<string, unknown>>;
console.log(
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», элементов в списке: ${stepRows.length}, ваш email из токена: «${tokenEmail}»`
);
console.table(
stepRows.map((r) => ({
Id: r.Id,
TicketId: r.TicketId,
StepOrder: r.StepOrder,
Status: r.Status,
'Approver/EMail': (r.Approver as { EMail?: string } | undefined)?.EMail,
}))
);
debug.approvalRowCount = stepRows.length;
const byTicket = new Map<number, IApprovalStep[]>();
stepRows.forEach((r) => {
const ticketId = r.TicketId as number | undefined;
if (!ticketId) return;
const approver = r.Approver as { EMail?: string } | undefined;
const steps = byTicket.get(ticketId) || [];
steps.push({
status: (r.Status as string) || '',
order: typeof r.StepOrder === 'number' ? (r.StepOrder as number) : undefined,
approverEmail: (approver?.EMail || '').toLowerCase(),
});
byTicket.set(ticketId, steps);
});
const myTicketIds: number[] = [];
const byTicketDebug: Array<Record<string, unknown>> = [];
byTicket.forEach((steps, ticketId) => {
const mine = isMyTurn(steps, tokenEmail);
byTicketDebug.push({ TicketId: ticketId, isMyTurn: mine, steps: JSON.stringify(steps) });
if (mine) myTicketIds.push(ticketId);
});
console.table(byTicketDebug);
debug.approvalPendingTickets = myTicketIds.length;
if (myTicketIds.length === 0) return [];
const ticketFilter = myTicketIds.map((id) => `Id eq ${id}`).join(' or ');
const ticketRows = (await list.items
.filter(ticketFilter)
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(2000)()) as Array<Record<string, unknown>>;
return ticketRows.map((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
return toMyRequest(r, author, type, 'approval');
});
} catch (e) {
debug.approvalError = (e as Error).message;
console.error(
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
debug.approvalError
);
return [];
}
}
// Сейчас ли очередь пользователя с данным email среди строк маршрута одной заявки.
// Маршрут остановлен (где-то есть Отклонено/Возвращена) — не его очередь, заявка
// не активна. Иначе «текущие» строки — это ещё не решённые (Status = На согласовании)
// без указанного StepOrder (параллельно, все активны сразу) плюс, если есть строки
// с указанным StepOrder, ещё не решённая с минимальным StepOrder (последовательно).
function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean {
if (!tokenEmail) return false;
const stopped = steps.some(
(s) => s.status === REQUEST_STATUS.rejected || s.status === REQUEST_STATUS.returned
);
if (stopped) return false;
const pending = steps.filter((s) => s.status === REQUEST_STATUS.onApproval);
const noOrder = pending.filter((s) => s.order === undefined);
const ordered = pending.filter((s): s is IApprovalStep & { order: number } => s.order !== undefined);
const current = [...noOrder];
if (ordered.length > 0) {
const minOrder = Math.min(...ordered.map((s) => s.order));
current.push(...ordered.filter((s) => s.order === minOrder));
}
return current.some((s) => s.approverEmail === tokenEmail);
}
function toMyRequest(
r: Record<string, unknown>,
author: { EMail?: string; Title?: string } | undefined,
type: IRequestType,
kind: 'own' | 'approval'
): IMyRequest {
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,
formKey: type.pageUrl,
kind,
};
}