Параллельные шаги (#3)

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-06 12:36:22 +00:00
parent 40f9ac6c03
commit df9d12622d
24 changed files with 967 additions and 175 deletions
@@ -0,0 +1,51 @@
import * as React from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from '../approve/ApproveFeature.module.scss';
import { getFormComponent } from '../../forms/registry';
import { REQUEST_TYPES } from '../../data/requests';
import { emitRequestsChanged } from '../../shared/requestEvents';
export interface IEditFeatureProps {
context: WebPartContext;
/** Ключ формы (тип заявки) из URL. */
formKey: string;
/** RowID редактируемой заявки из URL. */
itemId: number;
/** Закрыть панель (после сохранения). */
onClose: () => void;
}
// Фича «Редактирование»: открывает СВОЮ незавершённую заявку (черновик,
// возвращённую, отклонённую) в той же выезжающей панели, где обычно показывается
// просмотр. Рендерит ту же форму, что и подача, но с editId — форма подгружает
// существующую запись и сохраняет в неё же.
//
// Отдельно от features/submit намеренно: там форма живёт в потоке «плитки →
// новая заявка», а тут — точечное открытие конкретной записи по ссылке.
const EditFeature: React.FC<IEditFeatureProps> = ({ context, formKey, itemId, onClose }) => {
const FormComponent = getFormComponent(formKey);
const requestType = REQUEST_TYPES.filter((r) => r.id === formKey)[0];
if (!FormComponent || !requestType) {
return <div className={styles.state}>Для формы «{formKey}» редактирование не настроено.</div>;
}
return (
<div className={styles.content}>
<React.Suspense fallback={<div className={styles.state}>Загрузка формы…</div>}>
<FormComponent
requestType={requestType}
context={context}
editId={itemId}
onCancel={onClose}
onSaved={() => {
emitRequestsChanged();
onClose();
}}
/>
</React.Suspense>
</div>
);
};
export default EditFeature;
@@ -19,6 +19,13 @@ import styles from './RequestsTableView.module.scss';
const PAGE_SIZE = 20;
// Автообновление открытых таблиц: раз в 10 минут перечитываем данные, чтобы
// вкладка, оставленная открытой надолго, не показывала устаревший список.
// Обновление фоновое — строки с экрана не пропадают и перерисовываются, только
// если данные реально изменились (см. pageSignature).
const AUTO_REFRESH_MS = 10 * 60 * 1000;
export interface IRequestsTableViewProps {
context: WebPartContext;
/** 'approvals' — ждут решения текущего пользователя; 'own' — созданные им. */
@@ -239,9 +246,15 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
const unsubscribe = onRequestsChanged(() => {
load().catch(() => undefined);
});
// Пока вкладка браузера скрыта, обновляться незачем — при возврате к ней
// сработает ближайший тик.
const timer = window.setInterval(() => {
if (!document.hidden) load().catch(() => undefined);
}, AUTO_REFRESH_MS);
return () => {
cancelled = true;
unsubscribe();
window.clearInterval(timer);
};
}, [context, types, kind, debouncedQuery, page]);
@@ -11,6 +11,13 @@ import styles from './SearchView.module.scss';
const PAGE_SIZE = 20;
// Автообновление открытых таблиц: раз в 10 минут перечитываем данные, чтобы
// вкладка, оставленная открытой надолго, не показывала устаревший список.
// Обновление фоновое — строки с экрана не пропадают и перерисовываются, только
// если данные реально изменились (см. pageSignature).
const AUTO_REFRESH_MS = 10 * 60 * 1000;
export interface ISearchViewProps {
context: WebPartContext;
/** Клик по строке — открыть заявку (в выезжающей панели). */
@@ -169,9 +176,13 @@ export const SearchView: React.FC<ISearchViewProps> = ({ context, onOpen }) => {
const unsubscribe = onRequestsChanged(() => {
load().catch(() => undefined);
});
const timer = window.setInterval(() => {
if (!document.hidden) load().catch(() => undefined);
}, AUTO_REFRESH_MS);
return () => {
cancelled = true;
unsubscribe();
window.clearInterval(timer);
};
}, [context, types, debouncedQuery, page]);
@@ -28,6 +28,11 @@ const RECENT_MS = 10 * 60 * 1000;
// лимита пишем предупреждение в лог, а не молча обрезаем.
const SEARCH_ROW_LIMIT = 500;
// Сколько последних заявок автора дочитываем через REST в довесок к Search
// в разделе «Мои заявки» (см. getFreshOwnRowsForType). Это записи ОДНОГО
// пользователя, поэтому лимит с запасом; при упоре в него пишем предупреждение.
const OWN_REST_TOP = 200;
// Managed properties списка согласования. Имена включают группу колонок сайта
// (TansuSite) — SharePoint генерирует их так, поэтому «ожидаемого»
// ApproverOWSUSER в схеме поиска не находилось (разбирались 2026-08-06).
@@ -115,9 +120,6 @@ export async function getPendingApproval(
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 };
@@ -223,9 +225,6 @@ async function indexApprovalsForType(
r.RequestStatus === REQUEST_STATUS.onApproval ||
r.RequestStatus === REQUEST_STATUS.onExecution
);
console.log(
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=myTurn — свежих (REST, < 10 мин): ${recentRows.length}, через Search: ${searchRows.length}, после фильтра по статусу: ${stepRows.length}`
);
} else {
// 'involved' — используется ТОЛЬКО для текстового поиска (см.
// getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная
@@ -235,13 +234,8 @@ async function indexApprovalsForType(
// зависимости от того, успел ли Search её проиндексировать к моменту
// REST-окна).
stepRows = await getInvolvedRowsViaSearch(context, type, approvalList);
console.log(
`[Текущие заявки] «${type.name}»: список согласования «${approvalListTitle}», approver.Id=${approver.Id}, scope=involved (Search) — найдено: ${stepRows.length}`
);
}
console.table(stepRows.map((r) => ({ Id: r.Id, TicketId: r.TicketId, RequestStatus: r.RequestStatus })));
debug.approvalRowCount = stepRows.length;
// Дедуп по TicketId: при scope='involved' у одной заявки может найтись
@@ -336,10 +330,6 @@ async function getInvolvedRowsViaSearch(
.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} — остальные не попадут в выборку`
@@ -513,14 +503,33 @@ async function getFreshOwnRowsForType(context: WebPartContext, type: IRequestTyp
const me = await sp.web.currentUser.select('Id')();
const cutoff = new Date(Date.now() - RECENT_MS).toISOString();
// Фильтруем только по AuthorId, а «свежая или черновик» решаем в JS: статус
// в $filter не используем, потому что кириллица в сравнении там уже подводила
// (см. indexApprovalsForType). Берём последние OWN_REST_TOP заявок автора —
// это его собственные записи, их немного, и запрос ограничен по объёму.
const rows = (await sp.web
.getList(type.listId as string)
.items.filter(`AuthorId eq ${me.Id} and Created ge '${cutoff}'`)
.items.filter(`AuthorId eq ${me.Id}`)
.select('Id', 'Title', 'Status', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(50)()) as Array<Record<string, unknown>>;
.orderBy('Created', false)
.top(OWN_REST_TOP)()) as Array<Record<string, unknown>>;
return rows.map((r) => toMyRequest(r, r.Author as { EMail?: string; Title?: string } | undefined, type, 'own'));
if (rows.length === OWN_REST_TOP) {
console.warn(
`[Текущие заявки] «${type.name}»: REST-довесок упёрся в ${OWN_REST_TOP} заявок — более старые черновики сюда не попадут`
);
}
// Черновик берём ЛЮБОГО возраста: он живёт только у автора, правится часто и
// именно по нему индекс Search отстаёт заметнее всего, а увидеть свой
// черновик в «Моих заявках» пользователь ожидает сразу.
return rows
.filter((r) => {
const created = (r.Created as string) || '';
return r.Status === REQUEST_STATUS.draft || created >= cutoff;
})
.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 [];
@@ -551,12 +560,6 @@ export async function getOwnRequests(
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))
@@ -597,8 +600,6 @@ export async function searchOwnRequests(
SortList: [{ Property: 'Created', Direction: 1 }],
});
console.log(`[Текущие заявки] Search «Мои заявки»: KQL = ${kql}, всего найдено: ${results.TotalRows}`);
console.table(results.PrimarySearchResults);
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
@@ -646,8 +647,6 @@ export async function filterPendingByQuery(
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}`));
@@ -697,8 +696,6 @@ async function searchOwnAll(
TrimDuplicates: false,
});
console.log(`[Текущие заявки] Search «Поиск» (мои заявки): KQL = ${kql}, найдено: ${results.TotalRows}`);
console.table(results.PrimarySearchResults);
const entries = hitsToEntries(results, types);
const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map<string, IMyRequest>();
@@ -8,6 +8,7 @@ import { getRequestsByCategory, REQUEST_TYPES } from '../../data/requests';
import { getFormComponent } from '../../forms/registry';
import { IRequestType } from '../../models/types';
import { emitRequestsChanged } from '../../shared/requestEvents';
import { confirmDiscardFormChanges, markFormClean } from '../../shared/unsavedChanges';
export interface ISubmitFeatureProps {
context: WebPartContext;
@@ -43,7 +44,15 @@ const SubmitFeature: React.FC<ISubmitFeatureProps> = ({ context, activeCategoryI
const requests = getRequestsByCategory(activeCategoryId);
const handleBack = (): void => setSelectedRequest(null);
// Возврат к плиткам закрывает форму и теряет введённое (раздел остаётся
// смонтированным, но состояние формы живёт в самом FormComponent). Спрашиваем,
// если правки есть и они не сохранены; после согласия флаг снимаем — терять
// уже нечего.
const handleBack = (): void => {
if (!confirmDiscardFormChanges()) return;
markFormClean();
setSelectedRequest(null);
};
const renderSelected = (request: IRequestType): React.ReactNode => {
const FormComponent = getFormComponent(request.id);