From 7fa0a800d8dff6f16601ac56fc86a51b47553d8b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 5 Aug 2026 12:28:58 +0500 Subject: [PATCH 01/10] =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=BD=D0=B0=D1=8F=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/RequestsTableView.tsx | 105 ++-- .../features/myRequests/myRequests.service.ts | 497 ++++++++++++------ .../RecruitingHire/approval/provisioning.json | 2 +- .../WorkplaceSetup/approval/provisioning.json | 2 +- .../shared/approval/approvalList.service.ts | 43 +- .../approval/approvalSubmission.service.ts | 9 +- .../approval/approverDecision.service.ts | 64 ++- .../shared/approval/models/statuses.ts | 9 + 8 files changed, 515 insertions(+), 216 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 16417b9..aaf9c0e 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -1,6 +1,13 @@ 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, +} from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; @@ -166,15 +173,24 @@ const Table: React.FC<{ ); -// Раздел с таблицей заявок: поиск по названию + постраничный вывод. +// Раздел с таблицей заявок: поиск по всем полям + вывод постранично. // -// 'approvals' грузится целиком (алгоритм «чья сейчас очередь» требует видеть -// весь маршрут заявки), поиск и пагинация — на клиенте. -// 'own' идёт постранично через обычный REST (см. myRequests.service), поиск — -// серверным OData-фильтром по Title. +// 'own' — Search сразу с пагинацией (RowLimit/StartRow): автор = текущий +// пользователь + текст запроса по всем полям. +// +// 'approvals' без поиска — обычный REST, отфильтрованный по ApproverId(+Status), +// страница нарезается на СЕРВЕРЕ (getPendingApproval сам берёт нужный срез +// индекса и дочитывает автора только для него). +// 'approvals' с поиском — область расширяется до «где пользователь вообще есть +// в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично +// его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый +// фильтр через Search (filterPendingByQuery), и только после фильтрации — +// нарезка на страницы и дочитывание автора (hydratePendingAuthors) — тоже +// только для видимой страницы, а не для всего отфильтрованного набора. // // Оба варианта перечитываются по событию rp-requests-changed // (shared/requestEvents) — после создания заявки и после решения согласующего. +// Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно. export const RequestsTableView: React.FC = ({ context, kind, onOpen }) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); @@ -203,44 +219,63 @@ export const RequestsTableView: React.FC = ({ context, if (types === undefined) return; let cancelled = false; - 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 }; - }); - - request - .then((result) => { + const load = async (): Promise => { + try { + if (kind === 'own') { + const r = await getOwnRequests(context, types, query, page); if (cancelled) return; - setRows(result.rows); - setTotalRows(result.total); + setRows(r.rows); + setTotalRows(r.totalRows); setError(''); - }) - .catch((e) => { - if (!cancelled) setError((e as Error).message); - }); + return; + } + + const hasQuery = query.trim().length > 0; + + if (!hasQuery) { + // Без поиска — страница уже нарезана и дочитана сервером. + const r = await getPendingApproval(context, 'myTurn', page); + if (cancelled) return; + console.table(r.debug); + setRows(r.rows); + setTotalRows(r.totalRows); + setError(''); + return; + } + + // С поиском — сначала полный список «где я есть в маршруте» (без + // автора), фильтруем текстом, и только потом режем на страницы и + // дочитываем автора — только для видимой страницы. + const idx = await getPendingApprovalIndex(context, 'involved'); + if (cancelled) return; + console.table(idx.debug); + const filtered = await filterPendingByQuery(context, idx.types, idx.rows, query); + if (cancelled) return; + const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + await hydratePendingAuthors(context, idx.types, pageRows); + if (cancelled) return; + setRows(pageRows); + setTotalRows(filtered.length); + setError(''); + } catch (e) { + if (!cancelled) setError((e as Error).message); + } }; - load(); - const unsubscribe = onRequestsChanged(load); + load().catch(() => undefined); // ошибки уже обработаны внутри load через setError + const unsubscribe = onRequestsChanged(() => { + load().catch(() => undefined); + }); return () => { cancelled = true; unsubscribe(); }; }, [context, types, kind, query, 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 = query.trim(); + const total = totalRows; + const visible = rows || []; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const heading = kind === 'approvals' ? 'Ждут вашего решения' : 'Мои заявки'; @@ -255,7 +290,7 @@ export const RequestsTableView: React.FC = ({ context, setQuery(e.target.value)} /> diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 3ac786c..96b6815 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -3,6 +3,7 @@ import '@pnp/sp/webs'; import '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; +import '@pnp/sp/search'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval'; import { IMyRequest } from './models/IMyRequest'; @@ -12,12 +13,6 @@ 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; -} - export function currentUserEmail(context: WebPartContext): string { return (context.pageContext.user.email || '').toLowerCase(); } @@ -28,32 +23,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,6 +141,8 @@ async function getApprovalPendingForType( }; try { + if (!tokenEmail) return { rows: [], debug }; + const sp = spfi(type.siteUrl as string).using(SPFx(context)); const list = sp.web.getList(type.listId as string); @@ -77,63 +156,56 @@ 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, - })) - ); + // Approver/EMail eq '...' в $filter по Person-полю ненадёжен в SharePoint + // REST — резолвим email в числовой Id пользователя (как и везде в проекте, + // см. ensureUser) и фильтруем по ApproverId. + // + // Status в $filter НЕ используем: подтверждено на практике — фильтр + // `Status eq 'На согласовании'` (кириллица в $filter) молча возвращает 0 + // строк, даже когда без него те же строки находятся с этим же значением + // Status. Поэтому фильтруем только по ApproverId (числовое значение, + // проблем нет), а Status сравниваем в JS после получения строк. + const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; + const approver = await sp.web.ensureUser(claimsLogin); + const allRows: Array> = []; + for await (const p of approvalList.items + .filter(`ApproverId eq ${approver.Id}`) + .select('Id', 'TicketId', 'Status') + .top(2000)) { + allRows.push(...(p as Array>)); + } + + const stepRows = + scope === 'myTurn' ? allRows.filter((r) => r.Status === REQUEST_STATUS.onApproval) : allRows; + + console.log( + `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=${scope}, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}` + ); + console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, Status: r.Status }))); 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 +217,126 @@ 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 +// Дочитывает 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 = spfi(type.siteUrl as string).using(SPFx(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); + } + }) ); - if (stopped) return false; - 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 current = [...noOrder]; - if (ordered.length > 0) { - const minOrder = Math.min(...ordered.map((s) => s.order)); - current.push(...ordered.filter((s) => s.order === minOrder)); - } - - return current.some((s) => s.approverEmail === tokenEmail); + 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); @@ -268,20 +351,108 @@ export async function getOwnRequests( .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 +// «Мои заявки»: Search по спискам данных всех типов сразу — автор = текущий +// пользователь, текст запроса по всем полям. Пагинация серверная +// (RowLimit/StartRow), поэтому таблица не тянет весь архив заявок. +export async function getOwnRequests( + context: WebPartContext, + types: IRequestType[], + query: string, + page: number +): Promise { + if (types.length === 0) return { rows: [], totalRows: 0 }; + + const sp = spfi(types[0].siteUrl as string).using(SPFx(context)); + const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`; + + const clauses = [ + `(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, + `AuthorOWSUSER:"${claimsLogin}"`, + ]; + const text = freeTextClause(query); + if (text) clauses.push(text); + const kql = clauses.join(' AND '); + + const results = await sp.search({ + Querytext: kql, + RowLimit: 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); + if (entries.length === 0) return { rows: [], totalRows: results.TotalRows || 0 }; + + const byKey = await loadByIds(context, entries, 'own'); + // Порядок задаёт релевантность поиска, а не порядок ответов REST. + const rows = entries .map((e) => byKey.get(`${e.type.id}-${e.id}`)) .filter((r): r is IMyRequest => !!r); - return { rows, totalRows: index.length }; + return { rows, totalRows: results.TotalRows || 0 }; +} + +// «На согласовании»: список заявок уже отобран по строкам согласования +// (getPendingApproval — там же определяется, чья сейчас очередь). Здесь среди +// НИХ ищем по тексту через Search: KQL ограничивается ListItemID найденных +// заявок, текст идёт свободным термом по всем полям. +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 scope = rows.slice(0, KQL_ID_LIMIT); + + const byType = new Map(); + types.forEach((t) => byType.set(t.id, t)); + const scopeTypes = types.filter((t) => scope.some((r) => r.requestTypeId === t.id)); + if (scopeTypes.length === 0) return []; + + const sp = spfi(scopeTypes[0].siteUrl as string).using(SPFx(context)); + const kql = [ + `(${scopeTypes.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, + `(${scope.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 scope.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); } function toMyRequest( diff --git a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json index 3b0092d..207b2dd 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json @@ -2,7 +2,7 @@ "dataListTitle": "Прием на работу — согласование", "columns": [ { "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + { "internalName": "Status", "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/approval/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json index 46810c2..70dd5ce 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json @@ -2,7 +2,7 @@ "dataListTitle": "Организация рабочего места — согласование", "columns": [ { "internalName": "Approver", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "Status", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, + { "internalName": "Status", "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 78c9b36..e63845f 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -8,7 +8,7 @@ import '@pnp/sp/profiles'; import { IApprovalRow } from './models/IApprovalRow'; import { IMatrixRow } from './models/IMatrixRow'; import { ISiteUser } from './models/ISiteUser'; -import { APPROVAL_PHASE } from './models/statuses'; +import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; // Внутренние имена колонок списка согласования (создаются провижинингом). const FIELDS = { @@ -28,6 +28,12 @@ export interface IApproverToWrite { email: string; position: string; allowReassign: boolean; + /** Порядок (StepOrder). Не задан — берётся позиция в массиве (orderStart + индекс). + * Несколько согласующих с ОДИНАКОВЫМ order — параллельный шаг: все становятся + * «На согласовании» одновременно (см. createSteps). Сейчас редактор матрицы + * (ApprovalMatrix/useMatrixEditor) всегда даёт уникальный order по позиции — + * это поле для параллельных шагов, приходящих из внешнего маршрута напрямую. */ + order?: number; } // Все запросы к списку «<форма> — согласование». listTitle задаётся формой. @@ -38,9 +44,18 @@ 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). + // + // Статус строки решается здесь, а не читается заново при каждом открытии + // заявки: строки с НАИМЕНЬШИМ order в этой партии получают переданный status + // (обычно «На согласовании» — их очередь СЕЙЧАС; если несколько согласующих + // делят один и тот же order — это параллельный шаг, активны все сразу), + // остальные — «Ждёт очереди». Так «чья сейчас очередь» — это просто значение + // колонки Status, без пересчёта порядка на клиенте (см. + // approverDecision.service.ts/decide, который двигает очередь дальше при + // каждом решении — той же логикой «наименьший order среди нерешённых»). public async createSteps( requestId: number, approvers: IApproverToWrite[], @@ -48,16 +63,20 @@ 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); + const minOrder = withOrder.length > 0 ? Math.min(...withOrder.map((x) => x.order)) : 0; + + for (const { a, order } of withOrder) { const approverId = await this.resolveUserId(a.email); + const rowStatus = order === minOrder ? status : REQUEST_STATUS.waitingInQueue; await this.list().items.add({ Title: a.position || a.email, [`${FIELDS.approver}Id`]: approverId, - [FIELDS.status]: status, + [FIELDS.status]: rowStatus, [`${FIELDS.ticket}Id`]: requestId, - [FIELDS.order]: orderStart + i, + [FIELDS.order]: order, [FIELDS.allowReassign]: a.allowReassign, [FIELDS.phase]: phase, }); @@ -187,6 +206,12 @@ export class ApprovalListService { }); } + // Только статус строки, без даты/комментария — продвижение очереди + // («На согласовании» ⇄ «Ждёт очереди»), а не решение согласующего. + public async setStatus(rowId: number, status: string): Promise { + await this.list().items.getById(rowId).update({ [FIELDS.status]: status }); + } + // Удаляет все строки согласования по заявке (при возврате/повторной отправке). 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 574eedb..ac45415 100644 --- a/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalSubmission.service.ts @@ -3,7 +3,7 @@ import { resolveRoute } from './approvalRoute.service'; import { ApprovalListService, IApproverToWrite } from './approvalList.service'; import { IMatrixRow } from './models/IMatrixRow'; import { ApprovalStatus } from './models/IApprovalRouteApi'; -import { APPROVAL_PHASE } from './models/statuses'; +import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses'; // Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы. // status выбирает фазу: 'Approval' (согласующие, по умолчанию) или 'Execution' @@ -50,6 +50,11 @@ export async function writeApprovers( // (Order N+1..N+M) в один список. Исполнители пишутся сразу при отправке — // стороне согласующего внешний API уже не нужен, переход в фазу исполнения // делает computeRequestStatus, когда все согласующие одобрили. +// +// Исполнители стартуют ВСЕ как «Ждёт очереди» (не передаём им status — фаза +// исполнения ещё не наступила, до неё нет ничьей текущей очереди). Первого +// исполнителя переключит на «На согласовании» approverDecision.service.ts/decide, +// когда фаза согласования будет пройдена целиком. export async function writeRoute( approval: ApprovalListService, requestId: number, @@ -62,7 +67,7 @@ export async function writeRoute( await approval.createSteps( requestId, executors, - status, + REQUEST_STATUS.waitingInQueue, APPROVAL_PHASE.execution, approvers.length + 1 ); diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index 2f0f22b..af8c85f 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -17,6 +17,8 @@ const ROW_STATUS: Record = { reject: REQUEST_STATUS.rejected, }; +const TERMINAL_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.rejected, REQUEST_STATUS.returned]; + // Действия согласующего: определение его строки, применение решения, // делегирование и пересчёт статуса заявки в контентном списке. // @@ -30,12 +32,14 @@ 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((r) => r.status === REQUEST_STATUS.onApproval)[0]; - if (firstPending && firstPending.approverId === userId) return firstPending; + const current = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0]; + if (current && current.approverId === userId) return current; return undefined; } @@ -54,6 +58,8 @@ export class ApproverService { const newOrder = (prevOrder + current.order) / 2; const position = await this.approval.getJobTitle(user.loginName); // Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение). + // Пишем его сразу как «На согласовании» — advanceQueue ниже сам переведёт + // строку currentRowId (у неё теперь больше не наименьший Order) в «Ждёт очереди». await this.approval.addApproverRow( requestId, { email: user.email, position, allowReassign: false }, @@ -61,9 +67,13 @@ export class ApproverService { REQUEST_STATUS.onApproval, current.phase ); + await this.advanceQueue(requestId); + emitRequestsChanged(); } - // Применяет решение: обновляет строку и пересчитывает статус заявки. + // Применяет решение: обновляет строку, продвигает очередь (на следующего + // согласующего внутри фазы либо на первого исполнителя, если фаза согласования + // только что завершилась целиком) и пересчитывает статус заявки. // Возвращает новый статус заявки. public async decide( requestId: number, @@ -72,13 +82,57 @@ export class ApproverService { comment: string ): Promise { await this.approval.setDecision(rowId, ROW_STATUS[decision], comment); - const rows = await this.approval.getForRequest(requestId); + const rows = await this.advanceQueue(requestId); const requestStatus = this.computeRequestStatus(rows); await this.updateRequestStatus(requestId, requestStatus); emitRequestsChanged(); return requestStatus; } + // Пересчитывает и записывает «чья сейчас очередь» по обеим фазам маршрута: + // ровно у одной ещё не решённой строки на фазу становится «На согласовании» + // (наименьший StepOrder среди нерешённых), у остальных — «Ждёт очереди». + // Фаза исполнения активна, только когда фаза согласования пройдена целиком + // (без Отклонено/Возвращена) — иначе там тоже сплошное «Ждёт очереди». + // Возвращает строки МАРШРУТА в том виде, в каком они были ДО применения этих + // обновлений (для computeRequestStatus — ему важны только терминальные + // статусы approved/rejected/returned, на которые эта функция не влияет). + private async advanceQueue(requestId: number): Promise { + const rows = await this.approval.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 + ); + const approvalDone = + !approvalStopped && + (approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved)); + + const updates = new Map(); + const applyPhase = (phaseRows: IApprovalRow[], eligible: boolean): void => { + const undecided = phaseRows.filter((r) => TERMINAL_STATUSES.indexOf(r.status) === -1); + if (undecided.length === 0) return; + if (!eligible) { + undecided.forEach((r) => { + if (r.status !== REQUEST_STATUS.waitingInQueue) updates.set(r.id, REQUEST_STATUS.waitingInQueue); + }); + return; + } + const minOrder = Math.min(...undecided.map((r) => r.order)); + undecided.forEach((r) => { + const desired = r.order === minOrder ? REQUEST_STATUS.onApproval : REQUEST_STATUS.waitingInQueue; + if (r.status !== desired) updates.set(r.id, desired); + }); + }; + + applyPhase(approvalRows, !approvalStopped); + applyPhase(execRows, approvalDone); + + await Promise.all(Array.from(updates.entries()).map(([id, status]) => this.approval.setStatus(id, status))); + return rows; + } + // Статус заявки из строк маршрута с учётом фазы: // 1) фаза согласования: отклонение/возврат → соответствующий статус; // не все согласовали → «На согласовании». diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.ts b/src/webparts/requestPortal/shared/approval/models/statuses.ts index ef2e4cf..dbafdc1 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.ts @@ -11,6 +11,14 @@ export const REQUEST_STATUS = { returned: 'Возвращена на доработку', execution: 'Выполнение', closed: 'Закрыта', + // Строка маршрута, до которой очередь ещё не дошла. Применяется ТОЛЬКО к + // строкам списка «— согласование» (не к заявке в контентном списке): + // при создании маршрута и при каждом решении согласующего (см. + // approvalList.service.ts/createSteps, approverDecision.service.ts/decide) + // ровно у одной строки на фазу выставляется onApproval («её очередь сейчас»), + // у всех остальных недорешённых строк — waitingInQueue. Это позволяет читать + // «чья сейчас очередь» прямым фильтром по Status, без пересчёта на клиенте. + waitingInQueue: 'Ждёт очереди', } as const; // Статусы, при которых заявку ещё можно править (форма редактируема). @@ -31,6 +39,7 @@ export function isEditableStatus(status: string | undefined): boolean { export const REQUEST_STATUS_CHOICES: string[] = [ REQUEST_STATUS.draft, REQUEST_STATUS.onApproval, + REQUEST_STATUS.waitingInQueue, REQUEST_STATUS.approved, REQUEST_STATUS.rejected, REQUEST_STATUS.returned, -- 2.34.1 From d8737a15e375028a641e4b938b21560da12c5299 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 5 Aug 2026 12:55:29 +0500 Subject: [PATCH 02/10] =?UTF-8?q?=D0=B4=D0=BE=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B0=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B8=D1=85?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/myRequests.service.ts | 228 +++++++++++++++--- 1 file changed, 199 insertions(+), 29 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 96b6815..72a97a1 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -1,6 +1,7 @@ import { spfi, SPFx } from '@pnp/sp'; 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'; @@ -13,6 +14,12 @@ import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; // (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json). const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; +// Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может +// ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход +// Search, и подмешиваем к результату (см. getOwnRequests, indexApprovalsForType, +// filterPendingByQuery). +const RECENT_MS = 10 * 60 * 1000; + export function currentUserEmail(context: WebPartContext): string { return (context.pageContext.user.email || '').toLowerCase(); } @@ -168,20 +175,45 @@ async function indexApprovalsForType( const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; const approver = await sp.web.ensureUser(claimsLogin); - const allRows: Array> = []; - for await (const p of approvalList.items - .filter(`ApproverId eq ${approver.Id}`) - .select('Id', 'TicketId', 'Status') - .top(2000)) { - allRows.push(...(p as Array>)); + let stepRows: Array>; + + if (scope === 'myTurn') { + // 'myTurn' решает, надо ли действовать ПРЯМО СЕЙЧАС — Status должен быть + // гарантированно свежим (индекс Search обновляется с задержкой и мог бы + // показать уже решённый шаг как ожидающий). Поэтому здесь только REST, + // весь список по ApproverId, без архивного Search-довеска. + const allRows: Array> = []; + for await (const p of approvalList.items + .filter(`ApproverId eq ${approver.Id}`) + .select('Id', 'TicketId', 'Status') + .top(2000)) { + allRows.push(...(p as Array>)); + } + stepRows = allRows.filter((r) => r.Status === REQUEST_STATUS.onApproval); + console.log( + `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}` + ); + } else { + // 'involved' — просто «участвовал ли вообще», Status не важен для + // истинности результата, поэтому здесь можно рискнуть Search-ом для + // архивной (не только что созданной/изменённой) части: свежее по + // Modified — REST, остальное — Search по managed property ApproverOWSUSER + // (кастомное поле, не гарантировано, что проиндексировано — см. лог). + const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); + const recentRows = (await approvalList.items + .filter(`ApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`) + .select('Id', 'TicketId', 'Status') + .top(2000)()) as Array>; + + const archivalRows = await getArchivalInvolvedRows(context, type, approvalList, claimsLogin); + const seenIds = new Set(recentRows.map((r) => r.Id)); + stepRows = recentRows.concat(archivalRows.filter((r) => !seenIds.has(r.Id))); + + console.log( + `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved — свежих (REST, < 10 мин): ${recentRows.length}, архивных (Search): ${archivalRows.length}, итого: ${stepRows.length}` + ); } - const stepRows = - scope === 'myTurn' ? allRows.filter((r) => r.Status === REQUEST_STATUS.onApproval) : allRows; - - console.log( - `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=${scope}, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}` - ); console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, Status: r.Status }))); debug.approvalRowCount = stepRows.length; @@ -217,6 +249,59 @@ async function indexApprovalsForType( } } +// Архивная (не только что созданная/изменённая) часть 'involved' — через +// Search по managed property ApproverOWSUSER (кастомное Person-поле, НЕТ +// гарантии, что проиндексировано без ручной настройки Search Schema — если +// лог покажет «найдено: 0» при заведомо непустом архиве, значит не сработало +// и стоит вернуться на чистый REST для этой ветки). +// +// Search используется ТОЛЬКО чтобы найти Id строк — TicketId и Status берём +// свежими через REST по этим Id, а не из индекса (индекс мог устареть). +async function getArchivalInvolvedRows( + context: WebPartContext, + type: IRequestType, + approvalList: IList, + claimsLogin: string +): Promise>> { + try { + 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 sp = spfi(type.siteUrl as string).using(SPFx(context)); + const kql = `Path:"${approvalListAbsUrl}*" AND ApproverOWSUSER:"${claimsLogin}"`; + + const results = await sp.search({ + Querytext: kql, + RowLimit: 500, + SelectProperties: ['ListItemID'], + TrimDuplicates: false, + }); + + console.log(`[Текущие заявки] Search «${type.name}» (архив, involved): KQL = ${kql}, найдено: ${results.TotalRows}`); + console.table(results.PrimarySearchResults); + + const ids = (results.PrimarySearchResults as Array>) + .map((r) => Number(r.ListItemID)) + .filter((id) => !isNaN(id) && id > 0); + if (ids.length === 0) return []; + + return (await approvalList.items + .filter(ids.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'TicketId', 'Status') + .top(ids.length)()) as Array>; + } catch (e) { + console.error( + `[Текущие заявки] «${type.name}»: Search по архиву согласования не сработал:`, + (e as Error).message + ); + return []; + } +} + // Дочитывает Author/EMail + Author/Title в списке ДАННЫХ каждого типа — // только по переданным строкам (обычно уже нарезанная страница из ≤20), не по // всему найденному срезу. Мутирует переданные объекты (проставляет authorEmail/authorName). @@ -286,6 +371,23 @@ const PAGE_SIZE = 20; // (см. filterPendingByQuery) — иначе строка запроса станет неподъёмной. const KQL_ID_LIMIT = 300; +function isRecent(createdIso: string): boolean { + if (!createdIso) return false; + const t = new Date(createdIso).getTime(); + return !isNaN(t) && Date.now() - t <= RECENT_MS; +} + +// Замена свободному терму Search для случаев, когда матчим на клиенте (без +// индекса): все слова запроса должны встретиться в названии (без учёта +// регистра). Не идентично семантике Search (там — префикс, тут — подстрока), +// но для «довеска» из нескольких свежих строк разница не принципиальна. +function matchesQueryTitle(title: string, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + const t = title.toLowerCase(); + return q.split(/\s+/).filter(Boolean).every((word) => t.indexOf(word) !== -1); +} + function siteOrigin(siteUrl: string): string { const match = /^https?:\/\/[^/]+/.exec(siteUrl); return match ? match[0] : siteUrl; @@ -361,9 +463,45 @@ async function loadByIds( return byKey; } +// Свежие (созданные за последние RECENT_MS) заявки автора — обычным REST-ом, +// в обход Search (индекс мог их ещё не подхватить). Текст запроса матчим на +// клиенте (matchesQueryTitle) — substringof с кириллицей в $filter уже +// подводил на Status (см. indexApprovalsForType), поэтому в $filter только +// нечувствительные к этому условия (AuthorId/Created). +async function getFreshOwnRowsForType( + context: WebPartContext, + type: IRequestType, + query: string +): Promise { + try { + const sp = spfi(type.siteUrl as string).using(SPFx(context)); + const me = await sp.web.currentUser.select('Id')(); + const cutoff = new Date(Date.now() - RECENT_MS).toISOString(); + + 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')) + .filter((r) => matchesQueryTitle(r.title, query)); + } catch (e) { + console.error(`[Текущие заявки] «${type.name}»: не удалось дочитать свежие заявки:`, (e as Error).message); + return []; + } +} + // «Мои заявки»: Search по спискам данных всех типов сразу — автор = текущий // пользователь, текст запроса по всем полям. Пагинация серверная // (RowLimit/StartRow), поэтому таблица не тянет весь архив заявок. +// +// На первой странице довешиваем то, что Search мог ещё не проиндексировать +// (см. getFreshOwnRowsForType) — дедуп по (тип, Id), сортировка по дате и +// обрезка до PAGE_SIZE. На страницах 2+ этого не делаем: свежее по дате и так +// оказалось бы на первой странице, будь оно проиндексировано. export async function getOwnRequests( context: WebPartContext, types: IRequestType[], @@ -397,21 +535,47 @@ export async function getOwnRequests( console.table(results.PrimarySearchResults); const entries = hitsToEntries(results, types); - if (entries.length === 0) return { rows: [], totalRows: results.TotalRows || 0 }; - - const byKey = await loadByIds(context, entries, 'own'); + const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); // Порядок задаёт релевантность поиска, а не порядок ответов REST. - const rows = entries + const searchRows = entries .map((e) => byKey.get(`${e.type.id}-${e.id}`)) .filter((r): r is IMyRequest => !!r); - return { rows, totalRows: results.TotalRows || 0 }; + if (page !== 1) { + return { rows: searchRows, totalRows: results.TotalRows || 0 }; + } + + const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t, query)))).reduce( + (all, r) => all.concat(r), + [] as IMyRequest[] + ); + + const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`)); + const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`)); + + if (onlyFresh.length > 0) { + console.log( + `[Текущие заявки] «Мои заявки»: довесили ${onlyFresh.length} свежих заявок (< 10 мин), которых Search ещё не нашёл` + ); + } + + const merged = onlyFresh + .concat(searchRows) + .sort((a, b) => b.created.localeCompare(a.created)) + .slice(0, PAGE_SIZE); + + return { rows: merged, totalRows: (results.TotalRows || 0) + onlyFresh.length }; } // «На согласовании»: список заявок уже отобран по строкам согласования -// (getPendingApproval — там же определяется, чья сейчас очередь). Здесь среди -// НИХ ищем по тексту через Search: KQL ограничивается ListItemID найденных -// заявок, текст идёт свободным термом по всем полям. +// (getPendingApproval/getPendingApprovalIndex — там же определяется, чья +// сейчас очередь, обычным REST). Здесь среди НИХ фильтруем по тексту. +// +// Свежие (< 10 мин, isRecent) заявки Search мог не проиндексировать — +// их матчим по названию на клиенте (matchesQueryTitle), без похода в Search. +// Остальные — через Search: KQL ограничивается ListItemID этих заявок, текст +// идёт свободным термом по всем полям. Порядок сохраняется (rows уже +// отсортированы по дате в buildPendingIndex, свежие — в начале). export async function filterPendingByQuery( context: WebPartContext, types: IRequestType[], @@ -421,22 +585,26 @@ export async function filterPendingByQuery( const text = freeTextClause(query); if (!text || rows.length === 0) return rows; - if (rows.length > KQL_ID_LIMIT) { + const fresh = rows.filter((r) => isRecent(r.created)); + const older = rows.filter((r) => !isRecent(r.created)); + const freshMatches = fresh.filter((r) => matchesQueryTitle(r.title, query)); + + if (older.length === 0) return freshMatches; + + if (older.length > KQL_ID_LIMIT) { console.warn( - `[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${rows.length}` + `[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${older.length}` ); } - const scope = rows.slice(0, KQL_ID_LIMIT); + const candidates = older.slice(0, KQL_ID_LIMIT); - const byType = new Map(); - types.forEach((t) => byType.set(t.id, t)); - const scopeTypes = types.filter((t) => scope.some((r) => r.requestTypeId === t.id)); - if (scopeTypes.length === 0) return []; + const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id)); + if (scopeTypes.length === 0) return freshMatches; const sp = spfi(scopeTypes[0].siteUrl as string).using(SPFx(context)); const kql = [ `(${scopeTypes.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, - `(${scope.map((r) => `ListItemID:${r.id}`).join(' OR ')})`, + `(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`, text, ].join(' AND '); @@ -452,7 +620,9 @@ export async function filterPendingByQuery( // ListItemID уникален только внутри своего списка — сверяем пару тип+Id. const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`)); - return scope.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); + const olderMatches = candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); + + return freshMatches.concat(olderMatches); } function toMyRequest( -- 2.34.1 From 8f7d81d2b6e2efa828a8c570ead4b6f63f059c4c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 5 Aug 2026 13:53:54 +0500 Subject: [PATCH 03/10] =?UTF-8?q?=D0=B4=D0=BE=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B0=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B8=D1=85?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 22 +- .../components/SideNav/SideNav.tsx | 1 + .../features/myRequests/Pager.module.scss | 50 +++++ .../features/myRequests/Pager.tsx | 69 ++++++ .../myRequests/RequestsTableView.module.scss | 49 ----- .../features/myRequests/RequestsTableView.tsx | 77 ++----- .../myRequests/SearchView.module.scss | 203 ++++++++++++++++++ .../features/myRequests/SearchView.tsx | 194 +++++++++++++++++ .../features/myRequests/myRequests.service.ts | 95 ++++++++ src/webparts/requestPortal/shared/routing.ts | 7 +- 10 files changed, 648 insertions(+), 119 deletions(-) create mode 100644 src/webparts/requestPortal/features/myRequests/Pager.module.scss create mode 100644 src/webparts/requestPortal/features/myRequests/Pager.tsx create mode 100644 src/webparts/requestPortal/features/myRequests/SearchView.module.scss create mode 100644 src/webparts/requestPortal/features/myRequests/SearchView.tsx diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 0b2a4f5..b6221b5 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -12,6 +12,7 @@ import { REQUEST_TYPES } from '../data/requests'; import SubmitFeature from '../features/submit/SubmitFeature'; import ApproveFeature from '../features/approve/ApproveFeature'; import { RequestsTableView } from '../features/myRequests/RequestsTableView'; +import { SearchView } from '../features/myRequests/SearchView'; import { getPendingApproval } from '../features/myRequests/myRequests.service'; // Оболочка портала: тёмное боковое меню (разделы) + основная область. @@ -39,14 +40,18 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context }); // Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и - // сами таблицы (создание заявки, решение согласующего). + // сами таблицы (создание заявки, решение согласующего), и на монтировании + // (в т.ч. на обновление страницы). Когда сама вкладка «На согласовании» + // открыта, RequestsTableView сообщает точное число через onMyTurnCountChange + // (ниже) — это перезаписывает значение отсюда, чтобы не было гонки между + // двумя независимыми запросами одних и тех же данных. React.useEffect(() => { let cancelled = false; const load = (): void => { getPendingApproval(context) .then((r) => { - if (!cancelled) setApprovalsCount(r.rows.length); + if (!cancelled) setApprovalsCount(r.totalRows); }) .catch(() => { if (!cancelled) setApprovalsCount(undefined); @@ -108,6 +113,18 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context } /> + ) : route.nav === 'search' ? ( + + navigate({ + mode: 'approve', + nav: route.nav, + formKey: request.formKey, + itemId: request.id, + }) + } + /> ) : ( = ({ hasTeamsContext, context itemId: request.id, }) } + onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined} /> )} diff --git a/src/webparts/requestPortal/components/SideNav/SideNav.tsx b/src/webparts/requestPortal/components/SideNav/SideNav.tsx index dd84c9a..ed720cd 100644 --- a/src/webparts/requestPortal/components/SideNav/SideNav.tsx +++ b/src/webparts/requestPortal/components/SideNav/SideNav.tsx @@ -14,6 +14,7 @@ const ITEMS: INavItem[] = [ { id: 'new', title: 'Новая заявка', icon: 'plus' }, { id: 'approvals', title: 'На согласовании', icon: 'user-check' }, { id: 'own', title: 'Мои заявки', icon: 'clipboard' }, + { id: 'search', title: 'Поиск', icon: 'search' }, ]; interface ISideNavProps { diff --git a/src/webparts/requestPortal/features/myRequests/Pager.module.scss b/src/webparts/requestPortal/features/myRequests/Pager.module.scss new file mode 100644 index 0000000..01e17f8 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/Pager.module.scss @@ -0,0 +1,50 @@ +@import '../../../../styles/design-tokens'; + +.pager { + display: flex; + align-items: center; + gap: $sp-1; + margin-top: $sp-4; + flex-wrap: wrap; +} + +.pagerBtn { + min-width: 30px; + height: 30px; + padding: 0 8px; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $white; + color: $gray-700; + font-family: $font-sans; + font-size: $fs-sm; + cursor: pointer; + transition: .16s; + + &:hover:not(:disabled) { + border-color: $gold-400; + color: $gold-600; + } + + &:disabled { + opacity: .4; + cursor: default; + } +} + +.pagerBtnActive { + background: $navy-800; + border-color: $navy-800; + color: $white; + + &:hover { + border-color: $navy-800 !important; + color: $white !important; + } +} + +.pagerEllipsis { + color: $gray-400; + font-size: $fs-sm; + padding: 0 4px; +} diff --git a/src/webparts/requestPortal/features/myRequests/Pager.tsx b/src/webparts/requestPortal/features/myRequests/Pager.tsx new file mode 100644 index 0000000..906ac30 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/Pager.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import styles from './Pager.module.scss'; + +export interface IPagerProps { + page: number; + totalPages: number; + onChange: (page: number) => void; +} + +// Постраничная навигация: номера страниц + вперёд/назад. При большом +// количестве страниц показывает только соседние с текущей + первую/последнюю. +// Общий для всех разделов «Текущие заявки» (RequestsTableView, SearchView). +export const Pager: React.FC = ({ page, totalPages, onChange }) => { + if (totalPages <= 1) return null; + + const numbers: number[] = []; + const from = Math.max(1, page - 2); + const to = Math.min(totalPages, page + 2); + for (let i = from; i <= to; i++) numbers.push(i); + + return ( +
+ + {from > 1 && ( + <> + + {from > 2 && …} + + )} + {numbers.map((n) => ( + + ))} + {to < totalPages && ( + <> + {to < totalPages - 1 && …} + + + )} + +
+ ); +}; diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss index 420e609..effc7d5 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -193,52 +193,3 @@ background: $gray-100; color: $gray-500; } - -.pager { - display: flex; - align-items: center; - gap: $sp-1; - margin-top: $sp-4; - flex-wrap: wrap; -} - -.pagerBtn { - min-width: 30px; - height: 30px; - padding: 0 8px; - border: 1px solid $gray-200; - border-radius: $r-sm; - background: $white; - color: $gray-700; - font-family: $font-sans; - font-size: $fs-sm; - cursor: pointer; - transition: .16s; - - &:hover:not(:disabled) { - border-color: $gold-400; - color: $gold-600; - } - - &:disabled { - opacity: .4; - cursor: default; - } -} - -.pagerBtnActive { - background: $navy-800; - border-color: $navy-800; - color: $white; - - &:hover { - border-color: $navy-800 !important; - color: $white !important; - } -} - -.pagerEllipsis { - color: $gray-400; - font-size: $fs-sm; - padding: 0 4px; -} diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index aaf9c0e..1bf0f2d 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -11,6 +11,7 @@ import { import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; +import { Pager } from './Pager'; import styles from './RequestsTableView.module.scss'; const PAGE_SIZE = 20; @@ -21,6 +22,10 @@ export interface IRequestsTableViewProps { kind: 'approvals' | 'own'; /** Клик по строке — открыть заявку (в выезжающей панели). */ onOpen: (request: IMyRequest) => void; + /** Точное число «ждут решения СЕЙЧАС» (kind='approvals', без поиска) — для + * счётчика в SideNav, чтобы не гонять для него отдельный параллельный + * запрос, который мог бы разойтись по времени с тем, что видно в таблице. */ + onMyTurnCountChange?: (count: number) => void; } function formatDate(iso: string): string { @@ -48,70 +53,6 @@ function statusClass(status: string): string { } } -// Постраничная навигация: номера страниц + вперёд/назад. При большом -// количестве страниц показывает только соседние с текущей + первую/последнюю. -const Pager: React.FC<{ page: number; totalPages: number; onChange: (page: number) => void }> = ({ - page, - totalPages, - onChange, -}) => { - if (totalPages <= 1) return null; - - const numbers: number[] = []; - const from = Math.max(1, page - 2); - const to = Math.min(totalPages, page + 2); - for (let i = from; i <= to; i++) numbers.push(i); - - return ( -
- - {from > 1 && ( - <> - - {from > 2 && …} - - )} - {numbers.map((n) => ( - - ))} - {to < totalPages && ( - <> - {to < totalPages - 1 && …} - - - )} - -
- ); -}; - // Таблица заявок. Клик в ЛЮБОМ месте строки открывает заявку (кликабельна вся // строка, а не только название), поэтому строка — с onClick + tabIndex // (доступ с клавиатуры: Enter/Space). @@ -191,7 +132,12 @@ const Table: React.FC<{ // Оба варианта перечитываются по событию rp-requests-changed // (shared/requestEvents) — после создания заявки и после решения согласующего. // Поиск идёт по индексу, поэтому свежая правка может проявиться не мгновенно. -export const RequestsTableView: React.FC = ({ context, kind, onOpen }) => { +export const RequestsTableView: React.FC = ({ + context, + kind, + onOpen, + onMyTurnCountChange, +}) => { const [types, setTypes] = React.useState(undefined); const [rows, setRows] = React.useState(undefined); const [totalRows, setTotalRows] = React.useState(0); @@ -240,6 +186,7 @@ export const RequestsTableView: React.FC = ({ context, setRows(r.rows); setTotalRows(r.totalRows); setError(''); + if (onMyTurnCountChange) onMyTurnCountChange(r.totalRows); return; } diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.module.scss b/src/webparts/requestPortal/features/myRequests/SearchView.module.scss new file mode 100644 index 0000000..6663a95 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/SearchView.module.scss @@ -0,0 +1,203 @@ +@import '../../../../styles/design-tokens'; + +.view { + width: 100%; +} + +.head { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-4; + flex-wrap: wrap; + margin-bottom: $sp-4; +} + +.heading { + display: flex; + align-items: center; + gap: $sp-3; + margin: 0; + font-family: $font-serif; + font-size: $fs-page-title; + font-weight: 600; + color: $navy-900; +} + +.count { + padding: 2px 10px; + border-radius: $r-full; + background: $navy-100; + color: $navy-700; + font-family: $font-sans; + font-size: $fs-label; + font-weight: 600; +} + +.search { + width: 420px; + max-width: 100%; + padding: 9px 13px; + border: 1px solid $gray-300; + border-radius: $r-sm; + background: $white; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-900; + transition: .18s; + + @include input-focus-gold; + + &::placeholder { + color: $gray-400; + } +} + +.empty, +.error { + padding: $sp-6; + background: $white; + border: 1px solid $gray-200; + border-radius: $r-md; + color: $gray-500; + font-size: $fs-sm; +} + +.error { + color: $red-500; + border-color: $red-500; + background: $red-50; +} + +.tableWrap { + background: $white; + border: 1px solid $gray-200; + border-radius: $r-md; + box-shadow: $sh-1; + overflow: hidden; + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: $fs-sm; + + th { + text-align: left; + padding: 12px 16px; + background: $gray-50; + border-bottom: 1px solid $gray-200; + color: $gray-500; + font-size: $fs-label; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + white-space: nowrap; + } + + td { + padding: 14px 16px; + border-bottom: 1px solid $gray-100; + color: $gray-900; + vertical-align: middle; + overflow-wrap: break-word; + word-break: break-word; + } + + tbody tr:last-child td { + border-bottom: 0; + } +} + +// Кликабельна вся строка (см. SearchView — onClick на ). +.row { + cursor: pointer; + transition: background .16s; + + &:hover { + background: $gold-50; + } + + &:focus-visible { + outline: 2px solid $gold-500; + outline-offset: -2px; + } +} + +.colId { + width: 70px; + color: $gray-400; + white-space: nowrap; +} + +.colType { + width: 170px; + color: $navy-700; + font-weight: 500; +} + +.colTitle { + min-width: 220px; +} + +.colDate { + width: 100px; + color: $gray-500; + white-space: nowrap; +} + +.colAuthor { + width: 220px; + color: $gray-500; +} + +.colStatus { + width: 170px; +} + +.colKind { + width: 110px; + color: $gray-400; + font-size: $fs-caption; + white-space: nowrap; +} + +.status { + display: inline-block; + padding: 3px 10px; + border-radius: $r-md; + font-size: $fs-caption; + font-weight: 500; + line-height: 1.3; +} + +.statusDraft { + background: $gray-100; + color: $gray-700; +} + +.statusOnApproval { + background: $gold-100; + color: $gold-600; +} + +.statusApproved { + background: $green-50; + color: $green-600; +} + +.statusRejected { + background: $red-50; + color: $red-500; +} + +.statusReturned { + background: $blue-50; + color: $blue-600; +} + +.statusDefault { + background: $gray-100; + color: $gray-500; +} diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx new file mode 100644 index 0000000..ceda798 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -0,0 +1,194 @@ +import * as React from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import { getLinkedTypes, searchMyRequests } from './myRequests.service'; +import { IMyRequest } from './models/IMyRequest'; +import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; +import { onRequestsChanged } from '../../shared/requestEvents'; +import { Pager } from './Pager'; +import styles from './SearchView.module.scss'; + +const PAGE_SIZE = 20; + +export interface ISearchViewProps { + context: WebPartContext; + /** Клик по строке — открыть заявку (в выезжающей панели). */ + onOpen: (request: IMyRequest) => void; +} + +function formatDate(iso: string): string { + if (!iso) return ''; + const d = new Date(iso); + const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`; + const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`; + return `${dd}.${mm}.${d.getFullYear()}`; +} + +function statusClass(status: string): string { + switch (status) { + case REQUEST_STATUS.approved: + return styles.statusApproved; + case REQUEST_STATUS.rejected: + return styles.statusRejected; + case REQUEST_STATUS.returned: + return styles.statusReturned; + case REQUEST_STATUS.onApproval: + return styles.statusOnApproval; + case REQUEST_STATUS.draft: + return styles.statusDraft; + default: + return styles.statusDefault; + } +} + +// В отличие от RequestsTableView (где по смыслу раздела нужно либо Автор, +// либо Статус) — здесь смешаны «свои» и «где участвовал» заявки, поэтому +// показываем обе колонки сразу, плюс небольшая метка «Автор/Согласование» — +// чтобы было видно, откуда строка (kind), не только по автору. +const Table: React.FC<{ rows: IMyRequest[]; onOpen: (request: IMyRequest) => void }> = ({ rows, onOpen }) => ( +
+ + + + + + + + + + + + + + {rows.map((r) => ( + onOpen(r)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen(r); + } + }} + > + + + + + + + + + ))} + +
IDТипНазваниеДатаАвторСтатусРоль
{r.id}{r.requestTypeName}{r.title || `#${r.id}`}{formatDate(r.created)} + {[r.authorName, r.authorEmail].filter(Boolean).join(' · ') || '—'} + + {r.status || '—'} + {r.kind === 'own' ? 'Создал(а) я' : 'Участвую'}
+
+); + +// Вкладка «Поиск»: ищет сразу и среди своих заявок, и среди тех, где +// пользователь участвует как согласующий (независимо от того, дошла ли уже +// очередь) — единым списком, с колонками Автор и Статус одновременно (см. +// searchMyRequests в myRequests.service.ts). Без введённого текста — пусто, +// это не список «на экран по умолчанию», а именно поиск. +// +// Перечитывается по событию rp-requests-changed (после создания заявки и +// после решения согласующего) — но т.к. это по большей части Search, свежая +// правка может проявиться не мгновенно (частично компенсируется довеском +// последних 10 минут через REST — см. myRequests.service.ts). +export const SearchView: React.FC = ({ context, onOpen }) => { + const [types, setTypes] = React.useState(undefined); + const [rows, setRows] = React.useState(undefined); + const [totalRows, setTotalRows] = React.useState(0); + const [page, setPage] = React.useState(1); + const [query, setQuery] = React.useState(''); + const [error, setError] = React.useState(''); + + React.useEffect(() => setPage(1), [query]); + + React.useEffect(() => { + let cancelled = false; + getLinkedTypes(context) + .then((t) => { + if (!cancelled) setTypes(t); + }) + .catch((e) => { + if (!cancelled) setError((e as Error).message); + }); + return () => { + cancelled = true; + }; + }, [context]); + + React.useEffect(() => { + if (types === undefined) return; + if (query.trim().length === 0) { + setRows(undefined); + setTotalRows(0); + return; + } + let cancelled = false; + + const load = async (): Promise => { + try { + const r = await searchMyRequests(context, types, query, page); + if (cancelled) return; + setRows(r.rows); + setTotalRows(r.totalRows); + setError(''); + } catch (e) { + if (!cancelled) setError((e as Error).message); + } + }; + + load().catch(() => undefined); + const unsubscribe = onRequestsChanged(() => { + load().catch(() => undefined); + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [context, types, query, page]); + + const hasQuery = query.trim().length > 0; + const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); + + return ( +
+
+

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

