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; +}