дозагрузка свежих данных

This commit is contained in:
gorbenko.pavel
2026-08-05 13:53:54 +05:00
parent d8737a15e3
commit 8f7d81d2b6
10 changed files with 648 additions and 119 deletions
@@ -12,6 +12,7 @@ import { REQUEST_TYPES } from '../data/requests';
import SubmitFeature from '../features/submit/SubmitFeature';
import ApproveFeature from '../features/approve/ApproveFeature';
import { RequestsTableView } from '../features/myRequests/RequestsTableView';
import { SearchView } from '../features/myRequests/SearchView';
import { getPendingApproval } from '../features/myRequests/myRequests.service';
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
@@ -39,14 +40,18 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
});
// Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и
// сами таблицы (создание заявки, решение согласующего).
// сами таблицы (создание заявки, решение согласующего), и на монтировании
// (в т.ч. на обновление страницы). Когда сама вкладка «На согласовании»
// открыта, RequestsTableView сообщает точное число через onMyTurnCountChange
// (ниже) — это перезаписывает значение отсюда, чтобы не было гонки между
// двумя независимыми запросами одних и тех же данных.
React.useEffect(() => {
let cancelled = false;
const load = (): void => {
getPendingApproval(context)
.then((r) => {
if (!cancelled) setApprovalsCount(r.rows.length);
if (!cancelled) setApprovalsCount(r.totalRows);
})
.catch(() => {
if (!cancelled) setApprovalsCount(undefined);
@@ -108,6 +113,18 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
}
/>
</>
) : route.nav === 'search' ? (
<SearchView
context={context}
onOpen={(request) =>
navigate({
mode: 'approve',
nav: route.nav,
formKey: request.formKey,
itemId: request.id,
})
}
/>
) : (
<RequestsTableView
context={context}
@@ -120,6 +137,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
itemId: request.id,
})
}
onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined}
/>
)}
</main>
@@ -14,6 +14,7 @@ const ITEMS: INavItem[] = [
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
{ id: 'approvals', title: 'На согласовании', icon: 'user-check' },
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
{ id: 'search', title: 'Поиск', icon: 'search' },
];
interface ISideNavProps {
@@ -0,0 +1,50 @@
@import '../../../../styles/design-tokens';
.pager {
display: flex;
align-items: center;
gap: $sp-1;
margin-top: $sp-4;
flex-wrap: wrap;
}
.pagerBtn {
min-width: 30px;
height: 30px;
padding: 0 8px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $white;
color: $gray-700;
font-family: $font-sans;
font-size: $fs-sm;
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-sm;
padding: 0 4px;
}
@@ -0,0 +1,69 @@
import * as React from 'react';
import styles from './Pager.module.scss';
export interface IPagerProps {
page: number;
totalPages: number;
onChange: (page: number) => void;
}
// Постраничная навигация: номера страниц + вперёд/назад. При большом
// количестве страниц показывает только соседние с текущей + первую/последнюю.
// Общий для всех разделов «Текущие заявки» (RequestsTableView, SearchView).
export const Pager: React.FC<IPagerProps> = ({ 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>
);
};
@@ -193,52 +193,3 @@
background: $gray-100;
color: $gray-500;
}
.pager {
display: flex;
align-items: center;
gap: $sp-1;
margin-top: $sp-4;
flex-wrap: wrap;
}
.pagerBtn {
min-width: 30px;
height: 30px;
padding: 0 8px;
border: 1px solid $gray-200;
border-radius: $r-sm;
background: $white;
color: $gray-700;
font-family: $font-sans;
font-size: $fs-sm;
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-sm;
padding: 0 4px;
}
@@ -11,6 +11,7 @@ import {
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents';
import { Pager } from './Pager';
import styles from './RequestsTableView.module.scss';
const PAGE_SIZE = 20;
@@ -21,6 +22,10 @@ export interface IRequestsTableViewProps {
kind: 'approvals' | 'own';
/** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => void;
/** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для
* счётчика в SideNav, чтобы не гонять для него отдельный параллельный
* запрос, который мог бы разойтись по времени с тем, что видно в таблице. */
onMyTurnCountChange?: (count: number) => void;
}
function formatDate(iso: string): string {
@@ -48,70 +53,6 @@ function statusClass(status: string): string {
}
}
// Постраничная навигация: номера страниц + вперёд/назад. При большом
// количестве страниц показывает только соседние с текущей + первую/последнюю.
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>
);
};
// Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся
// строка, а не только название), поэтому строка — <tr> с onClick + tabIndex
// (доступ с клавиатуры: Enter/Space).
@@ -191,7 +132,12 @@ const Table: React.FC<{
// Оба варианта перечитываются по событию rp-requests-changed
// (shared/requestEvents) — после создания заявки и после решения согласующего.
// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно.
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context, kind, onOpen }) => {
export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
context,
kind,
onOpen,
onMyTurnCountChange,
}) => {
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [totalRows, setTotalRows] = React.useState<number>(0);
@@ -240,6 +186,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({ context,
setRows(r.rows);
setTotalRows(r.totalRows);
setError('');
if (onMyTurnCountChange) onMyTurnCountChange(r.totalRows);
return;
}
@@ -0,0 +1,203 @@
@import '../../../../styles/design-tokens';
.view {
width: 100%;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-4;
flex-wrap: wrap;
margin-bottom: $sp-4;
}
.heading {
display: flex;
align-items: center;
gap: $sp-3;
margin: 0;
font-family: $font-serif;
font-size: $fs-page-title;
font-weight: 600;
color: $navy-900;
}
.count {
padding: 2px 10px;
border-radius: $r-full;
background: $navy-100;
color: $navy-700;
font-family: $font-sans;
font-size: $fs-label;
font-weight: 600;
}
.search {
width: 420px;
max-width: 100%;
padding: 9px 13px;
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;
}
}
.empty,
.error {
padding: $sp-6;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
color: $gray-500;
font-size: $fs-sm;
}
.error {
color: $red-500;
border-color: $red-500;
background: $red-50;
}
.tableWrap {
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-1;
overflow: hidden;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: $fs-sm;
th {
text-align: left;
padding: 12px 16px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
color: $gray-500;
font-size: $fs-label;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
white-space: nowrap;
}
td {
padding: 14px 16px;
border-bottom: 1px solid $gray-100;
color: $gray-900;
vertical-align: middle;
overflow-wrap: break-word;
word-break: break-word;
}
tbody tr:last-child td {
border-bottom: 0;
}
}
// Кликабельна вся строка (см. SearchView — onClick на <tr>).
.row {
cursor: pointer;
transition: background .16s;
&:hover {
background: $gold-50;
}
&:focus-visible {
outline: 2px solid $gold-500;
outline-offset: -2px;
}
}
.colId {
width: 70px;
color: $gray-400;
white-space: nowrap;
}
.colType {
width: 170px;
color: $navy-700;
font-weight: 500;
}
.colTitle {
min-width: 220px;
}
.colDate {
width: 100px;
color: $gray-500;
white-space: nowrap;
}
.colAuthor {
width: 220px;
color: $gray-500;
}
.colStatus {
width: 170px;
}
.colKind {
width: 110px;
color: $gray-400;
font-size: $fs-caption;
white-space: nowrap;
}
.status {
display: inline-block;
padding: 3px 10px;
border-radius: $r-md;
font-size: $fs-caption;
font-weight: 500;
line-height: 1.3;
}
.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;
}
@@ -0,0 +1,194 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getLinkedTypes, searchMyRequests } from './myRequests.service';
import { IMyRequest } from './models/IMyRequest';
import { REQUEST_STATUS, IRequestType } from '../../shared/approval';
import { onRequestsChanged } from '../../shared/requestEvents';
import { Pager } from './Pager';
import styles from './SearchView.module.scss';
const PAGE_SIZE = 20;
export interface ISearchViewProps {
context: WebPartContext;
/** Клик по строке — открыть заявку (в выезжающей панели). */
onOpen: (request: IMyRequest) => void;
}
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;
}
}
// В отличие от RequestsTableView (где по смыслу раздела нужно либо Автор,
// либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому
// показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» —
// чтобы было видно, откуда строка (kind), не только по автору.
const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => void }> = ({ rows, onOpen }) => (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.colId}>ID</th>
<th className={styles.colType}>Тип</th>
<th className={styles.colTitle}>Название</th>
<th className={styles.colDate}>Дата</th>
<th className={styles.colAuthor}>Автор</th>
<th className={styles.colStatus}>Статус</th>
<th className={styles.colKind}>Роль</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
className={styles.row}
key={`${r.requestTypeId}-${r.id}`}
tabIndex={0}
role="button"
aria-label={`Открыть заявку «${r.title || r.id}»`}
onClick={() => onOpen(r)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen(r);
}
}}
>
<td className={styles.colId}>{r.id}</td>
<td className={styles.colType}>{r.requestTypeName}</td>
<td className={styles.colTitle}>{r.title || `#${r.id}`}</td>
<td className={styles.colDate}>{formatDate(r.created)}</td>
<td className={styles.colAuthor}>
{[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'}
</td>
<td className={styles.colStatus}>
<span className={`${styles.status} ${statusClass(r.status)}`}>{r.status || '—'}</span>
</td>
<td className={styles.colKind}>{r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}</td>
</tr>
))}
</tbody>
</table>
</div>
);
// Вкладка «Поиск»: ищет сразу и среди своих заявок, и среди тех, где
// пользователь участвует как согласующий (независимо от того, дошла ли уже
// очередь) — единым списком, с колонками Автор и Статус одновременно (см.
// searchMyRequests в myRequests.service.ts). Без введённого текста — пусто,
// это не список «на экран по умолчанию», а именно поиск.
//
// Перечитывается по событию rp-requests-changed (после создания заявки и
// после решения согласующего) — но т.к. это по большей части Search, свежая
// правка может проявиться не мгновенно (частично компенсируется довеском
// последних 10 минут через REST — см. myRequests.service.ts).
export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
const [types, setTypes] = React.useState<IRequestType[] | undefined>(undefined);
const [rows, setRows] = React.useState<IMyRequest[] | undefined>(undefined);
const [totalRows, setTotalRows] = React.useState<number>(0);
const [page, setPage] = React.useState<number>(1);
const [query, setQuery] = React.useState<string>('');
const [error, setError] = React.useState<string>('');
React.useEffect(() => setPage(1), [query]);
React.useEffect(() => {
let cancelled = false;
getLinkedTypes(context)
.then((t) => {
if (!cancelled) setTypes(t);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
};
}, [context]);
React.useEffect(() => {
if (types === undefined) return;
if (query.trim().length === 0) {
setRows(undefined);
setTotalRows(0);
return;
}
let cancelled = false;
const load = async (): Promise<void> => {
try {
const r = await searchMyRequests(context, types, query, page);
if (cancelled) return;
setRows(r.rows);
setTotalRows(r.totalRows);
setError('');
} catch (e) {
if (!cancelled) setError((e as Error).message);
}
};
load().catch(() => undefined);
const unsubscribe = onRequestsChanged(() => {
load().catch(() => undefined);
});
return () => {
cancelled = true;
unsubscribe();
};
}, [context, types, query, page]);
const hasQuery = query.trim().length > 0;
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
return (
<section className={styles.view}>
<div className={styles.head}>
<h2 className={styles.heading}>
Поиск
{hasQuery && rows && <span className={styles.count}>{totalRows}</span>}
</h2>
<input
type="search"
className={styles.search}
placeholder="Введите текст — ищем по своим заявкам и по тем, где вы участвуете…"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</div>
{error && <div className={styles.error}>Не удалось выполнить поиск: {error}</div>}
{!error && !hasQuery && <div className={styles.empty}>Введите текст, чтобы начать поиск.</div>}
{!error && hasQuery && rows === undefined && <div className={styles.empty}>Ищем…</div>}
{!error && hasQuery && rows && totalRows === 0 && (
<div className={styles.empty}>По запросу «{query}» ничего не найдено.</div>
)}
{!error && hasQuery && rows && totalRows > 0 && (
<>
<Table rows={rows} onOpen={onOpen} />
<Pager page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
</section>
);
};
@@ -625,6 +625,101 @@ export async function filterPendingByQuery(
return freshMatches.concat(olderMatches);
}
// ---------------------------------------------------------------------------
// Вкладка «Поиск»: ищет сразу по двум категориям — «Мои заявки» (я автор) и
// «где я участвовал как согласующий» (involved, независимо от того, дошла ли
// уже очередь) — и показывает их одним списком, с колонками Автор и Статус
// одновременно (в отличие от «Мои заявки»/«На согласовании», где по смыслу
// раздела одно из двух не нужно).
//
// Пагинация — клиентская поверх объединённого результата: «Мои заявки» уже
// умеет по-настоящему постранично через Search, но «где участвовал» — нет
// (см. filterPendingByQuery — там честного $skip/$top не сделать), так что
// единой серверной пагинации на двух источниках сразу не построить. Для
// сценария «искал что-то один раз» это приемлемо.
export interface ISearchPage {
rows: IMyRequest[];
totalRows: number;
}
// Как getOwnRequests, но без $skip/$top — сразу весь найденный срез (до
// KQL_ID_LIMIT), с тем же довеском свежих (< 10 мин) заявок через REST.
async function searchOwnAll(
context: WebPartContext,
types: IRequestType[],
query: string
): Promise<IMyRequest[]> {
if (types.length === 0) return [];
const sp = spfi(types[0].siteUrl as string).using(SPFx(context));
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
`(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`,
`AuthorOWSUSER:"${claimsLogin}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
const results = await sp.search({
Querytext: kql,
RowLimit: KQL_ID_LIMIT,
SelectProperties: ['ListItemID', 'Title', 'Path'],
TrimDuplicates: false,
});
console.log(`[Текущие заявки] Search «Поиск» (мои заявки): KQL = ${kql}, найдено: ${results.TotalRows}`);
console.table(results.PrimarySearchResults);
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
const searchRows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r);
const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t, query)))).reduce(
(all, r) => all.concat(r),
[] as IMyRequest[]
);
const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`));
return onlyFresh.concat(searchRows);
}
export async function searchMyRequests(
context: WebPartContext,
types: IRequestType[],
query: string,
page: number
): Promise<ISearchPage> {
const q = query.trim();
if (!q) return { rows: [], totalRows: 0 };
const [ownRows, involvedIdx] = await Promise.all([
searchOwnAll(context, types, q),
getPendingApprovalIndex(context, 'involved'),
]);
const involvedFiltered = await filterPendingByQuery(context, involvedIdx.types, involvedIdx.rows, q);
// Заявка может оказаться и своей, и involved одновременно (сам себе
// согласующий) — оставляем как 'own', не дублируем строку.
const ownKeys = new Set(ownRows.map((r) => `${r.requestTypeId}-${r.id}`));
const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`));
const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created));
const totalRows = merged.length;
const pageRows = merged.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
// Автор нужен для 'approval'-строк текущей страницы — 'own' уже пришли с
// автором (loadByIds/getFreshOwnRowsForType сами дочитывают Author).
const needAuthor = pageRows.filter((r) => r.kind === 'approval');
if (needAuthor.length > 0) {
await hydratePendingAuthors(context, involvedIdx.types, needAuthor);
}
return { rows: pageRows, totalRows };
}
function toMyRequest(
r: Record<string, unknown>,
author: { EMail?: string; Title?: string } | undefined,
+4 -3
View File
@@ -3,7 +3,8 @@ import * as React from 'react';
// Маршрутизация портала по параметрам URL.
//
// Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию),
// 'approvals' (ждут моего решения), 'own' (мои заявки).
// 'approvals' (ждут моего решения), 'own' (мои заявки), 'search' (поиск
// сразу по обеим категориям — и по своим заявкам, и по тем, где участвовал).
// Открытая заявка — переход по ссылке из потока Power Automate или клик по
// строке таблицы:
// …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid=<RowID>
@@ -22,7 +23,7 @@ import * as React from 'react';
export type PortalMode = 'submit' | 'approve' | 'create';
/** Раздел бокового меню. */
export type NavSection = 'new' | 'approvals' | 'own';
export type NavSection = 'new' | 'approvals' | 'own' | 'search';
export interface IRoute {
mode: PortalMode;
@@ -39,7 +40,7 @@ export interface IRoute {
const ROUTE_CHANGE_EVENT = 'rp-route-change';
function parseNav(raw: string | undefined): NavSection {
if (raw === 'approvals' || raw === 'own') return raw;
if (raw === 'approvals' || raw === 'own' || raw === 'search') return raw;
return 'new';
}