From 13833217d0b865bdf95e0069530abd8c233cd88a Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 6 Aug 2026 16:01:33 +0500 Subject: [PATCH 1/3] =?UTF-8?q?=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.module.scss | 8 ++ .../components/RequestPortal.tsx | 78 ++++++++--- .../features/myRequests/RequestsTableView.tsx | 13 ++ .../features/myRequests/SearchView.tsx | 11 ++ .../features/submit/SubmitFeature.tsx | 11 +- .../RecruitingHire/RecruitingHireForm.tsx | 9 +- .../RecruitingHire/RecruitingHireView.tsx | 11 +- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 9 +- .../shared/approval/approvalList.service.ts | 7 +- .../approval/approvalSubmission.service.ts | 13 +- .../shared/approval/models/IMatrixRow.ts | 6 + .../shared/approval/ui/ApprovalMatrix.tsx | 132 ++++++++++++------ .../shared/approval/ui/approval.module.scss | 48 +++++++ .../shared/approval/useMatrixEditor.ts | 120 ++++++++++++++-- .../requestPortal/shared/unsavedChanges.ts | 33 +++++ 15 files changed, 426 insertions(+), 83 deletions(-) create mode 100644 src/webparts/requestPortal/shared/unsavedChanges.ts diff --git a/src/webparts/requestPortal/components/RequestPortal.module.scss b/src/webparts/requestPortal/components/RequestPortal.module.scss index 660e246..73d0694 100644 --- a/src/webparts/requestPortal/components/RequestPortal.module.scss +++ b/src/webparts/requestPortal/components/RequestPortal.module.scss @@ -73,6 +73,14 @@ margin-bottom: $sp-6; } +// Раздел «Новая заявка», когда открыта другая вкладка. Именно скрытие, а не +// размонтирование: заполненная форма должна пережить переключение вкладок +// (см. RequestPortal.tsx). display:none выводит содержимое и из потока, и из +// последовательности табуляции — скрытая форма не ловит фокус с клавиатуры. +.hiddenPane { + display: none; +} + // На узких экранах рейл уходит наверх. @media (max-width: 900px) { .layout { diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 5a7cb66..df2c932 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -4,6 +4,7 @@ import type { IRequestPortalProps } from './IRequestPortalProps'; import { IconSprite } from '../shared/ui/Icon/IconSprite'; import { navigate, useRoute, NavSection } from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; +import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { SideNav } from './SideNav/SideNav'; import { Drawer } from './Drawer/Drawer'; import { CategoryTabs } from './CategoryTabs/CategoryTabs'; @@ -68,13 +69,28 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // Переход «создать на основе» (в т.ч. из открытой заявки в панели): переключаем // вкладку категорий на категорию целевой формы, чтобы она совпала с открытой формой. + // Форма при этом пересоздаётся (см. key у SubmitFeature), поэтому прежние + // несохранённые правки к ней уже не относятся — снимаем флаг. React.useEffect(() => { if (route.mode === 'create' && route.formKey) { const found = REQUEST_TYPES.filter((r) => r.id === route.formKey)[0]; if (found) setActiveCategoryId(found.categoryId); + markFormClean(); } }, [route.mode, route.formKey]); + // Сохранение (в том числе черновика) шлёт rp-requests-changed — после него + // терять нечего, предупреждать при «создать на основе» больше не о чем. + React.useEffect(() => onRequestsChanged(markFormClean), []); + + // Смена категории закрывает открытую форму (SubmitFeature сбрасывает выбор, + // когда категория перестаёт совпадать) — значит введённое пропадёт. + const changeCategory = (categoryId: string): void => { + if (!confirmDiscardFormChanges()) return; + markFormClean(); + setActiveCategoryId(categoryId); + }; + const goto = (nav: NavSection): void => { // Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект // загрузки в таблице не перезапустится сам. Шлём событие обновления — @@ -100,26 +116,46 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context
- {route.nav === 'new' ? ( - <> -
- -
- +
+ - - ) : route.nav === 'search' ? ( +
+ + + + {route.nav === 'search' && ( @@ -131,7 +167,9 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context }) } /> - ) : ( + )} + + {(route.nav === 'approvals' || route.nav === 'own') && ( = ({ 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]); diff --git a/src/webparts/requestPortal/features/myRequests/SearchView.tsx b/src/webparts/requestPortal/features/myRequests/SearchView.tsx index 063e403..a26e2ef 100644 --- a/src/webparts/requestPortal/features/myRequests/SearchView.tsx +++ b/src/webparts/requestPortal/features/myRequests/SearchView.tsx @@ -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 = ({ 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]); diff --git a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx index b7e773c..a28ad53 100644 --- a/src/webparts/requestPortal/features/submit/SubmitFeature.tsx +++ b/src/webparts/requestPortal/features/submit/SubmitFeature.tsx @@ -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 = ({ 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); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index ece4b12..2b80a4b 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -68,7 +68,14 @@ const RecruitingHireForm: React.FC = ({ requestType, context, isValid ? undefined : 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.'; const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => - rows.map((r) => ({ email: r.email, position: r.position, allowReassign: r.allowReassign })); + // order обязателен: параллельные шаги делят один номер, по позиции в + // массиве его не восстановить (см. useMatrixEditor/withOrders). + rows.map((r) => ({ + email: r.email, + position: r.position, + allowReassign: r.allowReassign, + order: r.order, + })); const save = (status: string): (() => Promise) => async (): Promise => { const id = await saveHireRequest( diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 20c4f63..17ac0cc 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -15,6 +15,7 @@ import { navigate } from '../../shared/routing'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { loadHireRequest } from './load'; +import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; import { hireApprovalService, DATA_LIST_TITLE } from './submit'; import { ApplicationSection } from './sections/ApplicationSection'; import { CandidateSection } from './sections/CandidateSection'; @@ -121,9 +122,13 @@ const RecruitingHireView: React.FC = ({ context, itemI
+ + + )} {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'; -- 2.34.1