From 8f7d81d2b6e2efa828a8c570ead4b6f63f059c4c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 5 Aug 2026 13:53:54 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B8=D1=85=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 22 +- .../components/SideNav/SideNav.tsx | 1 + .../features/myRequests/Pager.module.scss | 50 +++++ .../features/myRequests/Pager.tsx | 69 ++++++ .../myRequests/RequestsTableView.module.scss | 49 ----- .../features/myRequests/RequestsTableView.tsx | 77 ++----- .../myRequests/SearchView.module.scss | 203 ++++++++++++++++++ .../features/myRequests/SearchView.tsx | 194 +++++++++++++++++ .../features/myRequests/myRequests.service.ts | 95 ++++++++ src/webparts/requestPortal/shared/routing.ts | 7 +- 10 files changed, 648 insertions(+), 119 deletions(-) create mode 100644 src/webparts/requestPortal/features/myRequests/Pager.module.scss create mode 100644 src/webparts/requestPortal/features/myRequests/Pager.tsx create mode 100644 src/webparts/requestPortal/features/myRequests/SearchView.module.scss create mode 100644 src/webparts/requestPortal/features/myRequests/SearchView.tsx diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 0b2a4f5..b6221b5 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -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 = ({ 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 = ({ hasTeamsContext, context } /> + ) : route.nav === 'search' ? ( + + navigate({ + mode: 'approve', + nav: route.nav, + formKey: request.formKey, + itemId: request.id, + }) + } + /> ) : ( = ({ hasTeamsContext, context itemId: request.id, }) } + onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined} /> )} diff --git a/src/webparts/requestPortal/components/SideNav/SideNav.tsx b/src/webparts/requestPortal/components/SideNav/SideNav.tsx index dd84c9a..ed720cd 100644 --- a/src/webparts/requestPortal/components/SideNav/SideNav.tsx +++ b/src/webparts/requestPortal/components/SideNav/SideNav.tsx @@ -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 { diff --git a/src/webparts/requestPortal/features/myRequests/Pager.module.scss b/src/webparts/requestPortal/features/myRequests/Pager.module.scss new file mode 100644 index 0000000..01e17f8 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/Pager.module.scss @@ -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; +} diff --git a/src/webparts/requestPortal/features/myRequests/Pager.tsx b/src/webparts/requestPortal/features/myRequests/Pager.tsx new file mode 100644 index 0000000..906ac30 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/Pager.tsx @@ -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 = ({ 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 ( +
+ + {from > 1 && ( + <> + + {from > 2 && …} + + )} + {numbers.map((n) => ( + + ))} + {to < totalPages && ( + <> + {to < totalPages - 1 && …} + + + )} + +
+ ); +}; diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss index 420e609..effc7d5 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -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; -} diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index aaf9c0e..1bf0f2d 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -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 ( -
- - {from > 1 && ( - <> - - {from > 2 && …} - - )} - {numbers.map((n) => ( - - ))} - {to < totalPages && ( - <> - {to < totalPages - 1 && …} - - - )} - -
- ); -}; - // Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся // строка, а не только название), поэтому строка — с onClick + tabIndex // (доступ с клавиатуры: Enter/Space). @@ -191,7 +132,12 @@ const Table: React.FC<{ // Оба варианта перечитываются по событию rp-requests-changed // (shared/requestEvents) — после создания заявки и после решения согласующего. // Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно. -export const RequestsTableView: React.FC = ({ context, kind, onOpen }) => { +export const RequestsTableView: React.FC = ({ + context, + kind, + onOpen, + onMyTurnCountChange, +}) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); @@ -240,6 +186,7 @@ export const RequestsTableView: React.FC = ({ context, setRows(r.rows); setTotalRows(r.totalRows); setError(''); + if (onMyTurnCountChange) onMyTurnCountChange(r.totalRows); return; } diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.module.scss b/src/webparts/requestPortal/features/myRequests/SearchView.module.scss new file mode 100644 index 0000000..6663a95 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/SearchView.module.scss @@ -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 на ). +.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; +} diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx new file mode 100644 index 0000000..ceda798 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -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 }) => ( +
+ + + + + + + + + + + + + + {rows.map((r) => ( + onOpen(r)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen(r); + } + }} + > + + + + + + + + + ))} + +
IDТипНазваниеДатаАвторСтатусРоль
{r.id}{r.requestTypeName}{r.title || `#${r.id}`}{formatDate(r.created)} + {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} + + {r.status || '—'} + {r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}
+
+); + +// Вкладка «Поиск»: ищет сразу и среди своих заявок, и среди тех, где +// пользователь участвует как согласующий (независимо от того, дошла ли уже +// очередь) — единым списком, с колонками Автор и Статус одновременно (см. +// searchMyRequests в myRequests.service.ts). Без введённого текста — пусто, +// это не список «на экран по умолчанию», а именно поиск. +// +// Перечитывается по событию rp-requests-changed (после создания заявки и +// после решения согласующего) — но т.к. это по большей части Search, свежая +// правка может проявиться не мгновенно (частично компенсируется довеском +// последних 10 минут через REST — см. myRequests.service.ts). +export const SearchView: React.FC = ({ context, onOpen }) => { + const [types, setTypes] = React.useState(undefined); + const [rows, setRows] = React.useState(undefined); + const [totalRows, setTotalRows] = React.useState(0); + const [page, setPage] = React.useState(1); + const [query, setQuery] = React.useState(''); + const [error, setError] = React.useState(''); + + 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 => { + 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 ( +
+
+

+ Поиск + {hasQuery && rows && {totalRows}} +

+ setQuery(e.target.value)} + autoFocus + /> +
+ + {error &&
Не удалось выполнить поиск: {error}
} + {!error && !hasQuery &&
Введите текст, чтобы начать поиск.
} + {!error && hasQuery && rows === undefined &&
Ищем…
} + {!error && hasQuery && rows && totalRows === 0 && ( +
По запросу «{query}» ничего не найдено.
+ )} + {!error && hasQuery && rows && totalRows > 0 && ( + <> + + + + )} + + ); +}; diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 72a97a1..095a405 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -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 { + 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(); + 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 { + 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, author: { EMail?: string; Title?: string } | undefined, diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index b075473..09ae6e0 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -3,7 +3,8 @@ import * as React from 'react'; // Маршрутизация портала по параметрам URL. // // Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию), -// 'approvals' (ждут моего решения), 'own' (мои заявки). +// 'approvals' (ждут моего решения), 'own' (мои заявки), 'search' (поиск +// сразу по обеим категориям — и по своим заявкам, и по тем, где участвовал). // Открытая заявка — переход по ссылке из потока Power Automate или клик по // строке таблицы: // …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid= @@ -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'; }