куча правок

This commit is contained in:
gorbenko.pavel
2026-08-04 15:38:05 +05:00
parent 3a68f2a1c3
commit 3684c75a0c
6 changed files with 520 additions and 158 deletions
@@ -52,6 +52,11 @@
position: sticky;
top: $sp-4;
align-self: stretch;
transition: flex-basis .2s;
}
.sideCollapsed {
flex-basis: 44px;
}
.tabsWrap {
@@ -23,6 +23,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
.join(' ');
const route = useRoute();
const [panelCollapsed, setPanelCollapsed] = React.useState<boolean>(false);
const [activeCategoryId, setActiveCategoryId] = React.useState<string>(() => {
if (route.mode === 'create' && route.formKey) {
@@ -32,19 +33,43 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
return DEFAULT_CATEGORY_ID;
});
// Клик по заявке в «Текущие заявки» (или ссылка «создать на основе») — переключает
// вкладку категории на ту, к которой относится тип этой заявки, чтобы вкладки
// сверху совпадали с тем, что сейчас открыто в main.
React.useEffect(() => {
if ((route.mode === 'approve' || route.mode === 'create') && route.formKey) {
const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0];
if (found) setActiveCategoryId(found.categoryId);
}
}, [route.mode, route.formKey]);
return (
<section className={rootClasses}>
<IconSprite />
<div className={styles.layout}>
<div className={styles.side}>
<MyRequestsPanel context={context} />
<div className={`${styles.side} ${panelCollapsed ? styles.sideCollapsed : ''}`}>
<MyRequestsPanel
context={context}
collapsed={panelCollapsed}
onToggleCollapsed={() => setPanelCollapsed((v) => !v)}
/>
</div>
<main className={styles.main}>
<div className={styles.tabsWrap}>
<CategoryTabs
categories={CATEGORIES}
activeId={activeCategoryId}
onChange={setActiveCategoryId}
onChange={(id) => {
setActiveCategoryId(id);
// Клик по вкладке всегда возвращает к списку плиток: в режиме
// подачи это уже делает сам SubmitFeature (сброс выбранной формы
// при смене категории), а в режиме согласования (заявка открыта
// из «Текущие заявки») route сам не меняется — выходим явно,
// чтобы поведение вкладок было одинаковым в обоих случаях.
if (route.mode === 'approve') {
navigate({ mode: 'submit' });
}
}}
/>
</div>
{route.mode === 'approve' && route.formKey && route.itemId ? (
@@ -14,16 +14,68 @@
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-4 $sp-5;
border-bottom: 1px solid $gray-200;
}
.collapseToggle {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $navy-700;
cursor: pointer;
transition: .16s;
&:hover {
border-color: $gold-400;
color: $gold-600;
}
}
.panelCollapsed {
align-items: center;
padding: $sp-3 0;
}
.title {
font-family: $font-serif;
font-size: 18px;
color: $navy-900;
}
.searchWrap {
padding: $sp-3 $sp-5 0;
}
.search {
width: 100%;
padding: 8px 12px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
font-size: $fs-sm;
color: $gray-900;
transition: .18s;
@include input-focus-gold;
&::placeholder {
color: $gray-400;
}
}
.body {
flex: 1;
overflow-y: auto;
@@ -56,16 +108,53 @@
margin-bottom: $sp-4;
}
.group + .group {
margin-top: $sp-5;
.pager {
display: flex;
align-items: center;
gap: 4px;
margin-top: $sp-3;
flex-wrap: wrap;
}
.groupTitle {
font-family: $font-serif;
font-size: $fs-label;
font-weight: 600;
color: $navy-800;
margin-bottom: $sp-2;
.pagerBtn {
min-width: 24px;
height: 24px;
padding: 0 6px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $white;
color: $gray-700;
font-family: $font-sans;
font-size: $fs-caption;
cursor: pointer;
transition: .16s;
&:hover:not(:disabled) {
border-color: $gold-400;
color: $gold-600;
}
&:disabled {
opacity: .4;
cursor: default;
}
}
.pagerBtnActive {
background: $navy-800;
border-color: $navy-800;
color: $white;
&:hover {
border-color: $navy-800 !important;
color: $white !important;
}
}
.pagerEllipsis {
color: $gray-400;
font-size: $fs-caption;
padding: 0 2px;
}
.list {
@@ -103,6 +192,14 @@
overflow: hidden;
}
.itemType {
color: $gold-600;
font-size: $fs-caption;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .03em;
}
.itemTitle {
color: $gray-900;
font-size: $fs-sm;
@@ -1,33 +1,21 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getMyRequests } from './myRequests.service';
import { getLinkedTypes, getPendingApproval, searchOwnRequests } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS } from '../../shared/approval';
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
import { navigate, routeUrl } from '../../shared/routing';
import { onRequestsChanged } from '../../shared/requestEvents';
import { Icon } from '../../shared/ui/Icon/Icon';
import styles from './MyRequestsPanel.module.scss';
export interface IMyRequestsPanelProps {
context: WebPartContext;
/** Свёрнута ли панель (управляется снаружи — RequestPortal меняет ширину колонки). */
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
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] }));
}
const PAGE_SIZE = 20;
function formatDate(iso: string): string {
if (!iso) return '';
@@ -74,6 +62,7 @@ const RequestItem: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({
}}
>
<div className={styles.itemMain}>
<span className={styles.itemType}>{r.requestTypeName}</span>
<span className={styles.itemTitle}>{r.title || `#${r.id}`}</span>
{variant === 'approval' && (
<span className={styles.itemAuthor}>
@@ -89,50 +78,206 @@ const RequestItem: React.FC<{ r: IMyRequest; variant: 'own' | 'approval' }> = ({
</li>
);
const RequestGroups: React.FC<{ rows: IMyRequest[]; variant: 'own' | 'approval' }> = ({
rows,
variant,
}) => (
<>
{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} variant={variant} key={`${r.requestTypeId}-${r.id}`} />
))}
</ul>
</div>
))}
</>
);
// Постраничная навигация: номера страниц + вперёд/назад. При большом
// количестве страниц показывает только соседние с текущей + первую/последнюю.
const Pager: React.FC<{ page: number; totalPages: number; onChange: (page: number) => void }> = ({
page,
totalPages,
onChange,
}) => {
if (totalPages <= 1) return null;
const numbers: number[] = [];
const from = Math.max(1, page - 2);
const to = Math.min(totalPages, page + 2);
for (let i = from; i <= to; i++) numbers.push(i);
return (
<div className={styles.pager}>
<button
type="button"
className={styles.pagerBtn}
disabled={page <= 1}
onClick={() => onChange(page - 1)}
aria-label="Предыдущая страница"
>
‹
</button>
{from > 1 && (
<>
<button type="button" className={styles.pagerBtn} onClick={() => onChange(1)}>
1
</button>
{from > 2 && <span className={styles.pagerEllipsis}>…</span>}
</>
)}
{numbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pagerBtn} ${n === page ? styles.pagerBtnActive : ''}`}
onClick={() => onChange(n)}
>
{n}
</button>
))}
{to < totalPages && (
<>
{to < totalPages - 1 && <span className={styles.pagerEllipsis}>…</span>}
<button type="button" className={styles.pagerBtn} onClick={() => onChange(totalPages)}>
{totalPages}
</button>
</>
)}
<button
type="button"
className={styles.pagerBtn}
disabled={page >= totalPages}
onClick={() => onChange(page + 1)}
aria-label="Следующая страница"
>
›
</button>
</div>
);
};
// «Ждёт вашего решения»: данные уже загружены целиком (алгоритм требует видеть
// весь маршрут заявки, чтобы понять, чья сейчас очередь) — один общий список
// по всем типам, пагинация — клиентская нарезка по этому списку.
const PendingSection: React.FC<{ rows: IMyRequest[] }> = ({ rows }) => {
const [page, setPage] = React.useState(1);
React.useEffect(() => setPage(1), [rows]);
if (rows.length === 0) {
return <div className={styles.empty}>Нет заявок, ждущих вашего решения.</div>;
}
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const pageRows = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<>
<ul className={styles.list}>
{pageRows.map((r) => (
<RequestItem r={r} variant="approval" key={r.id} />
))}
</ul>
<Pager page={page} totalPages={totalPages} onChange={setPage} />
</>
);
};
// «Созданы вами»: один общий постраничный поиск по всем типам сразу через
// SharePoint Search REST API (см. myRequests.service.searchOwnRequests) —
// единая пагинация, без разбивки списка по типам заявок.
const OwnSection: React.FC<{ context: WebPartContext; types: IRequestType[]; query: string }> = ({
context,
types,
query,
}) => {
const [page, setPage] = React.useState(1);
const [rows, setRows] = React.useState<IMyRequest[]>([]);
const [totalRows, setTotalRows] = React.useState<number | undefined>(undefined);
const [error, setError] = React.useState<string>('');
React.useEffect(() => setPage(1), [query]);
React.useEffect(() => {
if (types.length === 0) {
setTotalRows(0);
return;
}
let cancelled = false;
searchOwnRequests(context, types, query, page)
.then((result) => {
if (cancelled) return;
setRows(result.rows);
setTotalRows(result.totalRows);
setError('');
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [context, types, query, page]);
if (error) {
return <div className={styles.error}>Не удалось загрузить: {error}</div>;
}
if (totalRows === undefined) {
return <div className={styles.empty}>Загрузка…</div>;
}
if (totalRows === 0) {
return <div className={styles.empty}>Вы ещё не создавали заявок.</div>;
}
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
return (
<>
<ul className={styles.list}>
{rows.map((r) => (
<RequestItem r={r} variant="own" key={r.id} />
))}
</ul>
<Pager page={page} totalPages={totalPages} onChange={setPage} />
</>
);
};
// Постоянная боковая панель «Текущие заявки»: заявки, созданные текущим
// пользователем, и заявки, ждущие сейчас его решения как согласующего —
// по всем типам заявок, известным approval-route API.
// по всем типам заявок, известным approval-route API, в двух общих списках
// без разбивки по типам (тип виден на самой карточке).
// Владелец: второй разработчик. Вся логика/данные — внутри этой папки.
// Общий UI-кит (shared/ui), pnpjs и config можно импортировать, но НЕ менять.
//
// Перечитывает данные по событию rp-requests-changed (shared/requestEvents) —
// оно шлётся после сохранения формы (SubmitFeature) и после решения
// согласующего (ApproverService.decide), так что панель обновляется сама,
// без перезагрузки страницы.
// «Ждёт вашего решения» перечитывается по событию rp-requests-changed
// (shared/requestEvents) — оно шлётся после сохранения формы и после решения
// согласующего. «Созданы вами» идёт через SharePoint Search (см.
// myRequests.service) постранично — намеренно НЕ обновляется по этому
// событию: только что созданная заявка появится там, когда её проиндексирует
// поиск (задержка приемлема).
//
// Диагностика по типам заявок (listId/siteUrl/rowCount/error) в UI не выводится —
// см. console.table в devtools при загрузке панели.
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context }) => {
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
// Поиск по названию: «Ждёт вашего решения» фильтруется на клиенте (данные уже
// загружены целиком), «Созданы вами» — реальный запрос к Search REST API
// (может слегка отставать от только что созданных/переименованных заявок).
//
// Диагностика (listId/siteUrl/rowCount/error, KQL-запросы) в UI не выводится —
// см. console.log/console.table в devtools при загрузке панели.
const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context, collapsed, onToggleCollapsed }) => {
const [linkedTypes, setLinkedTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [pendingRows, setPendingRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [error, setError] = React.useState<string>('');
const [query, setQuery] = React.useState<string>('');
React.useEffect(() => {
let cancelled = false;
getLinkedTypes(context)
.then((types) => {
if (!cancelled) setLinkedTypes(types);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
}, [context]);
React.useEffect(() => {
let cancelled = false;
const load = (): void => {
getMyRequests(context)
getPendingApproval(context)
.then((result) => {
if (cancelled) return;
setRows(result.rows);
setError('');
setPendingRows(result.rows);
console.table(result.debug);
})
.catch((e) => {
@@ -148,32 +293,66 @@ const MyRequestsPanel: React.FC<IMyRequestsPanelProps> = ({ context }) => {
};
}, [context]);
const pendingApproval = rows ? rows.filter((r) => r.kind === 'approval') : [];
const own = rows ? rows.filter((r) => r.kind === 'own') : [];
const q = query.trim().toLowerCase();
const filteredPending =
pendingRows && q ? pendingRows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : pendingRows;
const nothingLoaded = linkedTypes !== undefined && pendingRows !== undefined;
if (collapsed) {
return (
<aside className={`${styles.panel} ${styles.panelCollapsed}`} aria-label="Текущие заявки (свёрнуто)">
<button
type="button"
className={styles.collapseToggle}
onClick={onToggleCollapsed}
aria-label="Показать «Текущие заявки»"
title="Показать «Текущие заявки»"
>
<Icon name="chevron-right" size="sm" />
</button>
</aside>
);
}
return (
<aside className={styles.panel} aria-label="Текущие заявки">
<div className={styles.header}>
<span className={styles.title}>Текущие заявки</span>
<button
type="button"
className={styles.collapseToggle}
onClick={onToggleCollapsed}
aria-label="Свернуть «Текущие заявки»"
title="Свернуть «Текущие заявки»"
>
<Icon name="chevron-left" size="sm" />
</button>
</div>
<div className={styles.searchWrap}>
<input
type="search"
className={styles.search}
placeholder="Поиск по названию…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</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>
)}
{!error && !nothingLoaded && <div className={styles.empty}>Загрузка…</div>}
{pendingApproval.length > 0 && (
{!error && nothingLoaded && (
<div className={styles.section}>
<div className={styles.sectionTitle}>Ждёт вашего решения</div>
<RequestGroups rows={pendingApproval} variant="approval" />
<PendingSection rows={filteredPending || []} />
</div>
)}
{own.length > 0 && (
{!error && nothingLoaded && (
<div className={styles.section}>
<div className={styles.sectionTitle}>Созданы вами</div>
<RequestGroups rows={own} variant="own" />
<OwnSection context={context} types={linkedTypes || []} query={q} />
</div>
)}
</div>
@@ -1,5 +1,3 @@
import { IMyRequest } from './IMyRequest';
// Диагностика по одному типу заявки — для отладочного блока в панели «Мои заявки».
export interface IMyRequestsTypeDebug {
requestTypeId: number;
@@ -24,8 +22,3 @@ export interface IMyRequestsTypeDebug {
/** Текст ошибки, если запрос к списку согласования упал. */
approvalError?: string;
}
export interface IMyRequestsResult {
rows: IMyRequest[];
debug: IMyRequestsTypeDebug[];
}
@@ -1,13 +1,13 @@
import { spfi, SPFI, SPFx } from '@pnp/sp';
import { IList } from '@pnp/sp/lists';
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 '@pnp/sp/search';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval';
import { IMyRequest } from './models/IMyRequest';
import { IMyRequestsResult, IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug';
// Список данных «... — данные» → список согласования «... — согласование»
// (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json).
@@ -19,19 +19,30 @@ interface IApprovalStep {
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> {
export function currentUserEmail(context: WebPartContext): string {
return (context.pageContext.user.email || '').toLowerCase();
}
// Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению).
export async function getLinkedTypes(context: WebPartContext): Promise<IRequestType[]> {
const types = await getRequestTypes(context);
return types.filter((t) => !!t.listId && !!t.siteUrl);
}
// ---------------------------------------------------------------------------
// «Ждёт вашего решения» — требует видеть ВЕСЬ маршрут заявки, чтобы понять,
// чья сейчас очередь, поэтому идёт обычным REST-запросом (не через Search),
// целиком за один раз. Пагинация по этому результату — на стороне панели
// (клиентская нарезка по страницам), т.к. сам список обычно небольшой
// (сколько заявок сейчас реально ждут решения ОДНОГО согласующего).
export async function getPendingApproval(
context: WebPartContext
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> {
const types = await getLinkedTypes(context);
const tokenEmail = currentUserEmail(context);
const perType = await Promise.all(
types.map((t) => (t.listId && t.siteUrl ? getMyRequestsForType(context, t) : skipType(t)))
types.map((t) => getApprovalPendingForType(context, t, tokenEmail))
);
return {
@@ -40,22 +51,10 @@ export async function getMyRequests(context: WebPartContext): Promise<IMyRequest
};
}
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(
async function getApprovalPendingForType(
context: WebPartContext,
type: IRequestType
type: IRequestType,
tokenEmail: string
): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> {
const debug: IMyRequestsTypeDebug = {
requestTypeId: type.id,
@@ -69,61 +68,11 @@ async function getMyRequestsForType(
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 tokenEmail = (context.pageContext.user.email || '').toLowerCase();
const ownRows = await getOwnRows(list, type, me.Id, tokenEmail, debug);
const pendingRows = await getApprovalPendingRows(sp, list, type, tokenEmail, 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 [];
return { rows: [], debug };
}
const approvalListTitle = `${dataTitle.replace(DATA_LIST_SUFFIX, '')} — согласование`;
debug.approvalListTitle = approvalListTitle;
@@ -173,7 +122,7 @@ async function getApprovalPendingRows(
console.table(byTicketDebug);
debug.approvalPendingTickets = myTicketIds.length;
if (myTicketIds.length === 0) return [];
if (myTicketIds.length === 0) return { rows: [], debug };
const ticketFilter = myTicketIds.map((id) => `Id eq ${id}`).join(' or ');
const ticketRows = (await list.items
@@ -182,17 +131,18 @@ async function getApprovalPendingRows(
.expand('Author')
.top(2000)()) as Array<Record<string, unknown>>;
return ticketRows.map((r) => {
const rows = ticketRows.map((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
return toMyRequest(r, author, type, 'approval');
});
return { rows, debug };
} catch (e) {
debug.approvalError = (e as Error).message;
console.error(
`Мои заявки: не удалось прочитать согласование для «${type.name}»:`,
debug.approvalError
);
return [];
return { rows: [], debug };
}
}
@@ -221,6 +171,119 @@ function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean {
return current.some((s) => s.approverEmail === tokenEmail);
}
// ---------------------------------------------------------------------------
// «Созданы вами» — по каждому типу отдельно, через SharePoint Search REST API
// (search/query, KQL), с постраничной выборкой (RowLimit/StartRow). Заявок
// автора может накопиться много со временем, поэтому весь список целиком не
// грузим — только текущую страницу. Расплата за это — результаты идут из
// поискового индекса, который переиндексируется с задержкой (только что
// созданная/переименованная заявка может не найтись сразу).
//
// Search отдаёт только Title/Path/Id — Status и прочие поля не запрашиваем
// через Search (для кастомных колонок нет гарантии, что они вообще
// проиндексированы как managed property), а дочитываем обычным REST-запросом
// по найденным Id из самого списка данных — так надёжнее.
export interface IOwnSearchPage {
rows: IMyRequest[];
totalRows: number;
}
const PAGE_SIZE = 20;
function siteOrigin(siteUrl: string): string {
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
return match ? match[0] : siteUrl;
}
// KQL не терпит спецсимволов в значении — оставляем буквы (лат./кир.), цифры и пробелы.
// (без \p{L} — целевой ES-таргет проекта его не поддерживает)
function sanitizeForKql(text: string): string {
return text
.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Один запрос на ВСЕ типы сразу (не по одному на тип) — так у результата единая
// пагинация (одна StartRow/RowLimit/TotalRows), без разбивки по типам заявок.
// Path одного типа ищем через OR внутри запроса; после поиска по Path каждого
// найденного элемента определяем, какому типу он принадлежит, и дочитываем
// поля из ЕГО списка данных (см. комментарий выше — Search отдаёт не все поля).
export async function searchOwnRequests(
context: WebPartContext,
types: IRequestType[],
query: string,
page: number
): Promise<IOwnSearchPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
const sp = spfi(types[0].siteUrl as string).using(SPFx(context));
const tokenEmail = currentUserEmail(context);
const claimsLogin = `i:0#.f|membership|${tokenEmail}`;
const typeInfo = types.map((t) => ({ type: t, listAbsUrl: `${siteOrigin(t.siteUrl as string)}${t.listId}` }));
const pathClause = typeInfo.map((t) => `Path:"${t.listAbsUrl}*"`).join(' OR ');
const clauses = [`(${pathClause})`, `AuthorOWSUSER:"${claimsLogin}"`];
const q = sanitizeForKql(query);
if (q) clauses.push(`Title:${q}*`);
const kql = clauses.join(' AND ');
const results = await sp.search({
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: ['ListItemID', 'Title', 'Path'],
TrimDuplicates: false,
});
console.log(`[Текущие заявки] Search «Созданы вами»: KQL = ${kql}, всего найдено: ${results.TotalRows}`);
console.table(results.PrimarySearchResults);
const hits = results.PrimarySearchResults.map((r) => {
const rec = r as Record<string, unknown>;
return { id: Number(rec.ListItemID), path: String(rec.Path || '') };
}).filter((h) => !isNaN(h.id) && h.id > 0);
if (hits.length === 0) {
return { rows: [], totalRows: results.TotalRows || 0 };
}
// Каждый найденный элемент → его тип (по префиксу Path), чтобы дочитать поля
// из правильного списка данных.
const idsByType = new Map<IRequestType, number[]>();
hits.forEach((h) => {
const matched = typeInfo.find((t) => h.path.toLowerCase().indexOf(t.listAbsUrl.toLowerCase()) === 0);
if (!matched) return;
const ids = idsByType.get(matched.type) || [];
ids.push(h.id);
idsByType.set(matched.type, ids);
});
const byId = new Map<number, IMyRequest>();
await Promise.all(
Array.from(idsByType.entries()).map(async ([type, ids]) => {
const typeSp = spfi(type.siteUrl as string).using(SPFx(context));
const list = typeSp.web.getList(type.listId as string);
const filter = ids.map((id) => `Id eq ${id}`).join(' or ');
const rows = (await list.items
.filter(filter)
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(PAGE_SIZE)()) as Array<Record<string, unknown>>;
rows.forEach((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
byId.set(r.Id as number, toMyRequest(r, author, type, 'own'));
});
})
);
// Сохраняем порядок релевантности из поиска, а не порядок ответов REST.
const ordered = hits.map((h) => byId.get(h.id)).filter((r): r is IMyRequest => !!r);
return { rows: ordered, totalRows: results.TotalRows || 0 };
}
function toMyRequest(
r: Record<string, unknown>,
author: { EMail?: string; Title?: string } | undefined,