From df9d12622d62a7652410fd4e9e7dceb60d534283 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 6 Aug 2026 12:36:22 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D0=B0=D0=BB=D0=BB=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=88=D0=B0=D0=B3=D0=B8=20(#3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/3 --- .../requestPortal/RequestPortalWebPart.ts | 10 +- .../components/RequestPortal.module.scss | 8 + .../components/RequestPortal.tsx | 134 +++++++++++----- .../features/edit/EditFeature.tsx | 51 +++++++ .../features/myRequests/RequestsTableView.tsx | 13 ++ .../features/myRequests/SearchView.tsx | 11 ++ .../features/myRequests/myRequests.service.ts | 57 ++++--- .../features/submit/SubmitFeature.tsx | 11 +- .../RecruitingHire/RecruitingHireForm.tsx | 103 +++++++++++-- .../RecruitingHire/RecruitingHireView.tsx | 11 +- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 103 +++++++++++-- src/webparts/requestPortal/models/types.ts | 7 + .../shared/approval/approvalList.service.ts | 38 ++++- .../approval/approvalSubmission.service.ts | 13 +- .../requestPortal/shared/approval/index.ts | 2 +- .../shared/approval/models/IMatrixRow.ts | 6 + .../shared/approval/ui/ApprovalMatrix.tsx | 135 +++++++++++----- .../shared/approval/ui/approval.module.scss | 68 ++++++++- .../shared/approval/useMatrixEditor.ts | 144 ++++++++++++++++-- src/webparts/requestPortal/shared/routing.ts | 16 +- .../ui/ErrorOverlay/ErrorOverlay.module.scss | 85 +++++++++++ .../shared/ui/ErrorOverlay/ErrorOverlay.tsx | 82 ++++++++++ src/webparts/requestPortal/shared/ui/index.ts | 1 + .../requestPortal/shared/unsavedChanges.ts | 33 ++++ 24 files changed, 967 insertions(+), 175 deletions(-) create mode 100644 src/webparts/requestPortal/features/edit/EditFeature.tsx create mode 100644 src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.module.scss create mode 100644 src/webparts/requestPortal/shared/ui/ErrorOverlay/ErrorOverlay.tsx create mode 100644 src/webparts/requestPortal/shared/unsavedChanges.ts 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/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..ae54c0a 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'; @@ -11,9 +12,12 @@ import { CATEGORIES, DEFAULT_CATEGORY_ID } from '../data/categories'; import { REQUEST_TYPES } from '../data/requests'; import SubmitFeature from '../features/submit/SubmitFeature'; import ApproveFeature from '../features/approve/ApproveFeature'; +import EditFeature from '../features/edit/EditFeature'; import { RequestsTableView } from '../features/myRequests/RequestsTableView'; import { SearchView } from '../features/myRequests/SearchView'; import { getPendingApproval } from '../features/myRequests/myRequests.service'; +import { IMyRequest } from '../features/myRequests/models/IMyRequest'; +import { isEditableStatus } from '../shared/approval'; // Оболочка портала: тёмное боковое меню (разделы) + основная область. // Логики фич тут нет — файл общий, меняется редко. @@ -68,13 +72,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 => { // Клик по УЖЕ активному пункту меню не меняет маршрут, значит эффект // загрузки в таблице не перезапустится сам. Шлём событие обновления — @@ -83,8 +102,28 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); navigate({ mode: 'submit', nav }); }; + // Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик, + // возвращённая, отклонённая) открывается на редактирование, всё остальное — + // на просмотр: чужую заявку править нельзя, а свою уже ушедшую по маршруту — + // тем более (её статус в isEditableStatus не входит). + const openRequest = (request: IMyRequest): void => + navigate({ + mode: request.kind === 'own' && isEditableStatus(request.status) ? 'edit' : 'approve', + nav: route.nav, + formKey: request.formKey, + itemId: request.id, + }); + const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav }); + // Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт + // только в памяти — уходя без сохранения, пользователь теряет правки. + const closeEditDrawer = (): void => { + if (!confirmDiscardFormChanges()) return; + markFormClean(); + closeDrawer(); + }; + return (
@@ -100,49 +139,57 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context
- {route.nav === 'new' ? ( - <> -
- -
- +
+ - - ) : route.nav === 'search' ? ( - + - navigate({ - mode: 'approve', - nav: route.nav, - formKey: request.formKey, - itemId: request.id, - }) + activeCategoryId={activeCategoryId} + initialForm={ + route.mode === 'create' && route.formKey && route.sourceId + ? { formKey: route.formKey, sourceId: route.sourceId } + : undefined } /> - ) : ( +
+ + {route.nav === 'search' && ( + + )} + + {(route.nav === 'approvals' || route.nav === 'own') && ( - navigate({ - mode: 'approve', - nav: route.nav, - formKey: request.formKey, - itemId: request.id, - }) - } + onOpen={openRequest} onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined} /> )} @@ -154,6 +201,21 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context )} + + {/* Своя незавершённая заявка — та же панель, но с формой. Ввод здесь тоже + помечает состояние изменённым, поэтому обёртка ловит input/change. */} + {route.mode === 'edit' && route.formKey && route.itemId && ( + +
+ +
+
+ )}
); }; diff --git a/src/webparts/requestPortal/features/edit/EditFeature.tsx b/src/webparts/requestPortal/features/edit/EditFeature.tsx new file mode 100644 index 0000000..e721636 --- /dev/null +++ b/src/webparts/requestPortal/features/edit/EditFeature.tsx @@ -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 = ({ context, formKey, itemId, onClose }) => { + const FormComponent = getFormComponent(formKey); + const requestType = REQUEST_TYPES.filter((r) => r.id === formKey)[0]; + + if (!FormComponent || !requestType) { + return
Для формы «{formKey}» редактирование не настроено.
; + } + + return ( +
+ Загрузка формы…
}> + { + emitRequestsChanged(); + onClose(); + }} + /> + + + ); +}; + +export default EditFeature; diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx index db30b14..935db89 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.tsx @@ -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 = ({ 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/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 9e5c5dc..3b56a79 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -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>; + .orderBy('Created', false) + .top(OWN_REST_TOP)()) as Array>; - 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(); @@ -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(); 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..e3e435c 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import styles from './RecruitingHireForm.module.scss'; import { Alert, @@ -20,21 +20,26 @@ import { getPlannedMatrix, REQUEST_STATUS, isEditableStatus, - IApproverToWrite + IApproverToWrite, + APPROVAL_PHASE, + findUnassignedStep } from '../../shared/approval'; import { uploadAttachments } from '../../shared/attachments/attachments.service'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { saveHireRequest, hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE } from './submit'; +import { loadHireRequest } from './load'; import { ApplicationSection } from './sections/ApplicationSection'; import { CandidateSection } from './sections/CandidateSection'; import { PpeSection } from './sections/PpeSection'; import { ConditionsSection } from './sections/ConditionsSection'; -const RecruitingHireForm: React.FC = ({ requestType, context, onCancel, onSaved }) => { +const RecruitingHireForm: React.FC = ({ requestType, context, onCancel, onSaved, editId }) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); - const [requestId, setRequestId] = useState(undefined); + // При открытии на редактирование (editId) — сразу известен, иначе появится + // после первого сохранения. + const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); const submit = useRequestSubmit(); @@ -42,25 +47,68 @@ const RecruitingHireForm: React.FC = ({ requestType, context, const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); + // Открыта СВОЯ заявка на редактирование — подгружаем её содержимое и статус. + useEffect(() => { + if (!editId) { + return; + } + let alive = true; + loadHireRequest(editId) + .then((loaded) => { + if (alive) { + setData(loaded.data); + setRequestStatus(loaded.status); + } + }) + .catch((e) => { + if (alive) { + submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`); + } + }); + return () => { + alive = false; + }; + }, [editId]); + // Маршрут согласования (планируемый) резолвится по email инициатора — текущего - // пользователя (кандидата ещё нет в системе). + // пользователя (кандидата ещё нет в системе). У заявки, открытой на + // редактирование, маршрут читается из уже записанных строк — планирование + // затёрло бы правки, сделанные при первом сохранении. const approval = useMemo(() => hireApprovalService(), []); const userEmail = context.pageContext.user.email; const loadMatrix = useCallback( - () => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'), - [context, userEmail, approval] + () => + editId + ? approval + .getMatrixForRequest(editId) + .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)) + : getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'), + [context, userEmail, approval, editId] ); - const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) => - approval.getJobTitle(login) + const matrix = useMatrixEditor( + loadMatrix, + `${userEmail}-${editId ?? 'new'}`, + !readOnly, + true, + (login) => approval.getJobTitle(login) ); // Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения. const loadExecutors = useCallback( - () => getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'), - [context, userEmail, approval] + () => + editId + ? approval + .getMatrixForRequest(editId) + .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)) + : getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'), + [context, userEmail, approval, editId] ); - const executors = useMatrixEditor(loadExecutors, userEmail, false, true, (login) => - approval.getJobTitle(login) + const executors = useMatrixEditor( + loadExecutors, + `${userEmail}-${editId ?? 'new'}`, + false, + true, + (login) => approval.getJobTitle(login) ); const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0; @@ -68,7 +116,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( @@ -89,10 +144,28 @@ const RecruitingHireForm: React.FC = ({ requestType, context, } }; + // На отправку (в отличие от черновика) маршрут должен быть полностью + // укомплектован — иначе шаг без назначенного человека молча пропадёт при + // записи в SharePoint (createSteps не может записать Person-поле без + // значения), и согласующие в середине маршрута просто не увидят заявку. + const validateSubmit = (): string | undefined => { + const formError = validate(); + if (formError) return formError; + const unassigned = findUnassignedStep(matrix.rows); + if (unassigned) { + return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; + } + const unassignedExecutor = findUnassignedStep(executors.rows); + if (unassignedExecutor) { + return `Шаг ${unassignedExecutor.order} исполнения («${unassignedExecutor.position || 'без должности'}») без назначенного человека — выберите исполнителя или удалите шаг.`; + } + return undefined; + }; + const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); const handleSubmit = (): Promise => - submit.run('submit', save(REQUEST_STATUS.onApproval), validate); + submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); const sectionProps = { data, set, context }; 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
+ +
+ + + ); + } +} 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'; diff --git a/src/webparts/requestPortal/shared/unsavedChanges.ts b/src/webparts/requestPortal/shared/unsavedChanges.ts new file mode 100644 index 0000000..fb1f78b --- /dev/null +++ b/src/webparts/requestPortal/shared/unsavedChanges.ts @@ -0,0 +1,33 @@ +// Есть ли в открытой форме «Новая заявка» правки, которые ещё не сохранены. +// +// Нужно, потому что раздел «Новая заявка» держится смонтированным при +// переключении вкладок (см. RequestPortal.tsx), и единственное, что реально +// пересоздаёт форму, — переход «создать на основе». Перед ним и спрашиваем, +// но только если терять действительно есть что. +// +// Флаг ставится по факту пользовательского ввода (событие input/change из +// раздела формы — см. RequestPortal), а снимается после сохранения: черновик +// тоже считается сохранением, форма зовёт onSaved и в обоих случаях +// («Сохранить черновик» и «Отправить»), см. forms/*/Form.tsx. +let dirty = false; + +export function markFormDirty(): void { + dirty = true; +} + +export function markFormClean(): void { + dirty = false; +} + +export function hasUnsavedFormChanges(): boolean { + return dirty; +} + +// Спросить подтверждение, если несохранённые правки есть. true — можно +// продолжать (правок нет либо пользователь согласился их потерять). +export function confirmDiscardFormChanges(): boolean { + if (!dirty) return true; + return window.confirm( + 'В открытой форме «Новая заявка» есть несохранённые изменения — они будут потеряны. Продолжить?' + ); +}