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

Merged
gorbenko.pavel merged 3 commits from parallel-flow into main 2026-08-06 12:36:22 +00:00
8 changed files with 253 additions and 83 deletions
Showing only changes of commit bd5f567311 - Show all commits
@@ -12,6 +12,7 @@ import * as strings from 'RequestPortalWebPartStrings';
import RequestPortal from './components/RequestPortal';
import { IRequestPortalProps } from './components/IRequestPortalProps';
import { initSP } from './shared/pnp/pnpjs';
import { ErrorOverlay } from './shared/ui';
export interface IRequestPortalWebPartProps {
description: string;
@@ -23,16 +24,17 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
private _environmentMessage: string = '';
public render(): void {
const element: React.ReactElement<IRequestPortalProps> = React.createElement(
RequestPortal,
{
const element: React.ReactElement = React.createElement(
ErrorOverlay,
undefined,
React.createElement<IRequestPortalProps>(RequestPortal, {
description: this.properties.description,
isDarkTheme: this._isDarkTheme,
environmentMessage: this._environmentMessage,
hasTeamsContext: !!this.context.sdks.microsoftTeams,
userDisplayName: this.context.pageContext.user.displayName,
context: this.context
}
})
);
ReactDom.render(element, this.domElement);
@@ -120,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 };
@@ -228,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) — здесь важна не мгновенная
@@ -240,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' у одной заявки может найтись
@@ -341,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} — остальные не попадут в выборку`
@@ -575,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))
@@ -621,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>();
@@ -670,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}`));
@@ -721,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>();
@@ -39,11 +39,12 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
}
});
// Пока по маршруту не принято ни одного решения (заявка не отправлена на
// согласование — черновик, доработка), колонки «Статус» и «Дата» пустые у всех
// строк: показывать столбец из одних прочерков смысла нет. Как только
// появляется первое решение — колонки возвращаются.
const hasDecisions = rows.some((r) => (r.status && r.status !== '—') || r.date);
// Пустые колонки не показываем — столбец из одних прочерков только шумит.
// Условия РАЗНЫЕ, потому что заполняются эти поля в разные моменты: статус
// есть у строки сразу (у черновика — «Черновик»), а дата появляется только
// вместе с решением согласующего. У черновика поэтому виден статус, но не дата.
const hasStatuses = rows.some((r) => r.status && r.status !== '—');
const hasDates = rows.some((r) => !!r.date);
return (
<div className={styles.matrixWrap}>
@@ -60,8 +61,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
<th className={styles.matrixStepCol}>Шаг</th>
<th>{roleLabel}</th>
<th>Должность</th>
{hasDecisions && <th>Статус</th>}
{hasDecisions && <th>Дата</th>}
{hasStatuses && <th>Статус</th>}
{hasDates && <th>Дата</th>}
{editable && <th />}
{editable && <th />}
</tr>
@@ -82,10 +83,36 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
)}
<td>{r.who}</td>
<td>{r.position}</td>
{hasDecisions && (
{hasStatuses && (
<td>{r.status && r.status !== '—' ? <StatusBadge status={r.status} /> : '—'}</td>
)}
{hasDecisions && <td>{r.date}</td>}
{hasDates && <td>{r.date}</td>}
{/* Перемещение действует на ШАГ целиком (параллельные строки
переезжают вместе), поэтому стрелки живут в отдельной
объединённой по вертикали ячейке: одна пара на весь шаг,
по центру его строк. */}
{editable && isBlockStart && (
<td className={styles.matrixMoveCol} rowSpan={block.size}>
<button
type="button"
className={styles.iconBtn}
disabled={block.index === 0}
onClick={() => editor.moveBlockUp(i)}
title="Шаг выше"
>
<Icon name="chevron-down" size="sm" className={styles.flipY} />
</button>
<button
type="button"
className={styles.iconBtn}
disabled={block.index === blocks.length - 1}
onClick={() => editor.moveBlockDown(i)}
title="Шаг ниже"
>
<Icon name="chevron-down" size="sm" />
</button>
</td>
)}
{editable && (
<td className={styles.matrixActions}>
<button
@@ -111,30 +138,6 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
</button>
</td>
)}
{/* Перемещение — одно на весь шаг: параллельные строки
переезжают вместе, поэтому ячейка объединена по rowSpan. */}
{editable && isBlockStart && (
<td className={styles.matrixBlockMove} rowSpan={block.size}>
<button
type="button"
className={styles.iconBtn}
disabled={block.index === 0}
onClick={() => editor.moveBlockUp(i)}
title="Шаг выше"
>
<Icon name="chevron-down" size="sm" className={styles.flipY} />
</button>
<button
type="button"
className={styles.iconBtn}
disabled={block.index === blocks.length - 1}
onClick={() => editor.moveBlockDown(i)}
title="Шаг ниже"
>
<Icon name="chevron-down" size="sm" />
</button>
</td>
)}
</tr>
);
})}
@@ -113,17 +113,43 @@
}
}
// ВАЖНО: не display:flex — это сняло бы с ячейки display:table-cell, и она
// перестала бы вставать в строку рядом с соседней (кнопки уезжали на вторую
// строку). Ячейка остаётся табличной, кнопки держатся в строке через nowrap.
.matrixActions {
display: flex;
align-items: center;
gap: $sp-1;
justify-content: flex-end;
white-space: nowrap;
text-align: right;
vertical-align: middle;
// Отступ между любыми соседними элементами: кроме кнопок тут бывают распорки
// (см. .iconBtnSpacer), на которые селектор .iconBtn + .iconBtn не сработал бы.
> * + * {
margin-left: $sp-1;
}
}
// Стрелки перемещения шага. Ячейка объединена по вертикали (rowSpan) на все
// строки шага, поэтому пара кнопок стоит по центру блока, а не у его первой
// строки. Как и .matrixActions — остаётся табличной ячейкой (см. комментарий
// там), кнопки держатся в строке через nowrap.
.matrixMoveCol {
width: 1%; // ужимает колонку по содержимому, ширину забирают текстовые колонки
white-space: nowrap;
text-align: right;
vertical-align: middle;
> * + * {
margin-left: $sp-1;
}
}
.iconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
// Инлайновые кнопки иначе выравниваются по baseline и «прыгают» относительно
// кнопок соседней ячейки.
vertical-align: middle;
width: 26px;
height: 26px;
padding: 0;
@@ -180,20 +206,6 @@
color: $navy-700;
}
// Перемещение шага целиком — тоже объединённая по вертикали ячейка, поэтому
// кнопки центрируем относительно всего блока.
// Та же flex-раскладка, что и у .matrixActions — иначе кнопки здесь (обычные
// инлайновые элементы) выравниваются по baseline и «прыгают» относительно
// кнопок соседней колонки, которая уже flex-контейнер.
.matrixBlockMove {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $sp-1;
width: 64px;
white-space: nowrap;
}
// Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая
// заливка, чтобы блок читался как единое целое (границы ячейки «Шаг» его уже
// объединяют, но без фона строки визуально распадаются).
@@ -69,6 +69,18 @@ function withOrders(rows: IMatrixRow[]): IMatrixRow[] {
});
}
// Статус для ТОЛЬКО ЧТО ДОБАВЛЕННОЙ строки. Если у всех уже имеющихся строк
// статус одинаковый (маршрут целиком в одном состоянии — например, черновик),
// новая получает тот же: при сохранении writeRoute всё равно проставит всем
// строкам общий статус, и показывать «—» рядом с «Черновик» было бы враньём.
// Если статусы разные (возвращённая заявка, часть шагов уже с решениями) —
// честнее оставить «—»: в какое состояние попадёт новый шаг, ещё не решено.
function statusForNewRow(rows: IMatrixRow[]): string {
if (rows.length === 0) return '—';
const first = rows[0].status || '—';
return rows.every((r) => (r.status || '—') === first) ? first : '—';
}
// Обратное преобразование для загруженных строк: одинаковый order у соседей
// означает параллельный шаг.
function markParallel(rows: IMatrixRow[]): IMatrixRow[] {
@@ -138,7 +150,7 @@ export function useMatrixEditor(
who: user.name,
email: user.email,
position,
status: '—',
status: statusForNewRow(prev),
date: '',
comment: '',
allowReassign: false,
@@ -0,0 +1,85 @@
@import '../../../../../styles/design-tokens';
// Поверх всего, включая SPFx-хром вебпарта — иначе падение внутри вложенного
// блока схлопывает только его, а не видно, что вообще произошло.
.overlay {
position: fixed;
inset: 0;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
padding: $sp-6;
background: rgba($navy-950, .78);
font-family: $font-sans;
}
.card {
box-sizing: border-box;
width: 100%;
max-width: 780px;
max-height: 80vh;
display: flex;
flex-direction: column;
padding: $sp-6;
border-radius: $r-lg;
background: $white;
box-shadow: $sh-modal;
border-top: 4px solid $red-500;
}
.title {
font-family: $font-serif;
font-size: $fs-form-title;
font-weight: 600;
color: $navy-950;
}
.source {
margin-top: $sp-1;
font-size: $fs-caption;
color: $gray-500;
}
.stack {
box-sizing: border-box;
margin: $sp-4 0 0;
padding: $sp-3;
overflow: auto;
background: $gray-50;
border: 1px solid $gray-200;
border-radius: $r-sm;
font-family: 'Consolas', 'Menlo', monospace;
font-size: $fs-caption;
color: $gray-900;
white-space: pre-wrap;
word-break: break-word;
}
.actions {
display: flex;
gap: $sp-2;
margin-top: $sp-4;
}
.copyBtn {
@include btn-accent;
padding: 9px 16px;
border-radius: $r-sm;
font-size: $fs-sm;
cursor: pointer;
}
.reloadBtn {
padding: 9px 16px;
border: 1px solid $gray-300;
border-radius: $r-sm;
background: $white;
color: $navy-800;
font-size: $fs-sm;
cursor: pointer;
&:hover {
border-color: $gold-400;
}
}
@@ -0,0 +1,82 @@
import * as React from 'react';
import styles from './ErrorOverlay.module.scss';
interface ICaughtError {
message: string;
stack: string;
source: string; // откуда поймана: React-рендер, window.onerror, unhandledrejection
}
interface IErrorOverlayState {
error: ICaughtError | undefined;
}
function formatError(err: unknown, source: string): ICaughtError {
const e = err instanceof Error ? err : new Error(typeof err === 'string' ? err : JSON.stringify(err));
return { message: e.message, stack: e.stack || '(нет стека)', source };
}
// Копия message+stack в буфер обмена, чтобы кинуть разработчику. Ошибка
// самого копирования не должна маскировать исходную — просто игнорируем.
function copyToClipboard(text: string): void {
navigator.clipboard.writeText(text).catch(() => undefined);
}
/**
* Ловит падения, которые иначе схлопывают вебпарт в SPFx workbench:
* 1) React-рендер (componentDidCatch);
* 2) синхронные ошибки вне React (window.onerror);
* 3) необработанные rejected promise (unhandledrejection).
* Вместо пустого места рисует поверх всего оверлей с текстом ошибки и кнопкой
* «Скопировать», чтобы можно было переслать стек трейс.
*/
export class ErrorOverlay extends React.Component<React.PropsWithChildren<unknown>, IErrorOverlayState> {
public state: IErrorOverlayState = { error: undefined };
private onWindowError = (event: ErrorEvent): void => {
this.setState({ error: formatError(event.error || event.message, 'window.onerror') });
};
private onUnhandledRejection = (event: PromiseRejectionEvent): void => {
this.setState({ error: formatError(event.reason, 'unhandledrejection') });
};
public componentDidMount(): void {
window.addEventListener('error', this.onWindowError);
window.addEventListener('unhandledrejection', this.onUnhandledRejection);
}
public componentWillUnmount(): void {
window.removeEventListener('error', this.onWindowError);
window.removeEventListener('unhandledrejection', this.onUnhandledRejection);
}
public static getDerivedStateFromError(err: unknown): IErrorOverlayState {
return { error: formatError(err, 'React render') };
}
public render(): React.ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
const fullText = `[${error.source}] ${error.message}\n\n${error.stack}`;
return (
<div className={styles.overlay}>
<div className={styles.card}>
<div className={styles.title}>Необработанное исключение</div>
<div className={styles.source}>{error.source}</div>
<pre className={styles.stack}>{fullText}</pre>
<div className={styles.actions}>
<button type="button" className={styles.copyBtn} onClick={() => copyToClipboard(fullText)}>
Скопировать
</button>
<button type="button" className={styles.reloadBtn} onClick={() => window.location.reload()}>
Перезагрузить страницу
</button>
</div>
</div>
</div>
);
}
}
@@ -19,6 +19,7 @@ export { PeopleField } from './PeopleField/PeopleField';
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
export { FormModeProvider, useFormReadOnly } from './FormMode';
export { Icon } from './Icon/Icon';
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { SubmitActions } from './SubmitActions/SubmitActions';
export { RequestNumber } from './RequestNumber/RequestNumber';
export { PrintButton } from './PrintButton/PrintButton';