From d5832179fb66958be6a3d1f85ecb0bfd5baa85e4 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 6 Aug 2026 17:04:41 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BA=D1=83=D1=87=D0=B0=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BD=D1=8E=D0=B0=D0=BD=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/RequestPortal.tsx | 56 +++++++---- .../features/edit/EditFeature.tsx | 51 ++++++++++ .../features/myRequests/myRequests.service.ts | 30 +++++- .../RecruitingHire/RecruitingHireForm.tsx | 94 ++++++++++++++++--- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 94 ++++++++++++++++--- src/webparts/requestPortal/models/types.ts | 7 ++ .../shared/approval/approvalList.service.ts | 33 +++++-- .../requestPortal/shared/approval/index.ts | 2 +- .../shared/approval/ui/approval.module.scss | 14 +-- .../shared/approval/useMatrixEditor.ts | 10 ++ src/webparts/requestPortal/shared/routing.ts | 16 +++- 11 files changed, 343 insertions(+), 64 deletions(-) create mode 100644 src/webparts/requestPortal/features/edit/EditFeature.tsx diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index df2c932..ae54c0a 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -12,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'; // Оболочка портала: тёмное боковое меню (разделы) + основная область. // Логики фич тут нет — файл общий, меняется редко. @@ -99,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 (
@@ -158,14 +181,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context {route.nav === 'search' && ( - navigate({ - mode: 'approve', - nav: route.nav, - formKey: request.formKey, - itemId: request.id, - }) - } + onOpen={openRequest} /> )} @@ -173,14 +189,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context - navigate({ - mode: 'approve', - nav: route.nav, - formKey: request.formKey, - itemId: request.id, - }) - } + onOpen={openRequest} onMyTurnCountChange={route.nav === 'approvals' ? setApprovalsCount : undefined} /> )} @@ -192,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/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index 9e5c5dc..06b2d1d 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). @@ -513,14 +518,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 []; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 2b80a4b..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; @@ -96,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/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index b83501b..1c22865 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -20,7 +20,9 @@ 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'; @@ -32,14 +34,16 @@ import { DATA_LIST_TITLE } from './submit'; import { loadPrefillFromHire } from './prefill'; +import { loadWorkplaceRequest } from './load'; import { EmployeeSection } from './sections/EmployeeSection'; import { EquipmentSection } from './sections/EquipmentSection'; -const WorkplaceSetupForm: React.FC = ({ requestType, context, onCancel, onSaved, sourceId }) => { +const WorkplaceSetupForm: React.FC = ({ requestType, context, onCancel, onSaved, sourceId, editId }) => { const [data, setData] = useState(EMPTY_DATA); const [files, setFiles] = useState([]); - // Id и статус текущей заявки: заполняются после первого сохранения. - const [requestId, setRequestId] = useState(undefined); + // Id и статус текущей заявки: заполняются после первого сохранения, а при + // открытии на редактирование (editId) — сразу из загруженной записи. + const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); const submit = useRequestSubmit(); @@ -48,6 +52,31 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); + // Открыта СВОЯ заявка на редактирование — подгружаем её содержимое и статус. + // От предзаполнения «на основе» (ниже) отличается тем, что это та же запись: + // сохранение уйдёт в неё, а не создаст новую. + useEffect(() => { + if (!editId) { + return; + } + let alive = true; + loadWorkplaceRequest(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]); + // Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource). useEffect(() => { if (!sourceId) { @@ -72,19 +101,42 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, const approval = useMemo(() => workplaceApprovalService(), []); const userEmail = context.pageContext.user.email; + // У новой заявки маршрут ПЛАНИРУЕТСЯ (resolve по внешнему API), у открытой на + // редактирование — читается из уже записанных строк: их могли поправить + // вручную при первом сохранении, и планирование затёрло бы эти правки. const loadMatrix = useCallback( - () => getPlannedMatrix(context, WORKPLACE_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, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'), + [context, userEmail, approval, editId] + ); + const matrix = useMatrixEditor( + loadMatrix, + `${userEmail}-${editId ?? 'new'}`, + !readOnly, + true, + (login) => approval.getJobTitle(login) ); - const matrix = useMatrixEditor(loadMatrix, userEmail, !readOnly, true, (login) => approval.getJobTitle(login)); // Фаза исполнения: исполнители из маршрута (status='Execution'), только для чтения. const loadExecutors = useCallback( - () => getPlannedMatrix(context, WORKPLACE_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, WORKPLACE_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.FullName.trim().length > 0 && data.Position.trim().length > 0; @@ -121,10 +173,28 @@ const WorkplaceSetupForm: 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/models/types.ts b/src/webparts/requestPortal/models/types.ts index a7750f3..a543ec2 100644 --- a/src/webparts/requestPortal/models/types.ts +++ b/src/webparts/requestPortal/models/types.ts @@ -37,4 +37,11 @@ export interface IRequestFormProps { * Формы, не поддерживающие производные заявки, игнорируют. */ sourceId?: number; + /** + * Id СВОЕЙ заявки, открытой на редактирование (черновик / возвращённая / + * отклонённая). В отличие от sourceId это не предзаполнение новой заявки, а + * та же самая запись: сохранение обновляет её, а не создаёт новую, и маршрут + * согласования читается из уже записанных строк, а не планируется заново. + */ + editId?: number; } diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index 2d20ffe..6e504a8 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -68,11 +68,23 @@ export class ApprovalListService { // сквозную нумерацию). a.order — номер шага ВНУТРИ фазы (1..N), и у // параллельных шагов он совпадает, поэтому нумеровать по позиции в массиве // нельзя. Без a.order падаем на позицию — для вызовов, где параллельности нет. - const withOrder = approvers - .map((a, i) => ({ a, order: orderStart - 1 + (a.order ?? i + 1) })) - .filter((x) => !!x.a.email); + const withOrder = approvers.map((a, i) => ({ a, order: orderStart - 1 + (a.order ?? i + 1) })); - for (const { a, order } of withOrder) { + // Строку без email в SharePoint не записать (Person-поле обязательного + // значения) — раньше такие шаги пропадали отсюда молча, и согласующие в + // середине маршрута просто не получали заявку (см. findUnassignedStep — + // это должно было быть отловлено валидацией ДО вызова createSteps; лог + // здесь — на случай, если её обошли, например прямым вызовом сервиса). + const missing = withOrder.filter((x) => !x.a.email); + if (missing.length > 0) { + console.error( + `[Согласование] createSteps: пропущены шаги без назначенного человека (order ${missing + .map((x) => x.order) + .join(', ')}) — заявка №${requestId}` + ); + } + + for (const { a, order } of withOrder.filter((x) => !!x.a.email)) { const approverId = await this.resolveUserId(a.email); await this.list().items.add({ Title: a.position || a.email, @@ -170,6 +182,12 @@ export class ApprovalListService { } // Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий). + // + // Approver/EMail нужен не только для показа: при редактировании черновика + // (см. useMatrixEditor с editable=true в WorkplaceSetupForm/RecruitingHireForm) + // это ЕДИНСТВЕННЫЙ источник email для уже существующих строк — без него + // findUnassignedStep решает, что назначенного человека нет, хотя он есть + // (баг: пропущенный email тут выглядел так же, как реально пустой шаг). public async getMatrixForRequest(requestId: number): Promise { const rows = (await this.list() .items.filter(`${FIELDS.ticket}Id eq ${requestId}`) @@ -182,17 +200,18 @@ export class ApprovalListService { FIELDS.order, FIELDS.allowReassign, FIELDS.phase, - `${FIELDS.approver}/Title` + `${FIELDS.approver}/Title`, + `${FIELDS.approver}/EMail` ) .expand(FIELDS.approver) .orderBy(FIELDS.order, true)()) as Array>; return rows.map((r, i) => { - const approver = r[FIELDS.approver] as { Title?: string } | undefined; + const approver = r[FIELDS.approver] as { Title?: string; EMail?: string } | undefined; return { order: (r[FIELDS.order] as number) ?? i + 1, who: approver?.Title || '', - email: '', + email: approver?.EMail || '', position: (r.Title as string) || '', status: (r[FIELDS.status] as string) || '', date: this.formatDate((r[FIELDS.date] as string) || ''), diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index eeee35d..89ecc9b 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -24,7 +24,7 @@ export type { IApproverToWrite } from './approvalList.service'; export { ApproverService } from './approverDecision.service'; export type { ApproverDecision } from './approverDecision.service'; export { getPlannedMatrix, writeApprovers, writeRoute } from './approvalSubmission.service'; -export { useMatrixEditor } from './useMatrixEditor'; +export { useMatrixEditor, findUnassignedStep } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { UserPicker } from './ui/UserPicker'; diff --git a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss index d6595d9..0b1df37 100644 --- a/src/webparts/requestPortal/shared/approval/ui/approval.module.scss +++ b/src/webparts/requestPortal/shared/approval/ui/approval.module.scss @@ -115,6 +115,7 @@ .matrixActions { display: flex; + align-items: center; gap: $sp-1; justify-content: flex-end; } @@ -181,15 +182,16 @@ // Перемещение шага целиком — тоже объединённая по вертикали ячейка, поэтому // кнопки центрируем относительно всего блока. +// Та же flex-раскладка, что и у .matrixActions — иначе кнопки здесь (обычные +// инлайновые элементы) выравниваются по baseline и «прыгают» относительно +// кнопок соседней колонки, которая уже flex-контейнер. .matrixBlockMove { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $sp-1; width: 64px; white-space: nowrap; - vertical-align: middle; - text-align: right; - - .iconBtn + .iconBtn { - margin-left: $sp-1; - } } // Строки, входящие в шаг из нескольких параллельных согласующих — лёгкая diff --git a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts index b56d587..fb4d90f 100644 --- a/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts +++ b/src/webparts/requestPortal/shared/approval/useMatrixEditor.ts @@ -78,6 +78,16 @@ function markParallel(rows: IMatrixRow[]): IMatrixRow[] { })); } +// Первый шаг без назначенного человека (email пуст) — обычно шаг маршрута, +// который внешний approval-route API вернул как роль без привязанного +// исполнителя. Писать такую строку в SharePoint нельзя (Person-поле требует +// значение) — approvalList.service.ts/createSteps молча пропускает её при +// записи, поэтому перед отправкой это нужно поймать здесь и не дать заявке уйти +// на согласование с пропущенным шагом (см. RecruitingHireForm/WorkplaceSetupForm). +export function findUnassignedStep(rows: IMatrixRow[]): IMatrixRow | undefined { + return rows.filter((r) => !r.email).sort((a, b) => a.order - b.order)[0]; +} + /** * Загружает матрицу согласования и (если editable) позволяет её редактировать. * @param load загрузчик строк (планируемый маршрут из resolve или строки заявки). diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index 09ae6e0..e35fbfd 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -20,7 +20,9 @@ import * as React from 'react'; // перезагрузки страницы. useRoute() подписан и на popstate (кнопка «назад»), // и на её собственное событие, поэтому реагирует на оба способа смены URL. -export type PortalMode = 'submit' | 'approve' | 'create'; +// 'edit' — открыть СВОЮ незавершённую заявку (черновик / возвращённую / +// отклонённую) на редактирование в той же выезжающей панели, что и просмотр. +export type PortalMode = 'submit' | 'approve' | 'create' | 'edit'; /** Раздел бокового меню. */ export type NavSection = 'new' | 'approvals' | 'own' | 'search'; @@ -29,9 +31,9 @@ export interface IRoute { mode: PortalMode; /** Раздел бокового меню; сохраняется и когда поверх открыта заявка. */ nav: NavSection; - /** Ключ формы (тип заявки) — для 'approve' и 'create'. */ + /** Ключ формы (тип заявки) — для 'approve', 'edit' и 'create'. */ formKey?: string; - /** Id заявки в списке данных — для 'approve'. */ + /** Id заявки в списке данных — для 'approve' и 'edit'. */ itemId?: number; /** Id исходной заявки для предзаполнения — для 'create' (напр. орг. места на основе прием). */ sourceId?: number; @@ -59,6 +61,10 @@ export function parseRoute(search: string = window.location.search): IRoute { if (mode === 'approve' && formKey && itemId && !isNaN(itemId)) { return { mode: 'approve', nav, formKey, itemId }; } + // Редактирование собственной незавершённой заявки — та же панель, но с формой. + if (mode === 'edit' && formKey && itemId && !isNaN(itemId)) { + return { mode: 'edit', nav, formKey, itemId }; + } // Создание производной заявки с предзаполнением из исходной. if (formKey && sourceId && !isNaN(sourceId)) { return { mode: 'create', nav: 'new', formKey, sourceId }; @@ -78,8 +84,8 @@ function buildQuery(route: IRoute): string { if (route.nav !== 'new') { params.set('rpnav', route.nav); } - if (route.mode === 'approve' && route.formKey && route.itemId) { - params.set('rpmode', 'approve'); + if ((route.mode === 'approve' || route.mode === 'edit') && route.formKey && route.itemId) { + params.set('rpmode', route.mode); params.set('rptype', route.formKey); params.set('rpid', String(route.itemId)); } else if (route.mode === 'create' && route.formKey && route.sourceId) {