From 13833217d0b865bdf95e0069530abd8c233cd88a Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 6 Aug 2026 16:01:33 +0500 Subject: [PATCH] =?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