diff --git a/provisioning/Provision-Lists.ps1 b/provisioning/Provision-Lists.ps1 index a76dfaa..db949e9 100644 --- a/provisioning/Provision-Lists.ps1 +++ b/provisioning/Provision-Lists.ps1 @@ -75,9 +75,20 @@ function Test-SiteFieldExists { $SITE_COLUMN_GROUP = 'Портал заявок' -# Создаёт/обновляет СТОЛБЕЦ САЙТА (эталон) — один раз на весь сайт. Для Choice при -# повторе объединяет варианты (union), чтобы наборы разных форм не затирали друг друга. -# Lookup сюда НЕ попадает (эталоном быть не может — привязка к конкретному списку). +# Типы, которые НЕ делаем эталоном (столбцом сайта), а создаём столбцом списка: +# - Lookup (эталон привязан к одному списку, а Ticket у каждого свой); +# - Number с decimals и UserMulti (создаются через XML; web-level XML в PnP падает +# NRE, и это одноразовые поля конкретной формы — переиспользовать нечего). +function Test-NeedsListColumn { + param($Col) + return ($Col.type -eq 'Lookup') ` + -or ($Col.type -eq 'Number' -and $null -ne $Col.decimals) ` + -or ($Col.type -eq 'User' -and $Col.allowMultiple) +} + +# Создаёт/обновляет СТОЛБЕЦ САЙТА (эталон) — один раз на весь сайт. Только простые +# типы (без XML). Для Choice при повторе объединяет варианты (union), чтобы наборы +# разных форм не затирали друг друга. function Ensure-SiteColumn { param($Col) @@ -102,54 +113,89 @@ function Ensure-SiteColumn { } 'Boolean' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -Group $g | Out-Null } 'User' { - if ($Col.allowMultiple) { - $xml = "" - Add-PnPFieldFromXml -FieldXml $xml | Out-Null - } - else { - Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type User -Group $g | Out-Null - } + Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type User -Group $g | Out-Null if ($Col.userSelectionMode) { $mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 } Set-PnPField -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null } } 'Choice' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -Group $g | Out-Null } - 'Number' { - if ($null -ne $Col.decimals) { - $xml = "" - Add-PnPFieldFromXml -FieldXml $xml | Out-Null - } - else { - Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Number -Group $g | Out-Null - } - } + 'Number' { Add-PnPField -InternalName $Col.internalName -DisplayName $Col.title -Type Number -Group $g | Out-Null } default { throw "Неизвестный тип столбца сайта '$($Col.type)'" } } Write-Host " [+эталон] $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green } -# Обеспечивает колонку НА СПИСКЕ. Не-Lookup — из эталона (столбец сайта → подключить). -# Lookup — обычным столбцом списка (эталоном быть не может). +# Создаёт колонку прямо НА СПИСКЕ (для Lookup / XML-типов). Идемпотентно. +function Add-ListColumn { + param([string]$List, $Col) + + if (Test-FieldExists -List $List -InternalName $Col.internalName) { + if ($Col.type -eq 'Choice' -and $Col.choices) { + try { + Set-PnPField -List $List -Identity $Col.internalName -Values @{ Choices = [string[]]$Col.choices } | Out-Null + Write-Host " [~] варианты обновлены: $($Col.internalName)" -ForegroundColor DarkGray + } + catch { Write-Warning " [!] $($Col.internalName): не удалось обновить варианты — $($_.Exception.Message)" } + } + else { Write-Host " [=] столбец списка есть: $($Col.internalName)" -ForegroundColor DarkGray } + return + } + + try { + switch ($Col.type) { + 'Text' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Text -AddToDefaultView | Out-Null } + 'Note' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Note -AddToDefaultView | Out-Null } + 'DateTime' { + Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type DateTime -AddToDefaultView | Out-Null + if ($Col.dateOnly) { Set-PnPField -List $List -Identity $Col.internalName -Values @{ DisplayFormat = 0; FriendlyDisplayFormat = 1 } | Out-Null } + } + 'Boolean' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Boolean -AddToDefaultView | Out-Null } + 'User' { + if ($Col.allowMultiple) { + $xml = "" + Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null + } + else { + Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type User -AddToDefaultView | Out-Null + } + if ($Col.userSelectionMode) { + $mode = if ($Col.userSelectionMode -eq 'PeopleAndGroups') { 1 } else { 0 } + Set-PnPField -List $List -Identity $Col.internalName -Values @{ SelectionMode = $mode } | Out-Null + } + } + 'Choice' { Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Choice -Choices $Col.choices -AddToDefaultView | Out-Null } + 'Number' { + if ($null -ne $Col.decimals) { + $xml = "" + Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null + } + else { + Add-PnPField -List $List -InternalName $Col.internalName -DisplayName $Col.title -Type Number -AddToDefaultView | Out-Null + } + } + 'Lookup' { + $src = Get-PnPList -Identity $Col.lookupList -ErrorAction Stop + $guid = $src.Id.ToString() + $show = if ($Col.lookupShowField) { $Col.lookupShowField } else { 'Title' } + $xml = "" + Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null + } + default { throw "Неизвестный тип колонки '$($Col.type)'" } + } + if ($Col.required) { Set-PnPField -List $List -Identity $Col.internalName -Values @{ Required = $true } | Out-Null } + Write-Host " [+] столбец списка: $($Col.internalName) — «$($Col.title)» [$($Col.type)]" -ForegroundColor Green + } + catch { Write-Warning " [!] $($Col.internalName): $($_.Exception.Message)" } +} + +# Обеспечивает колонку НА СПИСКЕ: простые типы — из эталона (столбец сайта → +# подключить), Lookup/XML-типы — обычным столбцом списка. function Add-ColumnToList { param([string]$List, $Col) - if ($Col.type -eq 'Lookup') { - if (Test-FieldExists -List $List -InternalName $Col.internalName) { - Write-Host " [=] lookup есть: $($Col.internalName)" -ForegroundColor DarkGray - return - } - try { - $src = Get-PnPList -Identity $Col.lookupList -ErrorAction Stop - $guid = $src.Id.ToString() - $show = if ($Col.lookupShowField) { $Col.lookupShowField } else { 'Title' } - $xml = "" - Add-PnPFieldFromXml -List $List -FieldXml $xml | Out-Null - Write-Host " [+] lookup (столбец списка): $($Col.internalName)" -ForegroundColor Green - } - catch { - Write-Warning " [!] $($Col.internalName): $($_.Exception.Message)" - } + if (Test-NeedsListColumn -Col $Col) { + Add-ListColumn -List $List -Col $Col return } @@ -162,13 +208,14 @@ function Add-ColumnToList { return } - # 2) подключить эталон к списку (если ещё не подключён) + # 2) подключить эталон к списку (если ещё не подключён). Без -AddToDefaultView: + # с параметром -Field он несовместим (другой набор параметров cmdlet). if (Test-FieldExists -List $List -InternalName $Col.internalName) { Write-Host " [=] уже на списке: $($Col.internalName)" -ForegroundColor DarkGray } else { try { - Add-PnPField -List $List -Field $Col.internalName -AddToDefaultView | Out-Null + Add-PnPField -List $List -Field $Col.internalName | Out-Null Write-Host " [+] подключён эталон: $($Col.internalName)" -ForegroundColor Green } catch { diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 0b2a4f5..5a7cb66 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -3,7 +3,7 @@ import styles from './RequestPortal.module.scss'; import type { IRequestPortalProps } from './IRequestPortalProps'; import { IconSprite } from '../shared/ui/Icon/IconSprite'; import { navigate, useRoute, NavSection } from '../shared/routing'; -import { onRequestsChanged } from '../shared/requestEvents'; +import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; import { SideNav } from './SideNav/SideNav'; import { Drawer } from './Drawer/Drawer'; import { CategoryTabs } from './CategoryTabs/CategoryTabs'; @@ -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); @@ -70,7 +75,14 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context } }, [route.mode, route.formKey]); - const goto = (nav: NavSection): void => navigate({ mode: 'submit', nav }); + const goto = (nav: NavSection): void => { + // Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект + // загрузки в таблице не перезапустится сам. Шлём событие обновления — + // таблица на него подписана и перечитает данные фоном, не убирая с экрана + // текущие строки (см. RequestsTableView/SearchView). + if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); + navigate({ mode: 'submit', nav }); + }; const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav }); return ( @@ -84,7 +96,6 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context approvalsCount={approvalsCount} collapsed={navCollapsed} onToggleCollapsed={() => setNavCollapsed((v) => !v)} - userName={context.pageContext.user.displayName} /> @@ -108,6 +119,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.module.scss b/src/webparts/requestPortal/components/SideNav/SideNav.module.scss index aa708c4..4b4a3e0 100644 --- a/src/webparts/requestPortal/components/SideNav/SideNav.module.scss +++ b/src/webparts/requestPortal/components/SideNav/SideNav.module.scss @@ -39,22 +39,6 @@ min-width: 0; } -.logo { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border-radius: $r-sm; - background: $gold-500; - color: $navy-950; - font-family: $font-serif; - font-weight: 700; - font-size: 12px; - letter-spacing: .5px; -} - .brandText { font-family: $font-serif; font-size: 15px; @@ -168,41 +152,3 @@ top: 6px; right: 8px; } - -// ---- Подвал рейла: пользователь ---- -.footer { - margin: auto $sp-4 0; // margin-top:auto прижимает к низу; по бокам — инсет для border-top - display: flex; - align-items: center; - gap: $sp-2; - padding-top: $sp-3; - border-top: 1px solid rgba(255, 255, 255, .08); -} - -.navCollapsed .footer { - justify-content: center; - margin: auto 0 0; -} - -.avatar { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 50%; - background: rgba(255, 255, 255, .1); - color: $white; - font-size: 12px; - font-weight: 600; -} - -.userName { - min-width: 0; - color: rgba(255, 255, 255, .8); - font-size: 13px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} diff --git a/src/webparts/requestPortal/components/SideNav/SideNav.tsx b/src/webparts/requestPortal/components/SideNav/SideNav.tsx index dd84c9a..ba6119c 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 { @@ -23,27 +24,16 @@ interface ISideNavProps { approvalsCount?: number; collapsed: boolean; onToggleCollapsed: () => void; - /** Имя текущего пользователя (для подвала рейла). */ - userName?: string; } -// Инициалы для аватара (первые буквы двух первых слов имени). -function initials(name?: string): string { - if (!name) return '—'; - const parts = name.trim().split(/\s+/).filter(Boolean); - const value = ((parts[0]?.[0] ?? '') + (parts[1]?.[0] ?? '')).toUpperCase(); - return value || '—'; -} - -// Тёмный боковой рейл портала на всю высоту: бренд сверху, разделы, пользователь -// снизу. Сворачивается до узкой полосы с одними иконками. +// Тёмный боковой рейл портала на всю высоту: бренд сверху, разделы снизу. +// Сворачивается до узкой полосы с одними иконками. export const SideNav: React.FC = ({ activeId, onChange, approvalsCount, collapsed, onToggleCollapsed, - userName, }) => { const navClasses = [styles.nav, collapsed ? styles.navCollapsed : ''].filter(Boolean).join(' '); @@ -51,7 +41,6 @@ export const SideNav: React.FC = ({ ); }; 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 16417b9..db30b14 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -1,9 +1,20 @@ import * as React from 'react'; import type { WebPartContext } from '@microsoft/sp-webpart-base'; -import { getLinkedTypes, getOwnRequests, getPendingApproval } from './myRequests.service'; +import { + filterPendingByQuery, + getLinkedTypes, + getOwnRequests, + getPendingApproval, + getPendingApprovalIndex, + hydratePendingAuthors, + searchOwnRequests, +} from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; +import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; +import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache'; +import { Pager } from './Pager'; import styles from './RequestsTableView.module.scss'; const PAGE_SIZE = 20; @@ -14,6 +25,10 @@ export interface IRequestsTableViewProps { kind: 'approvals' | 'own'; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; + /** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для + * счётчика в SideNav, чтобы не гонять для него отдельный параллельный + * запрос, который мог бы разойтись по времени с тем, что видно в таблице. */ + onMyTurnCountChange?: (count: number) => void; } function formatDate(iso: string): string { @@ -41,70 +56,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). @@ -166,24 +117,49 @@ const Table: React.FC<{ ); -// Раздел с таблицей заявок: поиск по названию + постраничный вывод. +// Раздел с таблицей заявок: поиск по всем полям + вывод постранично. // -// 'approvals' грузится целиком (алгоритм «чья сейчас очередь» требует видеть -// весь маршрут заявки), поиск и пагинация — на клиенте. -// 'own' идёт постранично через обычный REST (см. myRequests.service), поиск — -// серверным OData-фильтром по Title. +// Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса — +// всегда обычный REST (мгновенно свежий, без задержки индексации), С запросом — +// всегда Search (полнотекстовый, но переиндексируется с задержкой). Раньше +// поиск подмешивал REST для «свежих» строк поверх Search — из-за этого +// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости +// от того, попала ли она в окно подстраховки); эту гибридную логику убрали. +// +// 'own' без поиска — getOwnRequests (REST по AuthorId, страница режется в +// памяти). 'own' с поиском — searchOwnRequests (Search, RowLimit/StartRow). +// +// 'approvals' без поиска — getPendingApproval, REST по ApproverId(+Status), +// страница нарезается на СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает +// автора только для него). +// 'approvals' с поиском — область расширяется до «где пользователь вообще есть +// в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично +// его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый +// фильтр через Search (filterPendingByQuery), и только после фильтрации — +// нарезка на страницы и дочитывание автора (hydratePendingAuthors) — тоже +// только для видимой страницы, а не для всего отфильтрованного набора. // // Оба варианта перечитываются по событию 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); const [page, setPage] = React.useState(1); - const [query, setQuery] = React.useState(''); + const [query, setQuery] = usePersistedQuery(kind); const [error, setError] = React.useState(''); + // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что + // уходит в запрос (после паузы в наборе). Всё ниже завязано на debouncedQuery, + // иначе поиск бил бы запросом по каждой набранной букве. + const debouncedQuery = useDebouncedQuery(query); - React.useEffect(() => setPage(1), [query, kind]); + React.useEffect(() => setPage(1), [debouncedQuery, kind]); React.useEffect(() => { let cancelled = false; @@ -202,45 +178,77 @@ export const RequestsTableView: React.FC = ({ context, React.useEffect(() => { if (types === undefined) return; let cancelled = false; + const cacheKey = pageKey('table', [kind, debouncedQuery.trim(), page]); - const load = (): void => { - const request = - kind === 'own' - ? getOwnRequests(context, types, query, page).then((r) => ({ rows: r.rows, total: r.totalRows })) - : getPendingApproval(context).then((r) => { - console.table(r.debug); - return { rows: r.rows, total: r.rows.length }; - }); + // Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса + // и страницы), а обновление идёт фоном. Компонент один на обе вкладки — + // без кеша при переключении на экране оставались бы строки предыдущей + // вкладки, пока не придёт ответ. Если для этой вкладки данных ещё нет — + // rows становится undefined, и показывается «Загрузка…». + const cached = readPage(cacheKey); + setRows(cached ? cached.rows : undefined); + setTotalRows(cached ? cached.totalRows : 0); - request - .then((result) => { - if (cancelled) return; - setRows(result.rows); - setTotalRows(result.total); - setError(''); - }) - .catch((e) => { - if (!cancelled) setError((e as Error).message); - }); + const fetchPage = async (): Promise => { + if (kind === 'own') { + return debouncedQuery.trim().length > 0 + ? searchOwnRequests(context, types, debouncedQuery, page) + : getOwnRequests(context, types, page); + } + + if (debouncedQuery.trim().length === 0) { + // Без поиска — страница уже нарезана и дочитана сервером. + const r = await getPendingApproval(context, 'myTurn', page); + return { rows: r.rows, totalRows: r.totalRows }; + } + + // С поиском — сначала полный список «где я есть в маршруте» (без + // автора), фильтруем текстом, и только потом режем на страницы и + // дочитываем автора — только для видимой страницы. + const idx = await getPendingApprovalIndex(context, 'involved'); + const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery); + const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + await hydratePendingAuthors(context, idx.types, pageRows); + return { rows: pageRows, totalRows: filtered.length }; }; - load(); - const unsubscribe = onRequestsChanged(load); + const load = async (): Promise => { + try { + const fresh = await fetchPage(); + if (cancelled) return; + + // Перерисовываем, только если данные реально изменились — иначе при + // каждом возврате на вкладку таблица моргала бы, перерисовываясь тем же + // самым содержимым. + const prev = readPage(cacheKey); + if (!prev || pageSignature(prev) !== pageSignature(fresh)) { + writePage(cacheKey, fresh); + setRows(fresh.rows); + setTotalRows(fresh.totalRows); + } + setError(''); + if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) { + onMyTurnCountChange(fresh.totalRows); + } + } catch (e) { + if (!cancelled) setError((e as Error).message); + } + }; + + load().catch(() => undefined); // ошибки уже обработаны внутри load через setError + const unsubscribe = onRequestsChanged(() => { + load().catch(() => undefined); + }); return () => { cancelled = true; unsubscribe(); }; - }, [context, types, kind, query, page]); + }, [context, types, kind, debouncedQuery, page]); - // 'approvals' приходит целиком — фильтруем и нарезаем страницы на клиенте. - const q = query.trim().toLowerCase(); - let visible = rows || []; - let total = totalRows; - if (kind === 'approvals' && rows) { - const filtered = q ? rows.filter((r) => r.title.toLowerCase().indexOf(q) !== -1) : rows; - total = filtered.length; - visible = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - } + // Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть. + const q = debouncedQuery.trim(); + const total = totalRows; + const visible = rows || []; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки'; @@ -255,7 +263,7 @@ export const RequestsTableView: React.FC = ({ context, setQuery(e.target.value)} /> @@ -266,7 +274,7 @@ export const RequestsTableView: React.FC = ({ context, {!error && rows && total === 0 && (
{q - ? `По запросу «${query}» ничего не найдено.` + ? `По запросу «${debouncedQuery}» ничего не найдено.` : kind === 'approvals' ? 'Нет заявок, ждущих вашего решения.' : 'Вы ещё не создавали заявок.'} 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..063e403 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -0,0 +1,212 @@ +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 { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; +import { pageKey, pageSignature, readPage, writePage } from './pageCache'; +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] = usePersistedQuery('search'); + // query — то, что в поле ввода (меняется сразу); debouncedQuery — то, что + // уходит в запрос (после паузы в наборе). Здесь это особенно важно: поиск + // ходит сразу по двум источникам (свои заявки + где участвую), то есть + // каждая набранная буква стоила бы двух серий запросов. + const debouncedQuery = useDebouncedQuery(query); + const [error, setError] = React.useState(''); + + React.useEffect(() => setPage(1), [debouncedQuery]); + + 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 (debouncedQuery.trim().length === 0) { + setRows(undefined); + setTotalRows(0); + return; + } + let cancelled = false; + const cacheKey = pageKey('search', [debouncedQuery.trim(), page]); + + // Уже искали это раньше — показываем прежний результат сразу, обновление + // идёт фоном (см. RequestsTableView, там та же схема и те же причины). + const cached = readPage(cacheKey); + setRows(cached ? cached.rows : undefined); + setTotalRows(cached ? cached.totalRows : 0); + + const load = async (): Promise => { + try { + const fresh = await searchMyRequests(context, types, debouncedQuery, page); + if (cancelled) return; + const prev = readPage(cacheKey); + if (!prev || pageSignature(prev) !== pageSignature(fresh)) { + writePage(cacheKey, fresh); + setRows(fresh.rows); + setTotalRows(fresh.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, debouncedQuery, page]); + + const hasQuery = debouncedQuery.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 && ( +
По запросу «{debouncedQuery}» ничего не найдено.
+ )} + {!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 3ac786c..9e5c5dc 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -1,8 +1,10 @@ -import { spfi, SPFx } from '@pnp/sp'; +import { spForSite } from '../../shared/pnp/pnpjs'; import '@pnp/sp/webs'; import '@pnp/sp/lists'; +import { IList } from '@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'; @@ -12,11 +14,28 @@ import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; // (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json). const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; -interface IApprovalStep { - status: string; - order?: number; - approverEmail: string; -} +// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может +// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход +// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn, +// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для +// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная +// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView). +const RECENT_MS = 10 * 60 * 1000; + +// Сколько строк согласования максимум забираем из Search за один запрос +// (см. getInvolvedRowsViaSearch). Постраничного перебора здесь нет: набор +// целиком уходит в слияние и сортировку по дате заявки, поэтому при превышении +// лимита пишем предупреждение в лог, а не молча обрезаем. +const SEARCH_ROW_LIMIT = 500; + +// Managed properties списка согласования. Имена включают группу колонок сайта +// (TansuSite) — SharePoint генерирует их так, поэтому «ожидаемого» +// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06). +// Суффикс — тип исходного поля: OWSUSER для Person/Group. +const MP_APPROVER = 'TansuSiteApproverOWSUSER'; +// Статус строки маршрута — Choice-поле, отсюда суффикс OWSCHCS (подтверждено +// отладочным перебором 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). +const MP_STATUS = 'RequestStatusOWSCHCS'; export function currentUserEmail(context: WebPartContext): string { return (context.pageContext.user.email || '').toLowerCase(); @@ -28,32 +47,114 @@ export async function getLinkedTypes(context: WebPartContext): Promise !!t.listId && !!t.siteUrl); } +// Что показывать в разделе «На согласовании»: +// 'myTurn' — только заявки, где очередь дошла до текущего пользователя +// (обычный режим, когда поиск не задан); +// 'involved' — все заявки, где он вообще есть в маршруте (уже согласовал, +// ждёт своей очереди и т.п.) — так поиск ищет по всему, в чём +// пользователь участвует или будет участвовать. +export type ApprovalScope = 'myTurn' | 'involved'; + // --------------------------------------------------------------------------- -// «Ждёт вашего решения» — требует видеть ВЕСЬ маршрут заявки, чтобы понять, -// чья сейчас очередь, поэтому идёт обычным REST-запросом (не через Search), -// целиком за один раз. Пагинация по этому результату — на стороне панели -// (клиентская нарезка по страницам), т.к. сам список обычно небольшой -// (сколько заявок сейчас реально ждут решения ОДНОГО согласующего). -export async function getPendingApproval( - context: WebPartContext -): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> { +// «На согласовании» — раньше сюда приходилось читать ВЕСЬ список согласования +// (иначе на клиенте нечем было посчитать «чья очередь»). Теперь очередь +// проставляется при создании/решении (см. approvalList.service.ts/createSteps, +// approverDecision.service.ts/decide) — «чья сейчас очередь» это просто +// значение колонки Status, поэтому здесь достаточно ОДНОГО отфильтрованного +// запроса: ApproverId (+ Status для 'myTurn'). Список согласования больше НЕ +// сканируется целиком, независимо от его размера. +// +// Заявку (Title/Status/Created) читаем сразу через expand по Lookup-полю +// Ticket — отдельный запрос к списку данных нужен только за Author +// (двухуровневый expand Ticket/Author у SharePoint REST ненадёжен), и то — +// ТОЛЬКО для строк текущей страницы (см. hydrateAuthors ниже), а не для всего +// найденного среза. Полноценный $skip/$top здесь не сделать: SharePoint REST +// не умеет сортировать по полю через expand (Ticket/Created — поле не этого +// списка), поэтому «свежие сверху» через несколько сайтов/типов можно получить +// только слиянием и сортировкой в памяти. Экономим на самом дорогом шаге — +// не дочитываем Author там, где он не попадёт на экран. +export interface IPendingApprovalPage { + rows: IMyRequest[]; + totalRows: number; + debug: IMyRequestsTypeDebug[]; +} + +const PENDING_PAGE_SIZE = 20; + +// Общая часть: собрать полный (не постраничный) индекс совпадающих заявок по +// всем типам — без автора, БЕЗ дочитывания. Используется и обычным режимом +// (без поиска — просто нарезаем страницу), и режимом с текстовым поиском (там +// нужен ПОЛНЫЙ набор, иначе фильтрация Search дала бы неверный итог/страницы — +// см. filterPendingByQuery и вызывающую сторону в RequestsTableView). +async function buildPendingIndex( + context: WebPartContext, + scope: ApprovalScope +): Promise<{ types: IRequestType[]; index: IMyRequest[]; debug: IMyRequestsTypeDebug[] }> { const types = await getLinkedTypes(context); const tokenEmail = currentUserEmail(context); const perType = await Promise.all( - types.map((t) => getApprovalPendingForType(context, t, tokenEmail)) + types.map((t) => indexApprovalsForType(context, t, tokenEmail, scope)) ); - return { - rows: perType.reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]), - debug: perType.map((r) => r.debug), - }; + // Свежие сверху — типы объединяются в один список, сортировать нужно после слияния. + const index = perType + .reduce((all, r) => all.concat(r.rows), [] as IMyRequest[]) + .sort((a, b) => b.created.localeCompare(a.created)); + + return { types, index, debug: perType.map((r) => r.debug) }; } -async function getApprovalPendingForType( +// Без текстового поиска: страница нарезается ЗДЕСЬ (сервер решает, какие 20 +// заявок отдать), автор дочитывается только для них. +export async function getPendingApproval( + context: WebPartContext, + scope: ApprovalScope = 'myTurn', + page: number = 1 +): Promise { + const { types, index, debug } = await buildPendingIndex(context, scope); + + const pageRows = index.slice((page - 1) * PENDING_PAGE_SIZE, page * PENDING_PAGE_SIZE); + console.log( + `[Текущие заявки] «На согласовании» (scope=${scope}): всего найдено ${index.length}, страница ${page} — ${pageRows.length} строк, дочитываем автора для них` + ); + await hydrateAuthors(context, types, pageRows); + + return { rows: pageRows, totalRows: index.length, debug }; +} + +// С текстовым поиском: полный индекс (без автора) — фильтрует его вызывающая +// сторона через filterPendingByQuery, а страницу и автора берёт из уже +// отфильтрованного результата (см. hydratePendingAuthors). +export async function getPendingApprovalIndex( + context: WebPartContext, + scope: ApprovalScope +): Promise<{ rows: IMyRequest[]; types: IRequestType[]; debug: IMyRequestsTypeDebug[] }> { + const { types, index, debug } = await buildPendingIndex(context, scope); + return { rows: index, types, debug }; +} + +// Публичная обёртка над hydrateAuthors — для дочитывания автора по уже +// отфильтрованной и нарезанной на страницу выборке (см. getPendingApprovalIndex). +export async function hydratePendingAuthors( + context: WebPartContext, + types: IRequestType[], + rows: IMyRequest[] +): Promise { + await hydrateAuthors(context, types, rows); +} + +// Лёгкий индекс совпадающих заявок для одного типа: строки согласования по +// ApproverId → Title/Status/Created заявки отдельным запросом к списку ДАННЫХ +// (НЕ через expand по Lookup-полю Ticket — SharePoint REST не отдаёт так +// произвольные поля связанного элемента, только через Person-поля вроде +// Author; попытка `expand('Ticket').select('Ticket/Status', ...)` падает с 400). +// Author дочитывается позже (hydrateAuthors), только для строк итоговой страницы. +async function indexApprovalsForType( context: WebPartContext, type: IRequestType, - tokenEmail: string + tokenEmail: string, + scope: ApprovalScope ): Promise<{ rows: IMyRequest[]; debug: IMyRequestsTypeDebug }> { const debug: IMyRequestsTypeDebug = { requestTypeId: type.id, @@ -64,7 +165,9 @@ async function getApprovalPendingForType( }; try { - const sp = spfi(type.siteUrl as string).using(SPFx(context)); + if (!tokenEmail) return { rows: [], debug }; + + const sp = spForSite(type.siteUrl as string, context); const list = sp.web.getList(type.listId as string); const dataListInfo = await list.select('Title')(); @@ -77,63 +180,91 @@ async function getApprovalPendingForType( debug.approvalListTitle = approvalListTitle; const approvalList = sp.web.lists.getByTitle(approvalListTitle); - // Phase не смотрим вообще — работаем только по Status. - const stepRows = (await approvalList.items - .select('Id', 'StepOrder', 'Status', 'TicketId', 'Approver/EMail') - .expand('Approver') - .top(2000)()) as Array>; - console.log( - `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», элементов в списке: ${stepRows.length}, ваш email из токена: «${tokenEmail}»` - ); - console.table( - stepRows.map((r) => ({ - Id: r.Id, - TicketId: r.TicketId, - StepOrder: r.StepOrder, - Status: r.Status, - 'Approver/EMail': (r.Approver as { EMail?: string } | undefined)?.EMail, - })) - ); + // TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в + // SharePoint REST — резолвим email в числовой Id пользователя (как и везде в + // проекте, см. ensureUser) и фильтруем по TansuSiteApproverId. + // + // RequestStatus в $filter НЕ используем: подтверждено на практике — фильтр + // вида `RequestStatus eq 'На согласовании'` (кириллица в $filter) молча + // возвращает 0 строк, даже когда без него те же строки находятся с этим же + // значением статуса. Поэтому фильтруем только по TansuSiteApproverId + // (числовое значение, проблем нет), а статус сравниваем в JS после + // получения строк. + const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; + const approver = await sp.web.ensureUser(claimsLogin); + let stepRows: Array>; + + if (scope === 'myTurn') { + // 'myTurn' — гибрид для масштаба (список согласования может быть очень + // большим): Search находит кандидатов, не сканируя список целиком, плюс + // REST по «последним N минутам» (Modified) добирает то, что Search мог ещё + // не проиндексировать. Status в обоих случаях берётся СВЕЖИМ через REST по + // найденным Id (не из индекса Search) — именно это делает + // getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда + // точное, даже когда список кандидатов пришёл из Search. + const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); + const recentRows = (await approvalList.items + .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`) + .select('Id', 'TicketId', 'RequestStatus') + .top(2000)()) as Array>; + + // Статусы уходят в KQL: сервер сразу отдаёт только активные строки, + // а не всё, где пользователь есть в маршруте. + const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, [ + REQUEST_STATUS.onApproval, + REQUEST_STATUS.onExecution, + ]); + const seenIds = new Set(recentRows.map((r) => r.Id)); + const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); + + stepRows = allRows.filter( + (r) => + r.RequestStatus === REQUEST_STATUS.onApproval || + r.RequestStatus === REQUEST_STATUS.onExecution + ); + console.log( + `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn — свежих (REST, < 10 мин): ${recentRows.length}, через Search: ${searchRows.length}, после фильтра по статусу: ${stepRows.length}` + ); + } else { + // 'involved' — используется ТОЛЬКО для текстового поиска (см. + // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная + // свежесть, а полнота по всему архиву, поэтому только Search, без + // REST-подстраховки на «последние N минут»: гибрид REST+Search при поиске + // давал неустойчивый результат (заявка то находилась, то нет, в + // зависимости от того, успел ли Search её проиндексировать к моменту + // REST-окна). + stepRows = await getInvolvedRowsViaSearch(context, type, approvalList); + + console.log( + `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved (Search) — найдено: ${stepRows.length}` + ); + } + + console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, RequestStatus: r.RequestStatus }))); debug.approvalRowCount = stepRows.length; - const byTicket = new Map(); - stepRows.forEach((r) => { - const ticketId = r.TicketId as number | undefined; - if (!ticketId) return; - const approver = r.Approver as { EMail?: string } | undefined; - const steps = byTicket.get(ticketId) || []; - steps.push({ - status: (r.Status as string) || '', - order: typeof r.StepOrder === 'number' ? (r.StepOrder as number) : undefined, - approverEmail: (approver?.EMail || '').toLowerCase(), - }); - byTicket.set(ticketId, steps); - }); + // Дедуп по TicketId: при scope='involved' у одной заявки может найтись + // несколько ваших строк (напр. вы согласующий и делегат) — заявка одна. + const ticketIds = Array.from( + new Set(stepRows.map((r) => r.TicketId as number | undefined).filter((id): id is number => !!id)) + ); + if (ticketIds.length === 0) { + debug.approvalPendingTickets = 0; + return { rows: [], debug }; + } - const myTicketIds: number[] = []; - const byTicketDebug: Array> = []; - byTicket.forEach((steps, ticketId) => { - const mine = isMyTurn(steps, tokenEmail); - byTicketDebug.push({ TicketId: ticketId, isMyTurn: mine, steps: JSON.stringify(steps) }); - if (mine) myTicketIds.push(ticketId); - }); - console.table(byTicketDebug); - - debug.approvalPendingTickets = myTicketIds.length; - if (myTicketIds.length === 0) return { rows: [], debug }; - - const ticketFilter = myTicketIds.map((id) => `Id eq ${id}`).join(' or '); + // Заголовок/статус/дата заявки — отдельным запросом к списку ДАННЫХ (см. + // комментарий у функции: через Lookup-expand это не отдаётся). + const ticketFilter = ticketIds.map((id) => `Id eq ${id}`).join(' or '); const ticketRows = (await list.items .filter(ticketFilter) - .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') - .expand('Author') - .top(2000)()) as Array>; + .select('Id', 'Title', 'Status', 'Created') + .top(ticketIds.length)()) as Array>; - const rows = ticketRows.map((r) => { - const author = r.Author as { EMail?: string; Title?: string } | undefined; - return toMyRequest(r, author, type, 'approval'); - }); + const rows = ticketRows.map((r) => toMyRequest(r, undefined, type, 'approval')); + + debug.approvalPendingTickets = rows.length; return { rows, debug }; } catch (e) { debug.approvalError = (e as Error).message; @@ -145,115 +276,210 @@ async function getApprovalPendingForType( } } -// Сейчас ли очередь пользователя с данным email среди строк маршрута одной заявки. -// Маршрут остановлен (где-то есть Отклонено/Возвращена) — не его очередь, заявка -// не активна. Иначе «текущие» строки — это ещё не решённые (Status = На согласовании) -// без указанного StepOrder (параллельно, все активны сразу) плюс, если есть строки -// с указанным StepOrder, ещё не решённая с минимальным StepOrder (последовательно). -function isMyTurn(steps: IApprovalStep[], tokenEmail: string): boolean { - if (!tokenEmail) return false; - const stopped = steps.some( - (s) => s.status === REQUEST_STATUS.rejected || s.status === REQUEST_STATUS.returned - ); - if (stopped) return false; +// Строки согласования текущего пользователя через Search, чтобы не сканировать +// список целиком (он растёт с каждой заявкой). +// +// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное +// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого» +// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго +// возвращали 0. +// +// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима +// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР: +// без него в выдачу попали бы все строки, где пользователь вообще есть в +// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры. +// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота). +// +// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём +// свежими через REST по этим Id, а не из индекса (индекс мог устареть, а на +// устаревшем статусе решение «моя очередь» строить нельзя). +async function getInvolvedRowsViaSearch( + context: WebPartContext, + type: IRequestType, + approvalList: IList, + statuses: string[] = [] +): Promise>> { + try { + const email = currentUserEmail(context); + if (!email) { + console.warn( + `[Текущие заявки] «${type.name}»: у текущего пользователя нет email — Search по согласующему невозможен` + ); + return []; + } + const claimsLogin = `i:0#.f|membership|${email}`; - const pending = steps.filter((s) => s.status === REQUEST_STATUS.onApproval); - const noOrder = pending.filter((s) => s.order === undefined); - const ordered = pending.filter((s): s is IApprovalStep & { order: number } => s.order !== undefined); + const rootFolder = (await approvalList + .select('RootFolder/ServerRelativeUrl') + .expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } }; + const relUrl = rootFolder.RootFolder?.ServerRelativeUrl; + if (!relUrl) return []; + const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl}`; - const current = [...noOrder]; - if (ordered.length > 0) { - const minOrder = Math.min(...ordered.map((s) => s.order)); - current.push(...ordered.filter((s) => s.order === minOrder)); + const sp = spForSite(type.siteUrl as string, context); + const pathClause = `Path:"${approvalListAbsUrl}*"`; + const clauses = [pathClause, `${MP_APPROVER}:"${claimsLogin}"`]; + if (statuses.length > 0) { + clauses.push(`(${statuses.map((s) => `${MP_STATUS}:"${s}"`).join(' OR ')})`); + } + const kql = clauses.join(' AND '); + + const results = await sp.search({ + Querytext: kql, + RowLimit: SEARCH_ROW_LIMIT, + SelectProperties: ['ListItemID'], + TrimDuplicates: false, + }); + + const total = results.TotalRows || 0; + const ids = (results.PrimarySearchResults as Array>) + .map((r) => Number(r.ListItemID)) + .filter((id) => !isNaN(id) && id > 0); + + console.log( + `[Текущие заявки] Search «${type.name}» (involved): KQL = ${kql}, найдено: ${total}, годных Id: ${ids.length}` + ); + + if (total > SEARCH_ROW_LIMIT) { + console.warn( + `[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку` + ); + } + if (ids.length === 0) return []; + + return (await approvalList.items + .filter(ids.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'TicketId', 'RequestStatus') + .top(ids.length)()) as Array>; + } catch (e) { + console.error( + `[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`, + (e as Error).message + ); + return []; } +} - return current.some((s) => s.approverEmail === tokenEmail); +// Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа — +// только по переданным строкам (обычно уже нарезанная страница из ≤20), не по +// всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName). +async function hydrateAuthors( + context: WebPartContext, + types: IRequestType[], + rows: IMyRequest[] +): Promise { + if (rows.length === 0) return; + + const typeById = new Map(); + types.forEach((t) => typeById.set(t.id, t)); + + const idsByType = new Map(); + rows.forEach((r) => { + const ids = idsByType.get(r.requestTypeId) || []; + ids.push(r.id); + idsByType.set(r.requestTypeId, ids); + }); + + const byKey = new Map(); + await Promise.all( + Array.from(idsByType.entries()).map(async ([requestTypeId, ids]) => { + const type = typeById.get(requestTypeId); + if (!type) return; + try { + const sp = spForSite(type.siteUrl as string, context); + const authorRows = (await sp.web + .getList(type.listId as string) + .items.filter(ids.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'Author/EMail', 'Author/Title') + .expand('Author') + .top(ids.length)()) as Array>; + authorRows.forEach((r) => { + byKey.set( + `${requestTypeId}-${r.Id as number}`, + (r.Author as { EMail?: string; Title?: string }) || {} + ); + }); + } catch (e) { + console.error(`[Текущие заявки] Не удалось дочитать автора для «${type.name}»:`, (e as Error).message); + } + }) + ); + + rows.forEach((r) => { + const a = byKey.get(`${r.requestTypeId}-${r.id}`); + r.authorEmail = a?.EMail || ''; + r.authorName = a?.Title || ''; + }); } // --------------------------------------------------------------------------- -// «Созданы вами» — обычный REST по спискам данных (НЕ Search): данные всегда -// актуальны, только что созданная заявка видна сразу (у поискового индекса -// была бы задержка переиндексации). +// Поиск через SharePoint Search REST API (search/query, KQL). // -// Пагинация «сшивает» несколько списков в один нумерованный список: сначала по -// каждому типу берём только лёгкие поля (Id + Created) всех заявок автора, -// сортируем объединённый набор по дате создания и лишь для нужной страницы -// дочитываем полные поля. Так на экран не тянется всё содержимое заявок, -// но общее число страниц известно точно. +// Текст запроса идёт свободным термом — поиск по ВСЕМ проиндексированным полям +// элемента, а не только по названию. Плата за это — результаты берутся из +// поискового индекса, который переиндексируется с задержкой: только что +// созданная или изменённая заявка может найтись не сразу. export interface IOwnRequestsPage { rows: IMyRequest[]; totalRows: number; } const PAGE_SIZE = 20; -// Столько «лёгких» строк (Id + Created) максимум читаем из одного списка, чтобы -// посчитать общее число страниц. Хватает с большим запасом; если у кого-то -// заявок окажется больше — в консоль уйдёт предупреждение. -const INDEX_LIMIT = 5000; +// Сколько заявок максимум перечисляем в KQL при поиске среди уже отобранных +// (см. filterPendingByQuery) — иначе строка запроса станет неподъёмной. +const KQL_ID_LIMIT = 300; -// В OData-строке одинарная кавычка экранируется удвоением. -function escapeOData(text: string): string { - return text.replace(/'/g, "''"); +function siteOrigin(siteUrl: string): string { + const match = /^https?:\/\/[^/]+/.exec(siteUrl); + return match ? match[0] : siteUrl; } -interface IOwnIndexEntry { - id: number; - created: string; - type: IRequestType; +function listAbsUrl(type: IRequestType): string { + return `${siteOrigin(type.siteUrl as string)}${type.listId}`; } -export async function getOwnRequests( - context: WebPartContext, - types: IRequestType[], - query: string, - page: number -): Promise { - if (types.length === 0) return { rows: [], totalRows: 0 }; +// KQL не терпит спецсимволов в свободном терме — оставляем буквы (лат./кир.), +// цифры и пробелы. (без \p{L} — целевой ES-таргет проекта его не поддерживает) +function sanitizeForKql(text: string): string { + return text + .replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} - const q = query.trim(); +// Свободный терм по всем полям: каждое слово — префиксный поиск, все слова +// обязательны (AND). Пустая строка → undefined (условие не добавляем). +function freeTextClause(query: string): string | undefined { + const q = sanitizeForKql(query); + if (!q) return undefined; + return `(${q.split(' ').map((word) => `${word}*`).join(' AND ')})`; +} - // 1) Лёгкий индекс по каждому типу: только Id + Created заявок автора. - const perType = await Promise.all( - types.map(async (type): Promise => { - try { - const sp = spfi(type.siteUrl as string).using(SPFx(context)); - const me = await sp.web.currentUser.select('Id')(); - const filters = [`AuthorId eq ${me.Id}`]; - if (q) filters.push(`substringof('${escapeOData(q)}',Title)`); - - const rows = (await sp.web - .getList(type.listId as string) - .items.filter(filters.join(' and ')) - .select('Id', 'Created') - .orderBy('Created', false) - .top(INDEX_LIMIT)()) as Array>; - - if (rows.length === INDEX_LIMIT) { - console.warn( - `[Текущие заявки] «${type.name}»: индекс своих заявок обрезан на ${INDEX_LIMIT} — часть страниц не видна` - ); - } - return rows.map((r) => ({ id: r.Id as number, created: (r.Created as string) || '', type })); - } catch (e) { - console.error(`[Текущие заявки] «${type.name}»: не удалось прочитать свои заявки:`, (e as Error).message); - return []; - } +// Ответ Search → пары (Id элемента, его тип по префиксу Path). +function hitsToEntries( + results: { PrimarySearchResults: unknown[] }, + types: IRequestType[] +): Array<{ id: number; type: IRequestType }> { + const urls = types.map((t) => ({ type: t, url: listAbsUrl(t).toLowerCase() })); + return (results.PrimarySearchResults as Array>) + .map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '').toLowerCase() })) + .filter((h) => !isNaN(h.id) && h.id > 0) + .map((h) => { + const matched = urls.filter((u) => h.path.indexOf(u.url) === 0)[0]; + return matched ? { id: h.id, type: matched.type } : undefined; }) - ); + .filter((e): e is { id: number; type: IRequestType } => !!e); +} - // 2) Общий список всех типов, свежие сверху. - const index = perType - .reduce((all, rows) => all.concat(rows), [] as IOwnIndexEntry[]) - .sort((a, b) => b.created.localeCompare(a.created)); - - const pageEntries = index.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - if (pageEntries.length === 0) { - return { rows: [], totalRows: index.length }; - } - - // 3) Полные поля — только для заявок текущей страницы, по своему списку на тип. +// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для +// кастомных полей нет гарантии, что они проиндексированы как managed property). +async function loadByIds( + context: WebPartContext, + entries: Array<{ id: number; type: IRequestType }>, + kind: 'own' | 'approval' +): Promise> { const idsByType = new Map(); - pageEntries.forEach((e) => { + entries.forEach((e) => { const ids = idsByType.get(e.type) || []; ids.push(e.id); idsByType.set(e.type, ids); @@ -262,26 +488,256 @@ export async function getOwnRequests( const byKey = new Map(); await Promise.all( Array.from(idsByType.entries()).map(async ([type, ids]) => { - const sp = spfi(type.siteUrl as string).using(SPFx(context)); + const sp = spForSite(type.siteUrl as string, context); const rows = (await sp.web .getList(type.listId as string) .items.filter(ids.map((id) => `Id eq ${id}`).join(' or ')) .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') .expand('Author') - .top(PAGE_SIZE)()) as Array>; + .top(ids.length)()) as Array>; rows.forEach((r) => { const author = r.Author as { EMail?: string; Title?: string } | undefined; - byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, 'own')); + byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, kind)); }); }) ); + return byKey; +} - // Порядок страницы задаёт индекс (по дате), а не порядок ответов REST. - const rows = pageEntries - .map((e) => byKey.get(`${e.type.id}-${e.id}`)) - .filter((r): r is IMyRequest => !!r); +// Свежие (созданные за последние RECENT_MS) заявки автора — обычным REST-ом, +// в обход Search (индекс мог их ещё не подхватить). Без текстового запроса — +// матчить название не нужно, отдаём как есть. +async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise { + try { + const sp = spForSite(type.siteUrl as string, context); + const me = await sp.web.currentUser.select('Id')(); + const cutoff = new Date(Date.now() - RECENT_MS).toISOString(); - return { rows, totalRows: index.length }; + const rows = (await sp.web + .getList(type.listId as string) + .items.filter(`AuthorId eq ${me.Id} and Created ge '${cutoff}'`) + .select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title') + .expand('Author') + .top(50)()) as Array>; + + return rows.map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')); + } catch (e) { + console.error(`[Текущие заявки] «${type.name}»: не удалось дочитать свежие заявки:`, (e as Error).message); + return []; + } +} + +// «Мои заявки» БЕЗ поиска — гибрид для масштаба: Search с серверной пагинацией +// (RowLimit/StartRow) находит страницу без сканирования всего архива, REST по +// «последним N минутам» добирает то, что Search мог не успеть проиндексировать +// (см. RECENT_MS). Довесок применяется только на первой странице — свежее по +// дате там и оказалось бы, будь оно проиндексировано. +export async function getOwnRequests( + context: WebPartContext, + types: IRequestType[], + page: number +): Promise { + if (types.length === 0) return { rows: [], totalRows: 0 }; + + const searchPage = await searchOwnRequests(context, types, '', page); + + if (page !== 1) return searchPage; + + const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t)))).reduce( + (all, r) => all.concat(r), + [] as IMyRequest[] + ); + + const searchKeys = new Set(searchPage.rows.map((r) => `${r.requestTypeId}-${r.id}`)); + const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`)); + + if (onlyFresh.length > 0) { + console.log( + `[Текущие заявки] «Мои заявки»: довесили ${onlyFresh.length} свежих заявок (< 10 мин), которых Search ещё не нашёл` + ); + } + + const merged = onlyFresh + .concat(searchPage.rows) + .sort((a, b) => b.created.localeCompare(a.created)) + .slice(0, PAGE_SIZE); + + return { rows: merged, totalRows: searchPage.totalRows + onlyFresh.length }; +} + +// «Мои заявки» С поиском — только Search, без REST-подстраховки на свежесть: +// только что созданная заявка может найтись не сразу (задержка индексации), но +// зато результат предсказуем — не зависит от 10-минутного REST-окна. +export async function searchOwnRequests( + context: WebPartContext, + types: IRequestType[], + query: string, + page: number +): Promise { + if (types.length === 0) return { rows: [], totalRows: 0 }; + + const sp = spForSite(types[0].siteUrl as string, 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: PAGE_SIZE, + StartRow: (page - 1) * PAGE_SIZE, + SelectProperties: ['ListItemID', 'Title', 'Path'], + TrimDuplicates: false, + // Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате). + SortList: [{ Property: 'Created', Direction: 1 }], + }); + + 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(); + // Порядок задаёт релевантность поиска, а не порядок ответов REST. + const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); + + return { rows, totalRows: results.TotalRows || 0 }; +} + +// «На согласовании»: список заявок уже отобран по строкам согласования +// (getPendingApprovalIndex — там же определяется, чья сейчас очередь, +// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search +// (KQL ограничивается ListItemID этих заявок, текст идёт свободным термом по +// всем полям), без клиентского REST-матчинга по свежести. +export async function filterPendingByQuery( + context: WebPartContext, + types: IRequestType[], + rows: IMyRequest[], + query: string +): Promise { + const text = freeTextClause(query); + if (!text || rows.length === 0) return rows; + + if (rows.length > KQL_ID_LIMIT) { + console.warn( + `[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${rows.length}` + ); + } + const candidates = rows.slice(0, KQL_ID_LIMIT); + + const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id)); + if (scopeTypes.length === 0) return []; + + const sp = spForSite(scopeTypes[0].siteUrl as string, context); + const kql = [ + `(${scopeTypes.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, + `(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`, + text, + ].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); + + // ListItemID уникален только внутри своего списка — сверяем пару тип+Id. + const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`)); + return candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); +} + +// --------------------------------------------------------------------------- +// Вкладка «Поиск»: ищет сразу по двум категориям — «Мои заявки» (я автор) и +// «где я участвовал как согласующий» (involved, независимо от того, дошла ли +// уже очередь) — и показывает их одним списком, с колонками Автор и Статус +// одновременно (в отличие от «Мои заявки»/«На согласовании», где по смыслу +// раздела одно из двух не нужно). +// +// Пагинация — клиентская поверх объединённого результата: «Мои заявки» уже +// умеет по-настоящему постранично через Search, но «где участвовал» — нет +// (см. filterPendingByQuery — там честного $skip/$top не сделать), так что +// единой серверной пагинации на двух источниках сразу не построить. Для +// сценария «искал что-то один раз» это приемлемо. +export interface ISearchPage { + rows: IMyRequest[]; + totalRows: number; +} + +// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до +// KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть. +async function searchOwnAll( + context: WebPartContext, + types: IRequestType[], + query: string +): Promise { + if (types.length === 0) return []; + + const sp = spForSite(types[0].siteUrl as string, 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(); + return entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); +} + +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 сам дочитывает 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( diff --git a/src/webparts/requestPortal/features/myRequests/pageCache.ts b/src/webparts/requestPortal/features/myRequests/pageCache.ts new file mode 100644 index 0000000..8159322 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/pageCache.ts @@ -0,0 +1,43 @@ +import { IMyRequest } from './models/IMyRequest'; + +// Загруженная страница таблицы — то, что кладётся в кеш вкладки +// (см. RequestsTableView/SearchView). +export interface IPage { + rows: IMyRequest[]; + totalRows: number; +} + +// Хранилище на уровне МОДУЛЯ, а не внутри компонента (useRef), потому что +// компоненты таблиц размонтируются при переключении разделов: RequestPortal +// рендерит либо SubmitFeature, либо SearchView, либо RequestsTableView — уход +// на «Поиск» или «Новая заявка» уничтожает экземпляр таблицы вместе со всем его +// состоянием. С useRef кеш переживал только переключение «Мои заявки» ↔ «На +// согласовании» (там экземпляр один, меняется лишь проп kind), а после захода +// на «Поиск» терялся. Здесь он живёт до перезагрузки страницы. +// +// Ключ формирует вызывающая сторона и обязательно начинает с префикса раздела +// (см. pageKey), иначе ключи двух таблиц пересеклись бы. +const store = new Map(); + +// Ключ кеша: раздел + всё, от чего зависит содержимое страницы. +export function pageKey(view: string, parts: Array): string { + return [view, ...parts].join('|'); +} + +export function readPage(key: string): IPage | undefined { + return store.get(key); +} + +export function writePage(key: string, page: IPage): void { + store.set(key, page); +} + +// Слепок страницы для сравнения «изменилось ли что-нибудь». Сравниваем поля, +// которые видны в таблице: если они совпали, перерисовывать нечего — иначе при +// каждом возврате на вкладку и при каждом фоновом обновлении таблица моргала бы, +// перерисовываясь тем же самым содержимым. +export function pageSignature(page: IPage): string { + return `${page.totalRows}|${page.rows + .map((r) => [r.requestTypeId, r.id, r.title, r.status, r.created, r.authorName].join('~')) + .join(';')}`; +} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 6ff29c2..20c4f63 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -50,6 +50,13 @@ const RecruitingHireView: React.FC = ({ context, itemI ); // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. + // + // decisionSeq входит в reloadKey: после решения согласующего строки маршрута + // меняются на сервере (своя — на «Согласовано», следующая — на «На + // согласовании»), поэтому счётчик инкрементируется и useMatrixEditor + // перечитывает матрицу. Иначе на экране остались бы данные, загруженные при + // открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании». + const [decisionSeq, setDecisionSeq] = useState(0); const approval = useMemo(() => hireApprovalService(), []); const loadApprovers = useCallback( () => @@ -58,7 +65,7 @@ const RecruitingHireView: React.FC = ({ context, itemI .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)), [approval, itemId] ); - const approvers = useMatrixEditor(loadApprovers, `a-${itemId}`, false, true, (l) => + const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); const loadExecutors = useCallback( @@ -68,7 +75,7 @@ const RecruitingHireView: React.FC = ({ context, itemI .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)), [approval, itemId] ); - const executors = useMatrixEditor(loadExecutors, `e-${itemId}`, false, true, (l) => + const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); @@ -161,7 +168,14 @@ const RecruitingHireView: React.FC = ({ context, itemI {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */}
- + { + setStatus(newStatus); + setDecisionSeq((n) => n + 1); + }} + />
); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json index f80ee62..45a8b4f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json @@ -1,8 +1,8 @@ { "dataListTitle": "Прием на работу — согласование", "columns": [ - { "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Ждёт очереди", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] }, + { "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, + { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] }, { "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }, { "internalName": "Date", "title": "Дата", "type": "DateTime" }, { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx index a6c5839..c98b430 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -54,6 +54,13 @@ const WorkplaceSetupView: React.FC = ({ context, itemI ); // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. + // + // decisionSeq входит в reloadKey: после решения согласующего строки маршрута + // меняются на сервере (своя — на «Согласовано», следующая — на «На + // согласовании»), поэтому счётчик инкрементируется и useMatrixEditor + // перечитывает матрицу. Иначе на экране остались бы данные, загруженные при + // открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании». + const [decisionSeq, setDecisionSeq] = useState(0); const approval = useMemo(() => workplaceApprovalService(), []); const loadApprovers = useCallback( () => @@ -62,7 +69,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)), [approval, itemId] ); - const approvers = useMatrixEditor(loadApprovers, `a-${itemId}`, false, true, (l) => + const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); const loadExecutors = useCallback( @@ -72,7 +79,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)), [approval, itemId] ); - const executors = useMatrixEditor(loadExecutors, `e-${itemId}`, false, true, (l) => + const executors = useMatrixEditor(loadExecutors, `e-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); @@ -209,6 +216,10 @@ const WorkplaceSetupView: React.FC = ({ context, itemI service={approverService} requestId={itemId} onBeforeDecide={canEditFields ? saveReviewFields : undefined} + onDecided={(newStatus) => { + setStatus(newStatus); + setDecisionSeq((n) => n + 1); + }} /> diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json index a758a8f..e9b0968 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json @@ -1,8 +1,8 @@ { "dataListTitle": "Организация рабочего места — согласование", "columns": [ - { "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Ждёт очереди", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] }, + { "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, + { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] }, { "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Организация рабочего места — данные", "lookupShowField": "Title" }, { "internalName": "Date", "title": "Дата", "type": "DateTime" }, { "internalName": "Comment", "title": "Комментарий", "type": "Note" }, diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index ef3dc9b..78acb6b 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -12,8 +12,12 @@ import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; // Внутренние имена колонок списка согласования (создаются провижинингом). const FIELDS = { - approver: 'Approver', // «Согласующий» (User) → ApproverId - status: 'Status', // «Статус» (Choice) + // Внутренние имена с префиксом группы колонок сайта (TansuSite) и «Request» — + // так они заведены в SharePoint. Из них SharePoint выводит имена managed + // properties для поиска: TansuSiteApproverOWSUSER и RequestStatusOWSCHCS + // (см. MP_APPROVER/MP_STATUS в features/myRequests/myRequests.service.ts). + approver: 'TansuSiteApprover', // «Согласующий» (User) → TansuSiteApproverId + status: 'RequestStatus', // «Статус» (Choice) ticket: 'Ticket', // «Заявка» (Lookup) → TicketId date: 'Date', // «Дата» (DateTime) comment: 'Comment', // «Комментарий» (Text) @@ -28,6 +32,12 @@ export interface IApproverToWrite { email: string; position: string; allowReassign: boolean; + /** Порядок (StepOrder). Не задан — берётся позиция в массиве (orderStart + индекс). + * Несколько согласующих с ОДИНАКОВЫМ order — параллельный шаг: все становятся + * «На согласовании» одновременно (см. createSteps). Сейчас редактор матрицы + * (ApprovalMatrix/useMatrixEditor) всегда даёт уникальный order по позиции — + * это поле для параллельных шагов, приходящих из внешнего маршрута напрямую. */ + order?: number; } // Все запросы к списку «<форма> — согласование». listTitle задаётся формой. @@ -38,9 +48,14 @@ export class ApprovalListService { return getSP().web.lists.getByTitle(this.listTitle); } - // Пишет по строке на каждого участника фазы с указанным статусом. - // Порядок (Order) = orderStart + позиция в массиве (фазы идут подряд в одном списке: - // сначала согласующие 1..N, затем исполнители N+1..N+M). + // Пишет по строке на каждого участника фазы. Порядок (Order) = a.order, если + // задан, иначе orderStart + позиция в массиве (фазы идут подряд в одном + // списке: сначала согласующие 1..N, затем исполнители N+1..N+M). + // + // Статус пишется переданным значением ОДИНАКОВО для всех строк партии — кто + // сейчас «на очереди», а кто «ждёт», решает отдельно activateNextQueued + // (вызывается сразу после createSteps в approvalSubmission.service.ts/writeRoute + // и в approverDecision.service.ts/decide). public async createSteps( requestId: number, approvers: IApproverToWrite[], @@ -48,22 +63,65 @@ export class ApprovalListService { phase: string = APPROVAL_PHASE.approval, orderStart: number = 1 ): Promise { - for (let i = 0; i < approvers.length; i++) { - const a = approvers[i]; - if (!a.email) continue; + const withOrder = approvers + .map((a, i) => ({ a, order: a.order ?? orderStart + i })) + .filter((x) => !!x.a.email); + + for (const { a, order } of withOrder) { const approverId = await this.resolveUserId(a.email); await this.list().items.add({ Title: a.position || a.email, [`${FIELDS.approver}Id`]: approverId, [FIELDS.status]: status, [`${FIELDS.ticket}Id`]: requestId, - [FIELDS.order]: orderStart + i, + [FIELDS.order]: order, [FIELDS.allowReassign]: a.allowReassign, [FIELDS.phase]: phase, }); } } + // Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди» + // в текущей фазе активирует те, что делят наименьший StepOrder (обычно одна + // строка; несколько — параллельный шаг). Фаза исполнения активируется, только + // когда фаза согласования пройдена целиком (без Отклонено/Возвращена) — иначе + // строки исполнения остаются «Ждёт очереди». Если фаза согласования уже + // остановлена решением, ничего не активирует. + public async activateNextQueued(requestId: number): Promise { + const rows = await this.getForRequest(requestId); + const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution); + const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution); + + const approvalStopped = approvalRows.some( + (r) => r.status === REQUEST_STATUS.rejected || r.status === REQUEST_STATUS.returned + ); + if (approvalStopped) return; + + const approvalDone = + approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved); + + const activate = async (phaseRows: IApprovalRow[], activeStatus: string): Promise => { + if (phaseRows.some((r) => r.status === activeStatus)) return; + const queuedRows = phaseRows.filter((r) => r.status === REQUEST_STATUS.queued); + if (queuedRows.length === 0) return; + const minOrder = Math.min(...queuedRows.map((r) => r.order)); + await Promise.all( + queuedRows.filter((r) => r.order === minOrder).map((r) => this.setStatus(r.id, activeStatus)) + ); + }; + + if (!approvalDone) { + await activate(approvalRows, REQUEST_STATUS.onApproval); + } else { + await activate(execRows, REQUEST_STATUS.onExecution); + } + } + + // Точечное обновление статуса одной строки маршрута. + public async setStatus(rowId: number, status: string): Promise { + await this.list().items.getById(rowId).update({ [FIELDS.status]: status }); + } + // Добавляет одну строку согласования с заданным Order (для делегата). public async addApproverRow( requestId: number, @@ -187,19 +245,6 @@ export class ApprovalListService { }); } - // Активирует следующий шаг маршрута: первую по очереди строку со статусом - // «Ждёт очереди» переводит в активный статус её фазы (Согласование → «На - // согласовании», Исполнение → «На исполнении»). Если ждущих нет — ничего. - // Вызывается при отправке (активировать первый шаг) и после каждого решения. - public async activateNextQueued(requestId: number): Promise { - const rows = await this.getForRequest(requestId); // отсортированы по StepOrder - const next = rows.filter((r) => r.status === REQUEST_STATUS.queued)[0]; - if (!next) return; - const active = - next.phase === APPROVAL_PHASE.execution ? REQUEST_STATUS.onExecution : REQUEST_STATUS.onApproval; - await this.list().items.getById(next.id).update({ [FIELDS.status]: active }); - } - // Удаляет все строки согласования по заявке (при возврате/повторной отправке). public async deleteForRequest(requestId: number): Promise { const rows = await this.getForRequest(requestId); diff --git a/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts b/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts index bb4fdb9..a74e3d9 100644 --- a/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts @@ -43,13 +43,24 @@ export async function writeApprovers( status: string ): Promise { await approval.deleteForRequest(requestId); - await approval.createSteps(requestId, approvers, status, APPROVAL_PHASE.approval, 1); + const submitted = status === REQUEST_STATUS.onApproval; + const rowStatus = submitted ? REQUEST_STATUS.queued : status; + await approval.createSteps(requestId, approvers, rowStatus, APPROVAL_PHASE.approval, 1); + if (submitted) { + await approval.activateNextQueued(requestId); + } } // Записать ОБЕ фазы маршрута заявки: согласующих (Order 1..N) и исполнителей // (Order N+1..N+M) в один список. Исполнители пишутся сразу при отправке — // стороне согласующего внешний API уже не нужен, переход в фазу исполнения // делает computeRequestStatus, когда все согласующие одобрили. +// +// Исполнители стартуют ВСЕ как «Ждёт очереди» (rowStatus, та же логика, что и +// у согласующих) — до прохождения фазы согласования у исполнителей нет текущей +// очереди. Первого исполнителя переключит на «На исполнении» +// approval.activateNextQueued, когда фаза согласования будет пройдена целиком +// (см. approverDecision.service.ts/decide). export async function writeRoute( approval: ApprovalListService, requestId: number, diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index a0dbeae..40dcd4b 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -32,17 +32,20 @@ export class ApproverService { ) {} // Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined. - // Последовательное согласование: активна первая строка со статусом «На согласовании». + // Очередь уже проставлена на запись (см. createSteps/advanceQueue) — активна + // ровно одна строка со статусом «На согласовании» на фазу, искать «первую по + // порядку» не нужно. public async getPendingRowForCurrentUser(requestId: number): Promise { const rows = await this.approval.getForRequest(requestId); const userId = await this.approval.getCurrentUserId(); - // Активна первая незакрытая строка по очереди: согласующий («На согласовании») - // или, когда согласование пройдено, исполнитель («На исполнении»). - const firstPending = rows.filter( + // Активные строки текущего шага: согласующий («На согласовании») или, когда + // согласование пройдено, исполнитель («На исполнении»). При параллельном шаге + // (одинаковый StepOrder) активных строк может быть несколько сразу — ищем + // среди ВСЕХ активных, а не только среди первой по порядку. + const active = rows.filter( (r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution - )[0]; - if (firstPending && firstPending.approverId === userId) return firstPending; - return undefined; + ); + return active.filter((r) => r.approverId === userId)[0]; } // Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между @@ -59,22 +62,24 @@ export class ApproverService { const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1; const newOrder = (prevOrder + current.order) / 2; const position = await this.approval.getJobTitle(user.loginName); - // Делегат наследует фазу той строки, вместо которой действует, и её статус - // ожидания (согласование → «На согласовании», исполнение → «На исполнении»). - const pendingStatus = - current.phase === APPROVAL_PHASE.execution - ? REQUEST_STATUS.onExecution - : REQUEST_STATUS.onApproval; + // Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение). + // Обе строки — делегат и прежний согласующий — уходят в «Ждёт очереди», + // activateNextQueued затем активирует делегата (у него меньший order). await this.approval.addApproverRow( requestId, { email: user.email, position, allowReassign: false }, newOrder, - pendingStatus, + REQUEST_STATUS.queued, current.phase ); + await this.approval.setStatus(current.id, REQUEST_STATUS.queued); + await this.approval.activateNextQueued(requestId); + emitRequestsChanged(); } - // Применяет решение: обновляет строку и пересчитывает статус заявки. + // Применяет решение: обновляет строку, продвигает очередь (на следующего + // согласующего внутри фазы либо на первого исполнителя, если фаза согласования + // только что завершилась целиком) и пересчитывает статус заявки. // Возвращает новый статус заявки. public async decide( requestId: number, diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 9695f26..eeee35d 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -2,6 +2,7 @@ export { REQUEST_STATUS, REQUEST_STATUS_CHOICES, + APPROVAL_ROW_STATUS_CHOICES, APPROVAL_PHASE, APPROVAL_PHASE_CHOICES, EDITABLE_STATUSES, diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.ts b/src/webparts/requestPortal/shared/approval/models/statuses.ts index d4012b2..8aa551c 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.ts @@ -3,18 +3,28 @@ // В слое формы кодом ставятся Draft (черновик) и OnApproval (отправка на согласование); // Approved/Rejected/Returned выставляются при решениях согласующих; Execution/Closed — // зарезервированы под будущие этапы. +// Статусы СТРОК списка «— согласование» (не заявки в контентном списке): +// draft/rejected/returned — общие с заявкой; остальные — только для строк +// маршрута, по фазам: +// Согласование: queued («Ждёт очереди») → onApproval («На согласовании») → approved/rejected/returned. +// Исполнение: queued → onExecution («На исполнении») → executed («Выполнено»). +// Активация — только через ApprovalListService.activateNextQueued (вызывается +// при отправке заявки и после каждого положительного решения, см. +// approvalSubmission.service.ts/writeRoute, approverDecision.service.ts/decide): +// среди строк со статусом queued в текущей фазе находит наименьший StepOrder +// и переводит её (или несколько, если делят один order — параллельный шаг) в +// onApproval/onExecution. Так «чья сейчас очередь» — просто значение колонки +// Status, без пересчёта порядка на клиенте. export const REQUEST_STATUS = { draft: 'Черновик', onApproval: 'На согласовании', + queued: 'Ждёт очереди', + onExecution: 'На исполнении', + executed: 'Выполнено', approved: 'Согласовано', rejected: 'Отклонено', returned: 'Возвращена на доработку', - // Статус СТРОКИ маршрута, до которой ещё не дошла очередь (активен только текущий шаг). - queued: 'Ждёт очереди', - // Статусы СТРОК фазы исполнения: активная очередь исполнителя и выполнено. - onExecution: 'На исполнении', - executed: 'Выполнено', - // Статусы ЗАЯВКИ на этапе исполнения. + // Статус ЗАЯВКИ (не строки) на этапе исполнения. execution: 'Выполнение', closed: 'Закрыта', } as const; @@ -33,20 +43,40 @@ export function isEditableStatus(status: string | undefined): boolean { return !status || EDITABLE_STATUSES.indexOf(status) !== -1; } -// Полный набор значений для Choice-колонки Status при провижининге. +// Наборов значений ДВА, и они не совпадают — это разные сущности: +// +// 1) статус ЗАЯВКИ — колонка Status в списке «… — данные». Пишется формой +// (Черновик/На согласовании) и computeRequestStatus после каждого решения. +// «Ждёт очереди»/«На исполнении»/«Выполнено» здесь не бывают: это состояния +// отдельного шага маршрута, а не заявки целиком. +// +// 2) статус СТРОКИ МАРШРУТА — колонка RequestStatus в списке «… — согласование». +// Пишется createSteps/activateNextQueued/setDecision. «Выполнение»/«Закрыта» +// здесь не бывают: это агрегатные состояния заявки, у отдельного шага +// смысла не имеют. +// +// Держать их в синхроне с forms/*/provisioning.json и forms/*/approval/provisioning.json. export const REQUEST_STATUS_CHOICES: string[] = [ REQUEST_STATUS.draft, REQUEST_STATUS.onApproval, REQUEST_STATUS.approved, REQUEST_STATUS.rejected, REQUEST_STATUS.returned, - REQUEST_STATUS.queued, - REQUEST_STATUS.onExecution, - REQUEST_STATUS.executed, REQUEST_STATUS.execution, REQUEST_STATUS.closed, ]; +export const APPROVAL_ROW_STATUS_CHOICES: string[] = [ + REQUEST_STATUS.draft, + REQUEST_STATUS.queued, + REQUEST_STATUS.onApproval, + REQUEST_STATUS.onExecution, + REQUEST_STATUS.approved, + REQUEST_STATUS.executed, + REQUEST_STATUS.rejected, + REQUEST_STATUS.returned, +]; + // Фаза строки в списке маршрута: согласование (кто одобряет) и исполнение // (кто выполняет после согласования). Обе фазы живут в одном списке «— согласование», // строки различаются колонкой Phase. Значения приходят из внешнего API как diff --git a/src/webparts/requestPortal/shared/pnp/pnpjs.ts b/src/webparts/requestPortal/shared/pnp/pnpjs.ts index 8844e35..4480b2b 100644 --- a/src/webparts/requestPortal/shared/pnp/pnpjs.ts +++ b/src/webparts/requestPortal/shared/pnp/pnpjs.ts @@ -4,9 +4,18 @@ import '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/attachments'; import '@pnp/sp/site-users/web'; +import { BrowserFetchWithRetry } from '@pnp/queryable'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { DATA_SITE_URL } from '../config'; +// Мягкая ретрай-политика вместо стандартной PnPjs (retries: 3, interval: 200). +// Дефолт слишком напорист: при отказе (429 «слишком много запросов», 503) он +// начинает повторять почти сразу, что при массовой загрузке только усугубляет +// throttling со стороны SharePoint. Здесь меньше попыток и заметно длиннее +// первая пауза; интервал в PnPjs удваивается с каждой попыткой, так что выходит +// примерно 1.5с → 3с. +const RETRY = { retries: 2, interval: 1500 }; + // Единый инициализированный клиент PnPjs на весь web part. let _sp: SPFI | undefined; @@ -16,7 +25,18 @@ let _sp: SPFI | undefined; * списки живут там, где бы ни стоял сам web part. Авторизация берётся из context. */ export function initSP(context: WebPartContext): void { - _sp = spfi(DATA_SITE_URL).using(SPFx(context)); + _sp = spfi(DATA_SITE_URL).using(SPFx(context), BrowserFetchWithRetry(RETRY)); +} + +/** + * Клиент для ПРОИЗВОЛЬНОГО сайта (списки типов заявок живут на разных сайтах, + * см. approval-route API). В отличие от getSP() не кэшируется — адрес сайта + * приходит из данных. Ретрай-политика та же, что и у основного клиента: + * BrowserFetchWithRetry идёт ПОСЛЕ SPFx, поэтому заменяет его обработчик + * отправки (у behavior replace: true по умолчанию). + */ +export function spForSite(siteUrl: string, context: WebPartContext): SPFI { + return spfi(siteUrl).using(SPFx(context), BrowserFetchWithRetry(RETRY)); } /** Получить инициализированный клиент. Бросает, если initSP не был вызван. */ 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'; } diff --git a/src/webparts/requestPortal/shared/usePersistedQuery.ts b/src/webparts/requestPortal/shared/usePersistedQuery.ts new file mode 100644 index 0000000..65dff92 --- /dev/null +++ b/src/webparts/requestPortal/shared/usePersistedQuery.ts @@ -0,0 +1,76 @@ +import * as React from 'react'; + +// Текст поиска переживает перезагрузку страницы (localStorage), но не более +// того — это просто «продолжить с того же места», а не синхронизация между +// вкладками/устройствами. key должен быть уникальным на раздел (см. вызовы в +// RequestsTableView/SearchView), иначе значения разных таблиц перепишут друг друга. +function readStored(storageKey: string): string { + try { + return window.localStorage.getItem(storageKey) || ''; + } catch { + return ''; + } +} + +export function usePersistedQuery(key: string): [string, (value: string) => void] { + const storageKey = `rp-search-query:${key}`; + + const [value, setValue] = React.useState(() => readStored(storageKey)); + + // Компонент может продолжать жить, когда key поменялся: RequestsTableView — + // это один и тот же элемент для вкладок «На согласовании» и «Мои заявки», + // меняется только проп kind. Инициализатор useState при этом повторно НЕ + // срабатывает, поэтому без перечитывания текст из одной вкладки оставался бы + // на экране во второй и записывался уже под её ключ — выглядело бы так, будто + // ключ у них общий. + const prevKey = React.useRef(storageKey); + React.useEffect(() => { + if (prevKey.current === storageKey) return; + prevKey.current = storageKey; + setValue(readStored(storageKey)); + }, [storageKey]); + + const setPersisted = React.useCallback( + (next: string): void => { + setValue(next); + try { + if (next) { + window.localStorage.setItem(storageKey, next); + } else { + window.localStorage.removeItem(storageKey); + } + } catch { + // localStorage недоступен (приватный режим и т.п.) — просто не сохраняем. + } + }, + [storageKey] + ); + + return [value, setPersisted]; +} + +// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск +// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов +// (Search + дочитывание через REST по каждому типу заявок), из которых нужен +// только последний. Заодно снимает часть нагрузки, из-за которой SharePoint +// начинает throttling'ить (429). +const SEARCH_DEBOUNCE_MS = 450; + +// Значение, отстающее от переданного на SEARCH_DEBOUNCE_MS: в поле ввода текст +// меняется сразу (без «залипания»), а в запрос уходит только когда пользователь +// перестал печатать. Пустая строка проходит без задержки — очистка поиска +// должна возвращать полный список мгновенно. +export function useDebouncedQuery(value: string, delayMs: number = SEARCH_DEBOUNCE_MS): string { + const [debounced, setDebounced] = React.useState(value); + + React.useEffect(() => { + if (!value) { + setDebounced(''); + return undefined; + } + const timer = window.setTimeout(() => setDebounced(value), delayMs); + return () => window.clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +}