куча правок
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user