Параллельные шаги (#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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user