From bd5f567311dc36ed1acc3fd576d9869960d20946 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 6 Aug 2026 17:35:09 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=B5=D0=B1=D0=B0=D0=B3=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../requestPortal/RequestPortalWebPart.ts | 10 ++- .../features/myRequests/myRequests.service.ts | 27 ------ .../shared/approval/ui/ApprovalMatrix.tsx | 69 ++++++++------- .../shared/approval/ui/approval.module.scss | 48 +++++++---- .../shared/approval/useMatrixEditor.ts | 14 ++- .../ui/ErrorOverlay/ErrorOverlay.module.scss | 85 +++++++++++++++++++ .../shared/ui/ErrorOverlay/ErrorOverlay.tsx | 82 ++++++++++++++++++ src/webparts/requestPortal/shared/ui/index.ts | 1 + 8 files changed, 253 insertions(+), 83 deletions(-) create mode 100644 src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.module.scss create mode 100644 src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.tsx diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index f8973e9..c68f148 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -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 = React.createElement( - RequestPortal, - { + const element: React.ReactElement = React.createElement( + ErrorOverlay, + undefined, + React.createElement(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); diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 06b2d1d..3b56a79 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -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(); @@ -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(); diff --git a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx index 144dc23..49d12de 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ApprovalMatrix.tsx @@ -39,11 +39,12 @@ export const ApprovalMatrix: React.FC = ({ } }); - // Пока по маршруту не принято ни одного решения (заявка не отправлена на - // согласование — черновик, доработка), колонки «Статус» и «Дата» пустые у всех - // строк: показывать столбец из одних прочерков смысла нет. Как только - // появляется первое решение — колонки возвращаются. - 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 (
@@ -60,8 +61,8 @@ export const ApprovalMatrix: React.FC = ({ Шаг {roleLabel} Должность - {hasDecisions && Статус} - {hasDecisions && Дата} + {hasStatuses && Статус} + {hasDates && Дата} {editable && } {editable && } @@ -82,10 +83,36 @@ export const ApprovalMatrix: React.FC = ({ )} {r.who} {r.position} - {hasDecisions && ( + {hasStatuses && ( {r.status && r.status !== '—' ? : '—'} )} - {hasDecisions && {r.date}} + {hasDates && {r.date}} + {/* Перемещение действует на ШАГ целиком (параллельные строки + переезжают вместе), поэтому стрелки живут в отдельной + объединённой по вертикали ячейке: одна пара на весь шаг, + по центру его строк. */} + {editable && isBlockStart && ( + + + + + )} {editable && ( )} - {/* Перемещение — одно на весь шаг: параллельные строки - переезжают вместе, поэтому ячейка объединена по rowSpan. */} - {editable && isBlockStart && ( - - - - - )} ); })} diff --git a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss index 0b1df37..a4e4a66 100644 --- a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss @@ -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; -} - // Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая // заливка, чтобы блок читался как единое целое (границы ячейки «Шаг» его уже // объединяют, но без фона строки визуально распадаются). diff --git a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts index fb4d90f..5c5d0f0 100644 --- a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts +++ b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts @@ -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, diff --git a/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.module.scss b/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.module.scss new file mode 100644 index 0000000..97dc0c7 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.module.scss @@ -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; + } +} diff --git a/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.tsx b/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.tsx new file mode 100644 index 0000000..76bfd9c --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.tsx @@ -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, 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 ( +
+
+
Необработанное исключение
+
{error.source}
+
{fullText}
+
+ + +
+
+
+ ); + } +} diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index 2c43849..a967658 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -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';