+ setQuery(e.target.value)} + autoFocus + /> +
+ + {error &&
Не удалось выполнить поиск: {error}
} + {!error && !hasQuery &&
Введите текст, чтобы начать поиск.
} + {!error && hasQuery && rows === undefined &&
Ищем…
} + {!error && hasQuery && rows && totalRows === 0 && ( +
По запросу «{query}» ничего не найдено.
+ )} + {!error && hasQuery && rows && totalRows > 0 && ( + <> + + + + )} + + ); +}; diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 72a97a1..095a405 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -625,6 +625,101 @@ export async function filterPendingByQuery( return freshMatches.concat(olderMatches); } +// --------------------------------------------------------------------------- +// Вкладка «Поиск»: ищет сразу по двум категориям — «Мои заявки» (я автор) и +// «где я участвовал как согласующий» (involved, независимо от того, дошла ли +// уже очередь) — и показывает их одним списком, с колонками Автор и Статус +// одновременно (в отличие от «Мои заявки»/«На согласовании», где по смыслу +// раздела одно из двух не нужно). +// +// Пагинация — клиентская поверх объединённого результата: «Мои заявки» уже +// умеет по-настоящему постранично через Search, но «где участвовал» — нет +// (см. filterPendingByQuery — там честного $skip/$top не сделать), так что +// единой серверной пагинации на двух источниках сразу не построить. Для +// сценария «искал что-то один раз» это приемлемо. +export interface ISearchPage { + rows: IMyRequest[]; + totalRows: number; +} + +// Как getOwnRequests, но без $skip/$top — сразу весь найденный срез (до +// KQL_ID_LIMIT), с тем же довеском свежих (< 10 мин) заявок через REST. +async function searchOwnAll( + context: WebPartContext, + types: IRequestType[], + query: string +): Promise { + if (types.length === 0) return []; + + const sp = spfi(types[0].siteUrl as string).using(SPFx(context)); + const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`; + const clauses = [ + `(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, + `AuthorOWSUSER:"${claimsLogin}"`, + ]; + const text = freeTextClause(query); + if (text) clauses.push(text); + const kql = clauses.join(' AND '); + + const results = await sp.search({ + Querytext: kql, + RowLimit: KQL_ID_LIMIT, + SelectProperties: ['ListItemID', 'Title', 'Path'], + TrimDuplicates: false, + }); + + console.log(`[Текущие заявки] Search «Поиск» (мои заявки): KQL = ${kql}, найдено: ${results.TotalRows}`); + console.table(results.PrimarySearchResults); + + const entries = hitsToEntries(results, types); + const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); + const searchRows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); + + const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t, query)))).reduce( + (all, r) => all.concat(r), + [] as IMyRequest[] + ); + const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`)); + const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`)); + + return onlyFresh.concat(searchRows); +} + +export async function searchMyRequests( + context: WebPartContext, + types: IRequestType[], + query: string, + page: number +): Promise { + const q = query.trim(); + if (!q) return { rows: [], totalRows: 0 }; + + const [ownRows, involvedIdx] = await Promise.all([ + searchOwnAll(context, types, q), + getPendingApprovalIndex(context, 'involved'), + ]); + + const involvedFiltered = await filterPendingByQuery(context, involvedIdx.types, involvedIdx.rows, q); + + // Заявка может оказаться и своей, и involved одновременно (сам себе + // согласующий) — оставляем как 'own', не дублируем строку. + const ownKeys = new Set(ownRows.map((r) => `${r.requestTypeId}-${r.id}`)); + const involvedOnly = involvedFiltered.filter((r) => !ownKeys.has(`${r.requestTypeId}-${r.id}`)); + + const merged = ownRows.concat(involvedOnly).sort((a, b) => b.created.localeCompare(a.created)); + const totalRows = merged.length; + const pageRows = merged.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + // Автор нужен для 'approval'-строк текущей страницы — 'own' уже пришли с + // автором (loadByIds/getFreshOwnRowsForType сами дочитывают Author). + const needAuthor = pageRows.filter((r) => r.kind === 'approval'); + if (needAuthor.length > 0) { + await hydratePendingAuthors(context, involvedIdx.types, needAuthor); + } + + return { rows: pageRows, totalRows }; +} + function toMyRequest( r: Record, author: { EMail?: string; Title?: string } | undefined, diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index b075473..09ae6e0 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -3,7 +3,8 @@ import * as React from 'react'; // Маршрутизация портала по параметрам URL. // // Раздел бокового меню — rpnav: 'new' (подать заявку, по умолчанию), -// 'approvals' (ждут моего решения), 'own' (мои заявки). +// 'approvals' (ждут моего решения), 'own' (мои заявки), 'search' (поиск +// сразу по обеим категориям — и по своим заявкам, и по тем, где участвовал). // Открытая заявка — переход по ссылке из потока Power Automate или клик по // строке таблицы: // …/СтраницаПортала.aspx?rpmode=approve&rptype=<форма>&rpid= @@ -22,7 +23,7 @@ import * as React from 'react'; export type PortalMode = 'submit' | 'approve' | 'create'; /** Раздел бокового меню. */ -export type NavSection = 'new' | 'approvals' | 'own'; +export type NavSection = 'new' | 'approvals' | 'own' | 'search'; export interface IRoute { mode: PortalMode; @@ -39,7 +40,7 @@ export interface IRoute { const ROUTE_CHANGE_EVENT = 'rp-route-change'; function parseNav(raw: string | undefined): NavSection { - if (raw === 'approvals' || raw === 'own') return raw; + if (raw === 'approvals' || raw === 'own' || raw === 'search') return raw; return 'new'; } -- 2.34.1 From 295d9b79144a5eae8f6de08ee4fc936764ebfc48 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 5 Aug 2026 14:11:16 +0500 Subject: [PATCH 04/10] =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 1 - .../components/SideNav/SideNav.module.scss | 54 ------------------- .../components/SideNav/SideNav.tsx | 21 +------- 3 files changed, 2 insertions(+), 74 deletions(-) diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index b6221b5..d675a3b 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -89,7 +89,6 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context approvalsCount={approvalsCount} collapsed={navCollapsed} onToggleCollapsed={() => setNavCollapsed((v) => !v)} - userName={context.pageContext.user.displayName} /> 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 ed720cd..ba6119c 100644 --- a/src/webparts/requestPortal/components/SideNav/SideNav.tsx +++ b/src/webparts/requestPortal/components/SideNav/SideNav.tsx @@ -24,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(' '); @@ -52,7 +41,6 @@ export const SideNav: React.FC = ({ ); }; -- 2.34.1 From 68638c765e1573caf5bb8df50d2461735782f0f3 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 5 Aug 2026 15:05:46 +0500 Subject: [PATCH 05/10] =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D1=80=D1=87=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D1=80=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/RequestsTableView.tsx | 23 +- .../features/myRequests/myRequests.service.ts | 231 ++++++++---------- .../approval/approverDecision.service.ts | 13 +- 3 files changed, 122 insertions(+), 145 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 1bf0f2d..22dc127 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -7,6 +7,7 @@ import { getPendingApproval, getPendingApprovalIndex, hydratePendingAuthors, + searchOwnRequests, } from './myRequests.service'; import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; @@ -116,12 +117,19 @@ const Table: React.FC<{ // Раздел с таблицей заявок: поиск по всем полям + вывод постранично. // -// 'own' — Search сразу с пагинацией (RowLimit/StartRow): автор = текущий -// пользователь + текст запроса по всем полям. +// Движок выбирается по наличию текста в поиске, а не по разделу: БЕЗ запроса — +// всегда обычный REST (мгновенно свежий, без задержки индексации), С запросом — +// всегда Search (полнотекстовый, но переиндексируется с задержкой). Раньше +// поиск подмешивал REST для «свежих» строк поверх Search — из-за этого +// результат мог быть неустойчивым (заявка то находилась, то нет, в зависимости +// от того, попала ли она в окно подстраховки); эту гибридную логику убрали. // -// 'approvals' без поиска — обычный REST, отфильтрованный по ApproverId(+Status), -// страница нарезается на СЕРВЕРЕ (getPendingApproval сам берёт нужный срез -// индекса и дочитывает автора только для него). +// 'own' без поиска — getOwnRequests (REST по AuthorId, страница режется в +// памяти). 'own' с поиском — searchOwnRequests (Search, RowLimit/StartRow). +// +// 'approvals' без поиска — getPendingApproval, REST по ApproverId(+Status), +// страница нарезается на СЕРВЕРЕ (сам берёт нужный срез индекса и дочитывает +// автора только для него). // 'approvals' с поиском — область расширяется до «где пользователь вообще есть // в маршруте» (getPendingApprovalIndex, ПОЛНЫЙ список без автора — постранично // его тут не взять, т.к. фильтрация текстом идёт уже после), затем текстовый @@ -168,7 +176,10 @@ export const RequestsTableView: React.FC = ({ const load = async (): Promise => { try { if (kind === 'own') { - const r = await getOwnRequests(context, types, query, page); + const hasOwnQuery = query.trim().length > 0; + const r = hasOwnQuery + ? await searchOwnRequests(context, types, query, page) + : await getOwnRequests(context, types, page); if (cancelled) return; setRows(r.rows); setTotalRows(r.totalRows); diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 7051a97..2da7aba 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -16,8 +16,10 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; // Свежие заявки/строки (созданы или изменены недавно) поисковый индекс может // ещё не проиндексировать — такие добираем обычным REST-ом напрямую, в обход -// Search, и подмешиваем к результату (см. getOwnRequests, indexApprovalsForType, -// filterPendingByQuery). +// Search, и подмешиваем к результату (см. indexApprovalsForType/myTurn, +// getFreshOwnRowsForType). Используется ТОЛЬКО в паре с Search (гибрид для +// таблиц без текстового запроса — там, где важны и масштаб, и мгновенная +// свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView). const RECENT_MS = 10 * 60 * 1000; export function currentUserEmail(context: WebPartContext): string { @@ -178,41 +180,42 @@ async function indexApprovalsForType( let stepRows: Array>; if (scope === 'myTurn') { - // 'myTurn' решает, надо ли действовать ПРЯМО СЕЙЧАС — Status должен быть - // гарантированно свежим (индекс Search обновляется с задержкой и мог бы - // показать уже решённый шаг как ожидающий). Поэтому здесь только REST, - // весь список по ApproverId, без архивного Search-довеска. - const allRows: Array> = []; - for await (const p of approvalList.items - .filter(`ApproverId eq ${approver.Id}`) - .select('Id', 'TicketId', 'Status') - .top(2000)) { - allRows.push(...(p as Array>)); - } - stepRows = allRows.filter( - (r) => r.Status === REQUEST_STATUS.onApproval || r.Status === REQUEST_STATUS.onExecution - ); - console.log( - `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn, строк по ApproverId: ${allRows.length}, после фильтра по статусу: ${stepRows.length}` - ); - } else { - // 'involved' — просто «участвовал ли вообще», Status не важен для - // истинности результата, поэтому здесь можно рискнуть Search-ом для - // архивной (не только что созданной/изменённой) части: свежее по - // Modified — REST, остальное — Search по managed property ApproverOWSUSER - // (кастомное поле, не гарантировано, что проиндексировано — см. лог). + // 'myTurn' — гибрид для масштаба (список согласования может быть очень + // большим): Search находит кандидатов по managed property ApproverOWSUSER + // без сканирования списка целиком, плюс 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(`ApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`) .select('Id', 'TicketId', 'Status') .top(2000)()) as Array>; - const archivalRows = await getArchivalInvolvedRows(context, type, approvalList, claimsLogin); + const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, claimsLogin); const seenIds = new Set(recentRows.map((r) => r.Id)); - stepRows = recentRows.concat(archivalRows.filter((r) => !seenIds.has(r.Id))); + const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); + + stepRows = allRows.filter( + (r) => r.Status === REQUEST_STATUS.onApproval || r.Status === 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 + // по managed property ApproverOWSUSER, без REST-подстраховки на + // «последние N минут» — гибрид REST+Search приводил к неустойчивому + // результату (заявка то находилась, то нет, в зависимости от того, + // успел ли Search её проиндексировать к моменту REST-окна). + stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, claimsLogin); console.log( - `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved — свежих (REST, < 10 мин): ${recentRows.length}, архивных (Search): ${archivalRows.length}, итого: ${stepRows.length}` + `[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved (Search) — найдено: ${stepRows.length}` ); } @@ -251,15 +254,15 @@ async function indexApprovalsForType( } } -// Архивная (не только что созданная/изменённая) часть 'involved' — через -// Search по managed property ApproverOWSUSER (кастомное Person-поле, НЕТ -// гарантии, что проиндексировано без ручной настройки Search Schema — если -// лог покажет «найдено: 0» при заведомо непустом архиве, значит не сработало -// и стоит вернуться на чистый REST для этой ветки). +// 'involved' целиком — через Search по managed property ApproverOWSUSER +// (кастомное Person-поле, НЕТ гарантии, что проиндексировано без ручной +// настройки Search Schema — если лог покажет «найдено: 0» при заведомо +// непустом архиве, значит не сработало). // // Search используется ТОЛЬКО чтобы найти Id строк — TicketId и Status берём -// свежими через REST по этим Id, а не из индекса (индекс мог устареть). -async function getArchivalInvolvedRows( +// свежими через REST по этим Id (точечное чтение по известным Id — не поиск, +// а обычное дочитывание данных, как и везде в проекте для найденных Search'ом строк). +async function getInvolvedRowsViaSearch( context: WebPartContext, type: IRequestType, approvalList: IList, @@ -373,23 +376,6 @@ const PAGE_SIZE = 20; // (см. filterPendingByQuery) — иначе строка запроса станет неподъёмной. const KQL_ID_LIMIT = 300; -function isRecent(createdIso: string): boolean { - if (!createdIso) return false; - const t = new Date(createdIso).getTime(); - return !isNaN(t) && Date.now() - t <= RECENT_MS; -} - -// Замена свободному терму Search для случаев, когда матчим на клиенте (без -// индекса): все слова запроса должны встретиться в названии (без учёта -// регистра). Не идентично семантике Search (там — префикс, тут — подстрока), -// но для «довеска» из нескольких свежих строк разница не принципиальна. -function matchesQueryTitle(title: string, query: string): boolean { - const q = query.trim().toLowerCase(); - if (!q) return true; - const t = title.toLowerCase(); - return q.split(/\s+/).filter(Boolean).every((word) => t.indexOf(word) !== -1); -} - function siteOrigin(siteUrl: string): string { const match = /^https?:\/\/[^/]+/.exec(siteUrl); return match ? match[0] : siteUrl; @@ -466,15 +452,9 @@ async function loadByIds( } // Свежие (созданные за последние RECENT_MS) заявки автора — обычным REST-ом, -// в обход Search (индекс мог их ещё не подхватить). Текст запроса матчим на -// клиенте (matchesQueryTitle) — substringof с кириллицей в $filter уже -// подводил на Status (см. indexApprovalsForType), поэтому в $filter только -// нечувствительные к этому условия (AuthorId/Created). -async function getFreshOwnRowsForType( - context: WebPartContext, - type: IRequestType, - query: string -): Promise { +// в обход Search (индекс мог их ещё не подхватить). Без текстового запроса — +// матчить название не нужно, отдаём как есть. +async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise { try { const sp = spfi(type.siteUrl as string).using(SPFx(context)); const me = await sp.web.currentUser.select('Id')(); @@ -487,24 +467,55 @@ async function getFreshOwnRowsForType( .expand('Author') .top(50)()) as Array>; - return rows - .map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own')) - .filter((r) => matchesQueryTitle(r.title, query)); + 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), поэтому таблица не тянет весь архив заявок. -// -// На первой странице довешиваем то, что Search мог ещё не проиндексировать -// (см. getFreshOwnRowsForType) — дедуп по (тип, Id), сортировка по дате и -// обрезка до PAGE_SIZE. На страницах 2+ этого не делаем: свежее по дате и так -// оказалось бы на первой странице, будь оно проиндексировано. +// «Мои заявки» БЕЗ поиска — гибрид для масштаба: 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, @@ -539,45 +550,16 @@ export async function getOwnRequests( const entries = hitsToEntries(results, types); const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); // Порядок задаёт релевантность поиска, а не порядок ответов REST. - const searchRows = entries - .map((e) => byKey.get(`${e.type.id}-${e.id}`)) - .filter((r): r is IMyRequest => !!r); + const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); - if (page !== 1) { - return { rows: searchRows, totalRows: results.TotalRows || 0 }; - } - - const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t, query)))).reduce( - (all, r) => all.concat(r), - [] as IMyRequest[] - ); - - const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`)); - const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`)); - - if (onlyFresh.length > 0) { - console.log( - `[Текущие заявки] «Мои заявки»: довесили ${onlyFresh.length} свежих заявок (< 10 мин), которых Search ещё не нашёл` - ); - } - - const merged = onlyFresh - .concat(searchRows) - .sort((a, b) => b.created.localeCompare(a.created)) - .slice(0, PAGE_SIZE); - - return { rows: merged, totalRows: (results.TotalRows || 0) + onlyFresh.length }; + return { rows, totalRows: results.TotalRows || 0 }; } // «На согласовании»: список заявок уже отобран по строкам согласования -// (getPendingApproval/getPendingApprovalIndex — там же определяется, чья -// сейчас очередь, обычным REST). Здесь среди НИХ фильтруем по тексту. -// -// Свежие (< 10 мин, isRecent) заявки Search мог не проиндексировать — -// их матчим по названию на клиенте (matchesQueryTitle), без похода в Search. -// Остальные — через Search: KQL ограничивается ListItemID этих заявок, текст -// идёт свободным термом по всем полям. Порядок сохраняется (rows уже -// отсортированы по дате в buildPendingIndex, свежие — в начале). +// (getPendingApprovalIndex — там же определяется, чья сейчас очередь, +// обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search +// (KQL ограничивается ListItemID этих заявок, текст идёт свободным термом по +// всем полям), без клиентского REST-матчинга по свежести. export async function filterPendingByQuery( context: WebPartContext, types: IRequestType[], @@ -587,21 +569,15 @@ export async function filterPendingByQuery( const text = freeTextClause(query); if (!text || rows.length === 0) return rows; - const fresh = rows.filter((r) => isRecent(r.created)); - const older = rows.filter((r) => !isRecent(r.created)); - const freshMatches = fresh.filter((r) => matchesQueryTitle(r.title, query)); - - if (older.length === 0) return freshMatches; - - if (older.length > KQL_ID_LIMIT) { + if (rows.length > KQL_ID_LIMIT) { console.warn( - `[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${older.length}` + `[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${rows.length}` ); } - const candidates = older.slice(0, KQL_ID_LIMIT); + 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 freshMatches; + if (scopeTypes.length === 0) return []; const sp = spfi(scopeTypes[0].siteUrl as string).using(SPFx(context)); const kql = [ @@ -622,9 +598,7 @@ export async function filterPendingByQuery( // ListItemID уникален только внутри своего списка — сверяем пару тип+Id. const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`)); - const olderMatches = candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); - - return freshMatches.concat(olderMatches); + return candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); } // --------------------------------------------------------------------------- @@ -644,8 +618,8 @@ export interface ISearchPage { totalRows: number; } -// Как getOwnRequests, но без $skip/$top — сразу весь найденный срез (до -// KQL_ID_LIMIT), с тем же довеском свежих (< 10 мин) заявок через REST. +// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до +// KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть. async function searchOwnAll( context: WebPartContext, types: IRequestType[], @@ -675,16 +649,7 @@ async function searchOwnAll( const entries = hitsToEntries(results, types); const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); - const searchRows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); - - const freshRows = (await Promise.all(types.map((t) => getFreshOwnRowsForType(context, t, query)))).reduce( - (all, r) => all.concat(r), - [] as IMyRequest[] - ); - const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`)); - const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`)); - - return onlyFresh.concat(searchRows); + return entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); } export async function searchMyRequests( @@ -713,7 +678,7 @@ export async function searchMyRequests( const pageRows = merged.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); // Автор нужен для 'approval'-строк текущей страницы — 'own' уже пришли с - // автором (loadByIds/getFreshOwnRowsForType сами дочитывают Author). + // автором (loadByIds сам дочитывает Author). const needAuthor = pageRows.filter((r) => r.kind === 'approval'); if (needAuthor.length > 0) { await hydratePendingAuthors(context, involvedIdx.types, needAuthor); diff --git a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts index 207dd0e..40dcd4b 100644 --- a/src/webparts/requestPortal/shared/approval/approverDecision.service.ts +++ b/src/webparts/requestPortal/shared/approval/approverDecision.service.ts @@ -38,13 +38,14 @@ export class ApproverService { public async getPendingRowForCurrentUser(requestId: number): Promise { const rows = await this.approval.getForRequest(requestId); const userId = await this.approval.getCurrentUserId(); - // Активна первая незакрытая строка по очереди: согласующий («На согласовании») - // или, когда согласование пройдено, исполнитель («На исполнении»). - const current = rows.filter( + // Активные строки текущего шага: согласующий («На согласовании») или, когда + // согласование пройдено, исполнитель («На исполнении»). При параллельном шаге + // (одинаковый StepOrder) активных строк может быть несколько сразу — ищем + // среди ВСЕХ активных, а не только среди первой по порядку. + const active = rows.filter( (r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution - )[0]; - if (current && current.approverId === userId) return current; - return undefined; + ); + return active.filter((r) => r.approverId === userId)[0]; } // Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между -- 2.34.1 From 9438ee30ad2a5467ebc53b16e31e2c7ad743470f Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 5 Aug 2026 15:13:52 +0500 Subject: [PATCH 06/10] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=81=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/RequestsTableView.tsx | 3 +- .../features/myRequests/SearchView.tsx | 3 +- .../requestPortal/shared/usePersistedQuery.ts | 35 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 src/webparts/requestPortal/shared/usePersistedQuery.ts diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index 22dc127..dd76b42 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -12,6 +12,7 @@ import { import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; +import { usePersistedQuery } from '../../shared/usePersistedQuery'; import { Pager } from './Pager'; import styles from './RequestsTableView.module.scss'; @@ -150,7 +151,7 @@ export const RequestsTableView: React.FC = ({ 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(''); React.useEffect(() => setPage(1), [query, kind]); diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index ceda798..1ce9a4b 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -4,6 +4,7 @@ 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 } from '../../shared/usePersistedQuery'; import { Pager } from './Pager'; import styles from './SearchView.module.scss'; @@ -107,7 +108,7 @@ export const SearchView: React.FC = ({ context, onOpen }) => { 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('search'); const [error, setError] = React.useState(''); React.useEffect(() => setPage(1), [query]); diff --git a/src/webparts/requestPortal/shared/usePersistedQuery.ts b/src/webparts/requestPortal/shared/usePersistedQuery.ts new file mode 100644 index 0000000..f813bda --- /dev/null +++ b/src/webparts/requestPortal/shared/usePersistedQuery.ts @@ -0,0 +1,35 @@ +import * as React from 'react'; + +// Текст поиска переживает перезагрузку страницы (localStorage), но не более +// того — это просто «продолжить с того же места», а не синхронизация между +// вкладками/устройствами. key должен быть уникальным на раздел (см. вызовы в +// RequestsTableView/SearchView), иначе значения разных таблиц перепишут друг друга. +export function usePersistedQuery(key: string): [string, (value: string) => void] { + const storageKey = `rp-search-query:${key}`; + + const [value, setValue] = React.useState(() => { + try { + return window.localStorage.getItem(storageKey) || ''; + } catch { + return ''; + } + }); + + 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]; +} -- 2.34.1 From 71873e0c302aaffa72777d597f30fa8b7dbf5bdb Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 6 Aug 2026 09:09:08 +0500 Subject: [PATCH 07/10] =?UTF-8?q?=D0=9F=D0=BE=D0=B8=D1=81=D0=BA=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D1=83=D1=8E=D1=89=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B2=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B5=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D0=B0=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20Search=20=D1=81=D0=B2=D0=BE=D0=B1=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=20=D1=82=D0=B5=D1=80=D0=BC=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApproverOWSUSER (managed property для Person-поля Approver) в тенанте не индексируется — запросы стабильно возвращали 0 при заведомо непустых данных. Диагностика показала, что свободный терм по отображаемому имени согласующего находит нужные строки без managed property, поэтому involved/myTurn-поиск в getInvolvedRowsViaSearch переведён на него. --- .../features/myRequests/myRequests.service.ts | 85 ++++++++++++------- 1 file changed, 56 insertions(+), 29 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 2da7aba..783fe58 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -22,6 +22,12 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; // свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView). const RECENT_MS = 10 * 60 * 1000; +// Сколько строк согласования максимум забираем из Search за один запрос +// (см. getInvolvedRowsViaSearch). Постраничного перебора здесь нет: набор +// целиком уходит в слияние и сортировку по дате заявки, поэтому при превышении +// лимита пишем предупреждение в лог, а не молча обрезаем. +const SEARCH_ROW_LIMIT = 500; + export function currentUserEmail(context: WebPartContext): string { return (context.pageContext.user.email || '').toLowerCase(); } @@ -181,20 +187,19 @@ async function indexApprovalsForType( if (scope === 'myTurn') { // 'myTurn' — гибрид для масштаба (список согласования может быть очень - // большим): Search находит кандидатов по managed property ApproverOWSUSER - // без сканирования списка целиком, плюс REST по «последним N минутам» - // (Modified) добирает то, что Search мог ещё не проиндексировать. Status - // в обоих случаях берётся СВЕЖИМ через REST по найденным Id (не из индекса - // Search) — именно это делает getInvolvedRowsViaSearch, поэтому решение - // «моя очередь сейчас» всегда точное, даже когда список кандидатов пришёл - // из Search. + // большим): 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(`ApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`) .select('Id', 'TicketId', 'Status') .top(2000)()) as Array>; - const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, claimsLogin); + const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList); const seenIds = new Set(recentRows.map((r) => r.Id)); const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); @@ -207,12 +212,12 @@ async function indexApprovalsForType( } else { // 'involved' — используется ТОЛЬКО для текстового поиска (см. // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная - // свежесть, а полнота по всему архиву, поэтому ищем чисто через Search - // по managed property ApproverOWSUSER, без REST-подстраховки на - // «последние N минут» — гибрид REST+Search приводил к неустойчивому - // результату (заявка то находилась, то нет, в зависимости от того, - // успел ли Search её проиндексировать к моменту REST-окна). - stepRows = await getInvolvedRowsViaSearch(context, type, approvalList, claimsLogin); + // свежесть, а полнота по всему архиву, поэтому только 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}` @@ -254,21 +259,36 @@ async function indexApprovalsForType( } } -// 'involved' целиком — через Search по managed property ApproverOWSUSER -// (кастомное Person-поле, НЕТ гарантии, что проиндексировано без ручной -// настройки Search Schema — если лог покажет «найдено: 0» при заведомо -// непустом архиве, значит не сработало). +// 'involved' — строки согласования текущего пользователя через Search, чтобы не +// сканировать список целиком (он растёт с каждой заявкой). // -// Search используется ТОЛЬКО чтобы найти Id строк — TicketId и Status берём -// свежими через REST по этим Id (точечное чтение по известным Id — не поиск, -// а обычное дочитывание данных, как и везде в проекте для найденных Search'ом строк). +// Ищем ОТОБРАЖАЕМЫМ ИМЕНЕМ СВОБОДНЫМ ТЕРМОМ, а не через managed property +// ApproverOWSUSER. Подтверждено диагностикой 2026-08-05 в этом тенанте: +// ApproverOWSUSER отдаёт null у всех строк (автосозданного свойства не было — +// нашлось только чужое ApprooverOWSUSER с опечаткой; созданное вручную и +// смапленное на ows_Approver так и не наполнилось), а вот свободный терм +// `"Павел Горбенко"` находит именно свои 12 строк из 27. То есть Person-поле в +// полнотекстовый индекс попадает как текст, просто через managed property не +// достаётся. +// +// Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём +// свежими через REST по этим Id, а не из индекса (индекс мог устареть). async function getInvolvedRowsViaSearch( context: WebPartContext, type: IRequestType, - approvalList: IList, - claimsLogin: string + approvalList: IList ): Promise>> { try { + // Кавычки сломали бы фразу в KQL — убираем (в ФИО их быть не должно, но + // значение приходит извне, поэтому не доверяем). + const displayName = (context.pageContext.user.displayName || '').replace(/"/g, ' ').trim(); + if (!displayName) { + console.warn( + `[Текущие заявки] «${type.name}»: у текущего пользователя нет отображаемого имени — Search по согласующему невозможен` + ); + return []; + } + const rootFolder = (await approvalList .select('RootFolder/ServerRelativeUrl') .expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } }; @@ -277,21 +297,28 @@ async function getInvolvedRowsViaSearch( const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl}`; const sp = spfi(type.siteUrl as string).using(SPFx(context)); - const kql = `Path:"${approvalListAbsUrl}*" AND ApproverOWSUSER:"${claimsLogin}"`; + const kql = `Path:"${approvalListAbsUrl}*" AND "${displayName}"`; const results = await sp.search({ Querytext: kql, - RowLimit: 500, + RowLimit: SEARCH_ROW_LIMIT, SelectProperties: ['ListItemID'], TrimDuplicates: false, }); - console.log(`[Текущие заявки] Search «${type.name}» (архив, involved): KQL = ${kql}, найдено: ${results.TotalRows}`); - console.table(results.PrimarySearchResults); - + 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 @@ -300,7 +327,7 @@ async function getInvolvedRowsViaSearch( .top(ids.length)()) as Array>; } catch (e) { console.error( - `[Текущие заявки] «${type.name}»: Search по архиву согласования не сработал:`, + `[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`, (e as Error).message ); return []; -- 2.34.1 From 86ce7056e817c4be0ab5f4a5d83d27c5c76c8054 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 6 Aug 2026 14:07:37 +0500 Subject: [PATCH 08/10] =?UTF-8?q?=D0=BD=D0=B0=D0=BA=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=D1=86=D1=82=D0=BE=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/myRequests/RequestsTableView.tsx | 22 ++-- .../features/myRequests/SearchView.tsx | 19 +-- .../features/myRequests/myRequests.service.ts | 108 +++++++++++------- .../RecruitingHire/RecruitingHireView.tsx | 20 +++- .../RecruitingHire/approval/provisioning.json | 4 +- .../WorkplaceSetup/WorkplaceSetupView.tsx | 20 +++- .../WorkplaceSetup/approval/provisioning.json | 4 +- .../shared/approval/approvalList.service.ts | 8 +- .../requestPortal/shared/pnp/pnpjs.ts | 22 +++- .../requestPortal/shared/usePersistedQuery.ts | 26 +++++ 10 files changed, 183 insertions(+), 70 deletions(-) diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index dd76b42..ee191d9 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -12,7 +12,7 @@ import { import { IMyRequest } from './models/IMyRequest'; import { REQUEST_STATUS, IRequestType } from '../../shared/approval'; import { onRequestsChanged } from '../../shared/requestEvents'; -import { usePersistedQuery } from '../../shared/usePersistedQuery'; +import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; import { Pager } from './Pager'; import styles from './RequestsTableView.module.scss'; @@ -153,8 +153,12 @@ export const RequestsTableView: React.FC = ({ const [page, setPage] = React.useState(1); 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; @@ -177,9 +181,9 @@ export const RequestsTableView: React.FC = ({ const load = async (): Promise => { try { if (kind === 'own') { - const hasOwnQuery = query.trim().length > 0; + const hasOwnQuery = debouncedQuery.trim().length > 0; const r = hasOwnQuery - ? await searchOwnRequests(context, types, query, page) + ? await searchOwnRequests(context, types, debouncedQuery, page) : await getOwnRequests(context, types, page); if (cancelled) return; setRows(r.rows); @@ -188,7 +192,7 @@ export const RequestsTableView: React.FC = ({ return; } - const hasQuery = query.trim().length > 0; + const hasQuery = debouncedQuery.trim().length > 0; if (!hasQuery) { // Без поиска — страница уже нарезана и дочитана сервером. @@ -208,7 +212,7 @@ export const RequestsTableView: React.FC = ({ const idx = await getPendingApprovalIndex(context, 'involved'); if (cancelled) return; console.table(idx.debug); - const filtered = await filterPendingByQuery(context, idx.types, idx.rows, query); + const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery); if (cancelled) return; const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); await hydratePendingAuthors(context, idx.types, pageRows); @@ -229,10 +233,10 @@ export const RequestsTableView: React.FC = ({ cancelled = true; unsubscribe(); }; - }, [context, types, kind, query, page]); + }, [context, types, kind, debouncedQuery, page]); // Обе ветки теперь отдают уже нарезанную страницу — просто показываем как есть. - const q = query.trim(); + const q = debouncedQuery.trim(); const total = totalRows; const visible = rows || []; const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); @@ -260,7 +264,7 @@ export const RequestsTableView: React.FC = ({ {!error && rows && total === 0 && (
{q - ? `По запросу «${query}» ничего не найдено.` + ? `По запросу «${debouncedQuery}» ничего не найдено.` : kind === 'approvals' ? 'Нет заявок, ждущих вашего решения.' : 'Вы ещё не создавали заявок.'} diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index 1ce9a4b..f20f2ef 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -4,7 +4,7 @@ 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 } from '../../shared/usePersistedQuery'; +import { usePersistedQuery, useDebouncedQuery } from '../../shared/usePersistedQuery'; import { Pager } from './Pager'; import styles from './SearchView.module.scss'; @@ -109,9 +109,14 @@ export const SearchView: React.FC = ({ context, onOpen }) => { 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), [query]); + React.useEffect(() => setPage(1), [debouncedQuery]); React.useEffect(() => { let cancelled = false; @@ -129,7 +134,7 @@ export const SearchView: React.FC = ({ context, onOpen }) => { React.useEffect(() => { if (types === undefined) return; - if (query.trim().length === 0) { + if (debouncedQuery.trim().length === 0) { setRows(undefined); setTotalRows(0); return; @@ -138,7 +143,7 @@ export const SearchView: React.FC = ({ context, onOpen }) => { const load = async (): Promise => { try { - const r = await searchMyRequests(context, types, query, page); + const r = await searchMyRequests(context, types, debouncedQuery, page); if (cancelled) return; setRows(r.rows); setTotalRows(r.totalRows); @@ -156,9 +161,9 @@ export const SearchView: React.FC = ({ context, onOpen }) => { cancelled = true; unsubscribe(); }; - }, [context, types, query, page]); + }, [context, types, debouncedQuery, page]); - const hasQuery = query.trim().length > 0; + const hasQuery = debouncedQuery.trim().length > 0; const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE)); return ( @@ -182,7 +187,7 @@ export const SearchView: React.FC = ({ context, onOpen }) => { {!error && !hasQuery &&
Введите текст, чтобы начать поиск.
} {!error && hasQuery && rows === undefined &&
Ищем…
} {!error && hasQuery && rows && totalRows === 0 && ( -
По запросу «{query}» ничего не найдено.
+
По запросу «{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 783fe58..9e5c5dc 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -1,4 +1,4 @@ -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'; @@ -28,6 +28,15 @@ const RECENT_MS = 10 * 60 * 1000; // лимита пишем предупреждение в лог, а не молча обрезаем. 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(); } @@ -158,7 +167,7 @@ async function indexApprovalsForType( try { if (!tokenEmail) return { rows: [], debug }; - const sp = spfi(type.siteUrl as string).using(SPFx(context)); + const sp = spForSite(type.siteUrl as string, context); const list = sp.web.getList(type.listId as string); const dataListInfo = await list.select('Title')(); @@ -171,15 +180,16 @@ async function indexApprovalsForType( debug.approvalListTitle = approvalListTitle; const approvalList = sp.web.lists.getByTitle(approvalListTitle); - // Approver/EMail eq '...' в $filter по Person-полю ненадёжен в SharePoint - // REST — резолвим email в числовой Id пользователя (как и везде в проекте, - // см. ensureUser) и фильтруем по ApproverId. + // TansuSiteApprover/EMail eq '...' в $filter по Person-полю ненадёжен в + // SharePoint REST — резолвим email в числовой Id пользователя (как и везде в + // проекте, см. ensureUser) и фильтруем по TansuSiteApproverId. // - // Status в $filter НЕ используем: подтверждено на практике — фильтр - // `Status eq 'На согласовании'` (кириллица в $filter) молча возвращает 0 - // строк, даже когда без него те же строки находятся с этим же значением - // Status. Поэтому фильтруем только по ApproverId (числовое значение, - // проблем нет), а Status сравниваем в JS после получения строк. + // 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); @@ -195,16 +205,23 @@ async function indexApprovalsForType( // точное, даже когда список кандидатов пришёл из Search. const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); const recentRows = (await approvalList.items - .filter(`ApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`) - .select('Id', 'TicketId', 'Status') + .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`) + .select('Id', 'TicketId', 'RequestStatus') .top(2000)()) as Array>; - const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList); + // Статусы уходят в 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.Status === REQUEST_STATUS.onApproval || r.Status === REQUEST_STATUS.onExecution + (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}` @@ -224,7 +241,7 @@ async function indexApprovalsForType( ); } - console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, Status: r.Status }))); + console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, RequestStatus: r.RequestStatus }))); debug.approvalRowCount = stepRows.length; // Дедуп по TicketId: при scope='involved' у одной заявки может найтись @@ -259,35 +276,38 @@ async function indexApprovalsForType( } } -// 'involved' — строки согласования текущего пользователя через Search, чтобы не -// сканировать список целиком (он растёт с каждой заявкой). +// Строки согласования текущего пользователя через Search, чтобы не сканировать +// список целиком (он растёт с каждой заявкой). // -// Ищем ОТОБРАЖАЕМЫМ ИМЕНЕМ СВОБОДНЫМ ТЕРМОМ, а не через managed property -// ApproverOWSUSER. Подтверждено диагностикой 2026-08-05 в этом тенанте: -// ApproverOWSUSER отдаёт null у всех строк (автосозданного свойства не было — -// нашлось только чужое ApprooverOWSUSER с опечаткой; созданное вручную и -// смапленное на ows_Approver так и не наполнилось), а вот свободный терм -// `"Павел Горбенко"` находит именно свои 12 строк из 27. То есть Person-поле в -// полнотекстовый индекс попадает как текст, просто через managed property не -// достаётся. +// Фильтр по согласующему — managed property MP_APPROVER. Имя нестандартное +// (с префиксом группы колонок сайта), см. комментарий к константе: «ожидаемого» +// ApproverOWSUSER в схеме поиска нет вовсе, из-за чего запросы по нему долго +// возвращали 0. +// +// statuses задаёт фильтр по статусу строки прямо в KQL — нужен для режима +// «чья сейчас очередь» (без текстового поиска), чтобы страницу резал СЕРВЕР: +// без него в выдачу попали бы все строки, где пользователь вообще есть в +// маршруте, и после отбрасывания неактивных в странице появлялись бы дыры. +// Пустой массив — не фильтровать (режим текстового поиска: там важна полнота). // // Search используется ТОЛЬКО чтобы сузить набор Id — TicketId и Status берём -// свежими через REST по этим Id, а не из индекса (индекс мог устареть). +// свежими через REST по этим Id, а не из индекса (индекс мог устареть, а на +// устаревшем статусе решение «моя очередь» строить нельзя). async function getInvolvedRowsViaSearch( context: WebPartContext, type: IRequestType, - approvalList: IList + approvalList: IList, + statuses: string[] = [] ): Promise>> { try { - // Кавычки сломали бы фразу в KQL — убираем (в ФИО их быть не должно, но - // значение приходит извне, поэтому не доверяем). - const displayName = (context.pageContext.user.displayName || '').replace(/"/g, ' ').trim(); - if (!displayName) { + const email = currentUserEmail(context); + if (!email) { console.warn( - `[Текущие заявки] «${type.name}»: у текущего пользователя нет отображаемого имени — Search по согласующему невозможен` + `[Текущие заявки] «${type.name}»: у текущего пользователя нет email — Search по согласующему невозможен` ); return []; } + const claimsLogin = `i:0#.f|membership|${email}`; const rootFolder = (await approvalList .select('RootFolder/ServerRelativeUrl') @@ -296,8 +316,13 @@ async function getInvolvedRowsViaSearch( if (!relUrl) return []; const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl}`; - const sp = spfi(type.siteUrl as string).using(SPFx(context)); - const kql = `Path:"${approvalListAbsUrl}*" AND "${displayName}"`; + 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, @@ -314,6 +339,7 @@ async function getInvolvedRowsViaSearch( 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} — остальные не попадут в выборку` @@ -323,7 +349,7 @@ async function getInvolvedRowsViaSearch( return (await approvalList.items .filter(ids.map((id) => `Id eq ${id}`).join(' or ')) - .select('Id', 'TicketId', 'Status') + .select('Id', 'TicketId', 'RequestStatus') .top(ids.length)()) as Array>; } catch (e) { console.error( @@ -360,7 +386,7 @@ async function hydrateAuthors( const type = typeById.get(requestTypeId); if (!type) return; try { - const sp = spfi(type.siteUrl as string).using(SPFx(context)); + 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 ')) @@ -462,7 +488,7 @@ async function loadByIds( 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 ')) @@ -483,7 +509,7 @@ async function loadByIds( // матчить название не нужно, отдаём как есть. async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestType): Promise { try { - const sp = spfi(type.siteUrl as string).using(SPFx(context)); + 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(); @@ -550,7 +576,7 @@ export async function searchOwnRequests( ): Promise { if (types.length === 0) return { rows: [], totalRows: 0 }; - const sp = spfi(types[0].siteUrl as string).using(SPFx(context)); + const sp = spForSite(types[0].siteUrl as string, context); const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`; const clauses = [ @@ -606,7 +632,7 @@ export async function filterPendingByQuery( const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id)); if (scopeTypes.length === 0) return []; - const sp = spfi(scopeTypes[0].siteUrl as string).using(SPFx(context)); + 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 ')})`, @@ -654,7 +680,7 @@ async function searchOwnAll( ): Promise { if (types.length === 0) return []; - const sp = spfi(types[0].siteUrl as string).using(SPFx(context)); + 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 ')})`, 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 b5dcae7..bc3d124 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 db40ca3..b10dc1b 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -39,6 +39,13 @@ const WorkplaceSetupView: React.FC = ({ context, itemI ); // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. + // + // decisionSeq входит в reloadKey: после решения согласующего строки маршрута + // меняются на сервере (своя — на «Согласовано», следующая — на «На + // согласовании»), поэтому счётчик инкрементируется и useMatrixEditor + // перечитывает матрицу. Иначе на экране остались бы данные, загруженные при + // открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании». + const [decisionSeq, setDecisionSeq] = useState(0); const approval = useMemo(() => workplaceApprovalService(), []); const loadApprovers = useCallback( () => @@ -47,7 +54,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( @@ -57,7 +64,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) ); @@ -138,7 +145,14 @@ const WorkplaceSetupView: React.FC = ({ context, itemI
- + { + 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 e9334ff..f0c7bc5 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 509a206..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) 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/usePersistedQuery.ts b/src/webparts/requestPortal/shared/usePersistedQuery.ts index f813bda..a997e3b 100644 --- a/src/webparts/requestPortal/shared/usePersistedQuery.ts +++ b/src/webparts/requestPortal/shared/usePersistedQuery.ts @@ -33,3 +33,29 @@ export function usePersistedQuery(key: string): [string, (value: string) => void 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; +} -- 2.34.1 From a86c8d54647979b083858023551526723ee6f2dc Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 6 Aug 2026 14:28:21 +0500 Subject: [PATCH 09/10] =?UTF-8?q?=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecruitingHire/approval/provisioning.json | 2 +- .../WorkplaceSetup/approval/provisioning.json | 2 +- .../requestPortal/shared/approval/index.ts | 1 + .../shared/approval/models/statuses.ts | 28 +++++++++++++++--- .../requestPortal/shared/usePersistedQuery.ts | 29 ++++++++++++++----- 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json index bc3d124..45a8b4f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingHire/approval/provisioning.json @@ -2,7 +2,7 @@ "dataListTitle": "Прием на работу — согласование", "columns": [ { "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] }, + { "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/approval/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json index f0c7bc5..e9b0968 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/approval/provisioning.json @@ -2,7 +2,7 @@ "dataListTitle": "Организация рабочего места — согласование", "columns": [ { "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" }, - { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Ждёт очереди", "Согласовано", "Отклонено", "Возвращена на доработку", "На исполнении", "Выполнено", "Выполнение", "Закрыта"] }, + { "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/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 b4461f2..8aa551c 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.ts @@ -43,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.queued, REQUEST_STATUS.approved, REQUEST_STATUS.rejected, REQUEST_STATUS.returned, - 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/usePersistedQuery.ts b/src/webparts/requestPortal/shared/usePersistedQuery.ts index a997e3b..65dff92 100644 --- a/src/webparts/requestPortal/shared/usePersistedQuery.ts +++ b/src/webparts/requestPortal/shared/usePersistedQuery.ts @@ -4,16 +4,31 @@ import * as React from 'react'; // того — это просто «продолжить с того же места», а не синхронизация между // вкладками/устройствами. 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(() => { - try { - return window.localStorage.getItem(storageKey) || ''; - } catch { - return ''; - } - }); + 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 => { -- 2.34.1 From bd3689cf14b74a3997ffee22a200a0460b9e633b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 6 Aug 2026 14:55:46 +0500 Subject: [PATCH 10/10] =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D1=8D=D1=88=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 11 ++- .../features/myRequests/RequestsTableView.tsx | 86 +++++++++++-------- .../features/myRequests/SearchView.tsx | 18 +++- .../features/myRequests/pageCache.ts | 43 ++++++++++ 4 files changed, 115 insertions(+), 43 deletions(-) create mode 100644 src/webparts/requestPortal/features/myRequests/pageCache.ts diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index d675a3b..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'; @@ -75,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 ( diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index ee191d9..db30b14 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -13,6 +13,7 @@ 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'; @@ -177,49 +178,58 @@ export const RequestsTableView: React.FC = ({ React.useEffect(() => { if (types === undefined) return; let cancelled = false; + const cacheKey = pageKey('table', [kind, debouncedQuery.trim(), page]); + + // Сразу показываем то, что уже загружали для ЭТОЙ вкладки (и этого запроса + // и страницы), а обновление идёт фоном. Компонент один на обе вкладки — + // без кеша при переключении на экране оставались бы строки предыдущей + // вкладки, пока не придёт ответ. Если для этой вкладки данных ещё нет — + // rows становится undefined, и показывается «Загрузка…». + const cached = readPage(cacheKey); + setRows(cached ? cached.rows : undefined); + setTotalRows(cached ? cached.totalRows : 0); + + 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 }; + }; const load = async (): Promise => { try { - if (kind === 'own') { - const hasOwnQuery = debouncedQuery.trim().length > 0; - const r = hasOwnQuery - ? await searchOwnRequests(context, types, debouncedQuery, page) - : await getOwnRequests(context, types, page); - if (cancelled) return; - setRows(r.rows); - setTotalRows(r.totalRows); - setError(''); - return; + 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); } - - const hasQuery = debouncedQuery.trim().length > 0; - - if (!hasQuery) { - // Без поиска — страница уже нарезана и дочитана сервером. - const r = await getPendingApproval(context, 'myTurn', page); - if (cancelled) return; - console.table(r.debug); - setRows(r.rows); - setTotalRows(r.totalRows); - setError(''); - if (onMyTurnCountChange) onMyTurnCountChange(r.totalRows); - return; - } - - // С поиском — сначала полный список «где я есть в маршруте» (без - // автора), фильтруем текстом, и только потом режем на страницы и - // дочитываем автора — только для видимой страницы. - const idx = await getPendingApprovalIndex(context, 'involved'); - if (cancelled) return; - console.table(idx.debug); - const filtered = await filterPendingByQuery(context, idx.types, idx.rows, debouncedQuery); - if (cancelled) return; - const pageRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - await hydratePendingAuthors(context, idx.types, pageRows); - if (cancelled) return; - setRows(pageRows); - setTotalRows(filtered.length); setError(''); + if (onMyTurnCountChange && kind === 'approvals' && debouncedQuery.trim().length === 0) { + onMyTurnCountChange(fresh.totalRows); + } } catch (e) { if (!cancelled) setError((e as Error).message); } diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index f20f2ef..063e403 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -5,6 +5,7 @@ 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'; @@ -140,13 +141,24 @@ export const SearchView: React.FC = ({ context, onOpen }) => { 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 r = await searchMyRequests(context, types, debouncedQuery, page); + const fresh = await searchMyRequests(context, types, debouncedQuery, page); if (cancelled) return; - setRows(r.rows); - setTotalRows(r.totalRows); + 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); 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(';')}`; +} -- 2.34.1