diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx index a7aea88..f31a471 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx @@ -15,6 +15,7 @@ import { import { composeRemoveRowRule, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, @@ -25,7 +26,11 @@ import { REQUEST_STATUS, isEditableStatus, IApproverToWrite, - unassignedStepMessage + unassignedStepMessage, + useFormExecutors, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus } from '../../shared/approval'; import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; @@ -171,7 +176,7 @@ const ConcreteRequestForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, data.SiteChiefEmail, approval, 'Approval', null, undefined, projectId), [context, approval, editId, data.SiteChiefEmail, requestTypeId, projectId] ); @@ -183,6 +188,17 @@ const ConcreteRequestForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители — из маршрута админки (заявитель их не правит); те же параметры + // резолва, что и у согласующих. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: data.SiteChiefEmail, + editId, + projectId + }); + const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0); const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems); const validate = (): string | undefined => { @@ -224,6 +240,7 @@ const ConcreteRequestForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -238,7 +255,7 @@ const ConcreteRequestForm: React.FC = ({ // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Имена списков — той же партиции, куда фактически записана заявка (writeListId), // не текущей: поток ищет строку маршрута именно в НЕЙ. - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -260,11 +277,21 @@ const ConcreteRequestForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + // Без исполнителей — прежний статус «На согласовании»; с ними — как у рефералки + // (initialSubmitStatus учитывает маршрут без согласующих). + const submitStatus = executors.rows.length > 0 + ? initialSubmitStatus(matrix.rows.length, executors.rows.length) + : REQUEST_STATUS.onApproval; + const handleSubmit = (): Promise => submit.run('submit', save(submitStatus), validateSubmit); const sectionProps = { data, @@ -331,6 +358,8 @@ const ConcreteRequestForm: React.FC = ({ waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования." /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.tsx index a0e5fcd..f6d8c91 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.tsx +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.tsx @@ -8,10 +8,13 @@ import { DecisionPanel, StatusBadge, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, useMatrixEditor, notifyActiveStep, - useRequestTypeId + useRequestTypeId, + useViewExecutors, + approvalPhaseRows } from '../../shared/approval'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { @@ -71,10 +74,11 @@ const ConcreteRequestView: React.FC = ({ context, ite // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -153,6 +157,7 @@ const ConcreteRequestView: React.FC = ({ context, ite approval.searchUsers(q)} /> + approval.searchUsers(q)} /> {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/submit.ts b/src/webparts/requestPortal/forms/ConcreteRequest/submit.ts index 80ad495..9b381c9 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/submit.ts +++ b/src/webparts/requestPortal/forms/ConcreteRequest/submit.ts @@ -47,7 +47,7 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить заявку на бетон: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей. + * Исполнители (фаза «Исполнение») приходят из маршрута админки и пишутся тем же writeRoute. * Возвращает Id заявки (+ код при создании). * * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadConcreteRequest). Имеет смысл @@ -59,12 +59,13 @@ export async function saveConcreteRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { const item = buildItem(data, status); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(concreteRequestApprovalService(listId), result.id, approvers, [], status); + await writeRoute(concreteRequestApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx index 7de7791..151d50e 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -15,6 +15,7 @@ import { import { composeRemoveRowRule, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, @@ -25,7 +26,11 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - resolveWriteListId + resolveWriteListId, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, + useFormExecutors } from '../../shared/approval'; import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; @@ -159,7 +164,7 @@ const DismissalForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [ { fieldName: 'Position', value: primaryPosition(data.Organization) } ]), @@ -173,6 +178,17 @@ const DismissalForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители самого заявления (фаза «Исполнение» маршрута из админки) — по + // тому же сотруднику и тем же fieldValues, что и согласующие выше. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId, + fieldValues: [{ fieldName: 'Position', value: primaryPosition(data.Organization) }] + }); + // Убрать компанию из заявления (совместитель, реально не увольняется из // ВСЕХ) — см. OrganizationsTable. Снимает компанию из data.Organization И, по // возможности, соответствующего согласующего (директора/должностного @@ -245,6 +261,7 @@ const DismissalForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -259,7 +276,7 @@ const DismissalForm: React.FC = ({ // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Имена списков — той же партиции, куда фактически записана заявка (writeListId), // не текущей: поток ищет строку маршрута именно в НЕЙ. - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -281,11 +298,27 @@ const DismissalForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + // Исполнители в маршруте есть → статус стартует с учётом фаз (initialSubmitStatus: + // без согласующих сразу «Выполнение»); иначе прежний «На согласовании». + const handleSubmit = (): Promise => + submit.run( + 'submit', + save( + executors.rows.length > 0 + ? initialSubmitStatus(matrix.rows.length, executors.rows.length) + : REQUEST_STATUS.onApproval + ), + validateSubmit + ); const sectionProps = { data, @@ -348,6 +381,8 @@ const DismissalForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили, — поэтому история и в форме, а не только в просмотре. Только у уже существующей заявки: у новой истории нет. */} diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx index b8ac226..4bfe0b6 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx @@ -8,11 +8,14 @@ import { DecisionPanel, StatusBadge, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, useMatrixEditor, notifyActiveStep, useRequestTypeId, - REQUEST_STATUS + REQUEST_STATUS, + approvalPhaseRows, + useViewExecutors } from '../../shared/approval'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { getManagerEmail } from '../../shared/dictionary'; @@ -87,10 +90,12 @@ const DismissalView: React.FC = ({ context, itemId, formKey // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + // Исполнители — отдельная фаза тех же строк маршрута; перечитываются вместе с согласующими. + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -282,6 +287,8 @@ const DismissalView: React.FC = ({ context, itemId, formKey approval.searchUsers(q)} /> + + approval.searchUsers(q)} /> {/* История согласования: маршрут пересохраняется патчем (см. diff --git a/src/webparts/requestPortal/forms/Dismissal/submit.ts b/src/webparts/requestPortal/forms/Dismissal/submit.ts index 1ee007d..f6450b8 100644 --- a/src/webparts/requestPortal/forms/Dismissal/submit.ts +++ b/src/webparts/requestPortal/forms/Dismissal/submit.ts @@ -96,7 +96,9 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить заявление на увольнение: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей. + * executors — исполнители самого заявления (фаза «Исполнение» маршрута из + * админки; пусто, если в маршруте их нет). Это не маршрут связанной заявки + * Clearance (см. createClearance.ts). * Возвращает Id заявки (+ код при создании). * * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadDismissalRequest). Имеет смысл @@ -109,6 +111,7 @@ export async function saveDismissalRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { @@ -116,7 +119,7 @@ export async function saveDismissalRequest( const item = buildItem(data, status, employee.Id, employee.Title || data.Employee); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(dismissalApprovalService(listId), result.id, approvers, [], status); + await writeRoute(dismissalApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx index 77cf7b8..6a0ab99 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx @@ -18,6 +18,11 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + useFormExecutors, + ExecutorsMatrix, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -155,7 +160,7 @@ const EmployeeTransferForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'), [context, approval, editId, data.Employee, requestTypeId] ); @@ -167,6 +172,16 @@ const EmployeeTransferForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители — по тому же сотруднику, что и маршрут согласования. Только чтение: + // их назначает маршрут из админки, не заявитель. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId + }); + const isValid = !!( data.Employee && data.Position.trim() && @@ -213,6 +228,7 @@ const EmployeeTransferForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -227,7 +243,7 @@ const EmployeeTransferForm: React.FC = ({ // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Имена списков — той же партиции, куда фактически записана заявка (writeListId), // не текущей: поток ищет строку маршрута именно в НЕЙ. - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -249,11 +265,27 @@ const EmployeeTransferForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + // Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих + // (сразу «Выполнение»); иначе прежний статус «На согласовании». + const handleSubmit = (): Promise => + submit.run( + 'submit', + save( + executors.rows.length > 0 + ? initialSubmitStatus(matrix.rows.length, executors.rows.length) + : REQUEST_STATUS.onApproval + ), + validateSubmit + ); const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange }; @@ -310,6 +342,8 @@ const EmployeeTransferForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferView.tsx b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferView.tsx index eb29c3a..e47881f 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferView.tsx +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferView.tsx @@ -10,6 +10,9 @@ import { ApprovalMatrix, ApprovalHistory, useMatrixEditor, + useViewExecutors, + ExecutorsMatrix, + approvalPhaseRows, notifyActiveStep, useRequestTypeId } from '../../shared/approval'; @@ -70,10 +73,11 @@ const EmployeeTransferView: React.FC = ({ context, i // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -150,6 +154,7 @@ const EmployeeTransferView: React.FC = ({ context, i approval.searchUsers(q)} /> + approval.searchUsers(q)} /> {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/submit.ts b/src/webparts/requestPortal/forms/EmployeeTransfer/submit.ts index 139ac20..ff6f152 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/submit.ts +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/submit.ts @@ -43,7 +43,8 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить заявку на перемещение: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей. + * executors — исполнители из маршрута (фаза «Исполнение»); пустой список, если в + * маршруте их нет. * Возвращает Id заявки (+ код при создании). * * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadEmployeeTransferRequest). Имеет @@ -55,6 +56,7 @@ export async function saveEmployeeTransferRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { @@ -62,6 +64,6 @@ export async function saveEmployeeTransferRequest( const item = buildItem(data, status, employee.Id, employee.Title || data.Employee); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(employeeTransferApprovalService(listId), result.id, approvers, [], status); + await writeRoute(employeeTransferApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx index 768fc32..405faf6 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -25,7 +25,12 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - resolveWriteListId + resolveWriteListId, + useFormExecutors, + ExecutorsMatrix, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus } from '../../shared/approval'; import { uploadAttachments, @@ -181,13 +186,23 @@ const ServiceRequestForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId), [context, userEmail, approval, editId, projectId, requestTypeId] ); const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) => approval.getJobTitle(login) ); + // Исполнители резолвятся тем же маршрутом и с тем же проектом, что и согласующие: + // пока проект не выбран, e-mail пустой — useFormExecutors не грузит маршрут (404). + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: matrixReady ? userEmail : '', + editId, + projectId + }); const hasCosts = hasAnyCostItem(data.Costs); const isValid = @@ -273,7 +288,7 @@ const ServiceRequestForm: React.FC = ({ throw new Error('Тип заявки не определён — обновите страницу и повторите.'); } const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); - const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), status, writeListId); + const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), executorsToWrite(executors.rows), status, writeListId); setRequestId(id); setRequestStatus(status); if (code) { @@ -294,7 +309,7 @@ const ServiceRequestForm: React.FC = ({ if (onSaved) { onSaved(); } - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -314,11 +329,26 @@ const ServiceRequestForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + const handleSubmit = (): Promise => submit.run( + 'submit', + // Есть исполнители — initialSubmitStatus сам выбирает стартовый статус (согласующих + // может не быть вовсе), иначе прежнее «На согласовании». + save( + executors.rows.length > 0 + ? initialSubmitStatus(matrix.rows.length, executors.rows.length) + : REQUEST_STATUS.onApproval + ), + validateSubmit + ); const sectionProps = { data, @@ -390,6 +420,8 @@ const ServiceRequestForm: React.FC = ({ waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования." /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx index 15b34cf..66cc213 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx @@ -20,6 +20,9 @@ import { ApprovalMatrix, ApprovalHistory, useMatrixEditor, + useViewExecutors, + ExecutorsMatrix, + approvalPhaseRows, notifyActiveStep, useRequestTypeId, isEditableStatus @@ -77,10 +80,11 @@ const ServiceRequestView: React.FC = ({ context, itemI const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -180,6 +184,7 @@ const ServiceRequestView: React.FC = ({ context, itemI approval.searchUsers(q)} /> + approval.searchUsers(q)} /> {/* История согласования: маршрут пересохраняется патчем (см. diff --git a/src/webparts/requestPortal/forms/ServiceRequest/submit.ts b/src/webparts/requestPortal/forms/ServiceRequest/submit.ts index 2d3263b..e8ea569 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/submit.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/submit.ts @@ -43,7 +43,7 @@ function buildItem(data: IFormData, status: string): Record { export type ISaveResult = ISaveItemResult; /** - * Сохранить «Заявку на услуги»: контент + маршрут согласования. + * Сохранить «Заявку на услуги»: контент + маршрут согласования и исполнения. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём * новую. Возвращает Id заявки (+ уникальный код при создании). * @@ -56,14 +56,13 @@ export async function saveServiceRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { const item = buildItem(data, status); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - // Фазы исполнения у заявки на услуги нет — договор дальше ведут вне портала, - // поэтому исполнителей не пишем (пустой массив). - await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, [], status); + await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index 425f81b..a2af1ef 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -29,7 +29,12 @@ import { REQUEST_STATUS, isEditableStatus, IApproverToWrite, - unassignedStepMessage + unassignedStepMessage, + approvalPhaseRows, + executorsToWrite, + useFormExecutors, + ExecutorsMatrix, + initialSubmitStatus } from '../../shared/approval'; import { uploadAttachments, @@ -172,7 +177,7 @@ const Ticket1CForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, [ { fieldName: 'WorkType', value: data.WorkType }, { fieldName: 'Department', value: data.Department } @@ -186,6 +191,20 @@ const Ticket1CForm: React.FC = ({ !!editId || (!!data.WorkType && !!requestTypeId), (login) => approval.getJobTitle(login) ); + // Исполнители (фаза «Исполнение») резолвятся с теми же fieldValues, что и + // согласующие: условные шаги маршрута зависят от вида работ и отдела. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail, + editId, + departmentId: null, + fieldValues: [ + { fieldName: 'WorkType', value: data.WorkType }, + { fieldName: 'Department', value: data.Department } + ] + }); // Должность нового «Руководителя» — approval-route её не знает (резолвил шаг // на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по // e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих, @@ -278,7 +297,13 @@ const Ticket1CForm: React.FC = ({ // работ (раньше в этом случае заявка сразу становилась «Согласовано» в // обход согласования; теперь это просто «На согласовании» с пустой // матрицей — сигнал администратору маршрутов донастроить условие). - const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : REQUEST_STATUS.onApproval; + // Есть исполнители — статус отправки по initialSubmitStatus (без согласующих + // сразу «Выполнение»); иначе, как раньше, «На согласовании» (в т.ч. при пустом маршруте). + const submitStatus = + executors.rows.length > 0 + ? initialSubmitStatus(matrixRows.length, executors.rows.length) + : REQUEST_STATUS.onApproval; + const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : submitStatus; // requestId уже задан → редактируем существующую заявку, пишем в ту же // партицию (editListId). Иначе — создаём новую, и партицию для записи @@ -293,6 +318,7 @@ const Ticket1CForm: React.FC = ({ requestId, data, toApprovers(matrixRows), + executorsToWrite(executors.rows), finalStatus, writeListId ); @@ -315,7 +341,7 @@ const Ticket1CForm: React.FC = ({ // Передача в Planner теперь происходит только после реального решения // согласующего (см. Ticket1CView.handoffToPlanner) — здесь заявка всегда // отправляется на согласование, минуя Planner напрямую. - if (finalStatus === REQUEST_STATUS.onApproval) { + if (finalStatus === REQUEST_STATUS.onApproval || finalStatus === REQUEST_STATUS.execution) { // Имена списков — той же партиции, куда фактически записали (writeListId), // не обязательно текущей на момент рендера формы: поток ищет строку // маршрута именно в НЕЙ. @@ -345,6 +371,11 @@ const Ticket1CForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; @@ -424,6 +455,8 @@ const Ticket1CForm: React.FC = ({ emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С." /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx index ae504f4..710b68b 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx @@ -25,7 +25,10 @@ import { notifyActiveStep, useRequestTypeId, isEditableStatus, - REQUEST_STATUS + REQUEST_STATUS, + approvalPhaseRows, + useViewExecutors, + ExecutorsMatrix } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -112,10 +115,11 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, // decisionSeq входит в reloadKey: после решения строки маршрута меняются на // сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -246,6 +250,8 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, search={(q) => approval.searchUsers(q)} emptyText="Согласование не требовалось для этого вида работ." /> + + approval.searchUsers(q)} /> {/* История согласования: маршрут пересохраняется патчем (см. diff --git a/src/webparts/requestPortal/forms/Ticket1C/submit.ts b/src/webparts/requestPortal/forms/Ticket1C/submit.ts index b4e459a..7b145cf 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/submit.ts +++ b/src/webparts/requestPortal/forms/Ticket1C/submit.ts @@ -32,10 +32,10 @@ function buildItem(data: IFormData, status: string): Record { export type ISaveResult = ISaveItemResult; /** - * Сохранить заявку в отдел 1С: содержимое + маршрут согласования. + * Сохранить заявку в отдел 1С: содержимое + маршрут (согласующие и исполнители). * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю - * из матрицы. Возвращает Id заявки (+ код при создании). + * executors — исполнители из маршрута админки (фаза «Исполнение»), могут быть пустыми: + * тогда после согласования заявка уходит в Planner. Возвращает Id заявки (+ код при создании). * * listId — партиция, в которую фактически пишем: при редактировании — партиция * ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция, @@ -49,13 +49,14 @@ export async function saveTicketRequest( existingId: number | undefined, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { const item = buildItem(data, status); const result = await saveRequestItem(FORM_ID, existingId, listId, TICKET1C_CODE_PREFIX, item); - await writeRoute(ticket1cApprovalService(listId), result.id, approvers, [], status); + await writeRoute(ticket1cApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx index 228b758..cd5ac84 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx @@ -15,9 +15,14 @@ import { import { composeRemoveRowRule, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, + useFormExecutors, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, getPlannedMatrix, notifyActiveStep, useRequestTypeId, @@ -156,7 +161,7 @@ const UnpaidLeaveForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'), [context, approval, editId, data.Employee, requestTypeId] ); @@ -168,6 +173,15 @@ const UnpaidLeaveForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители фазы «Исполнение» — по маршруту из админки, только чтение. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId + }); + // Даты корректны, если конец не раньше начала (обе обязательны). const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate); // Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала @@ -225,6 +239,7 @@ const UnpaidLeaveForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -239,7 +254,7 @@ const UnpaidLeaveForm: React.FC = ({ // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Имена списков — той же партиции, куда фактически записана заявка (writeListId), // не текущей: поток ищет строку маршрута именно в НЕЙ. - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -261,11 +276,22 @@ const UnpaidLeaveForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + // Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих. + const handleSubmit = (): Promise => + submit.run( + 'submit', + save(executors.rows.length > 0 ? initialSubmitStatus(matrix.rows.length, executors.rows.length) : REQUEST_STATUS.onApproval), + validateSubmit + ); const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange }; @@ -322,6 +348,8 @@ const UnpaidLeaveForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx index a9af4a3..e2f0d53 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx @@ -8,8 +8,11 @@ import { DecisionPanel, StatusBadge, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, useMatrixEditor, + useViewExecutors, + approvalPhaseRows, notifyActiveStep, useRequestTypeId } from '../../shared/approval'; @@ -70,7 +73,8 @@ const UnpaidLeaveView: React.FC = ({ context, itemId, for // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); @@ -150,6 +154,8 @@ const UnpaidLeaveView: React.FC = ({ context, itemId, for approval.searchUsers(q)} /> + + approval.searchUsers(q)} /> {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts b/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts index 62353f3..4b43bc0 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts +++ b/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts @@ -62,7 +62,7 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить заявку без содержания: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей. + * executors — исполнители фазы «Исполнение» из маршрута (пусто, если в маршруте их нет). * Возвращает Id заявки (+ код при создании). * * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadUnpaidLeaveRequest). Имеет смысл @@ -75,6 +75,7 @@ export async function saveUnpaidLeaveRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { @@ -84,6 +85,6 @@ export async function saveUnpaidLeaveRequest( const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, [], status); + await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index 6950f14..2220059 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -18,6 +18,11 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + useFormExecutors, + ExecutorsMatrix, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -157,7 +162,7 @@ const VacationForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'), [context, approval, editId, data.Employee, requestTypeId] ); @@ -169,6 +174,16 @@ const VacationForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители из маршрута админки (фаза «Исполнение»): только чтение, таблица + // и запись в заявку — когда они в маршруте есть. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId + }); + const daysNum = Number(data.DaysCount); // Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала // отпуска — календарь (min у input type=date, см. RequestSection) не даёт @@ -223,6 +238,7 @@ const VacationForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -238,7 +254,7 @@ const VacationForm: React.FC = ({ // Имена списков — той же партиции, куда фактически записали (writeListId), // не обязательно текущей на момент рендера формы: поток ищет строку // маршрута именно в НЕЙ. - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -260,11 +276,27 @@ const VacationForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + // Есть исполнители — initialSubmitStatus учтёт и маршрут без согласующих (сразу «Выполнение»); + // иначе прежнее поведение: «На согласовании». + const handleSubmit = (): Promise => + submit.run( + 'submit', + save( + executors.rows.length > 0 + ? initialSubmitStatus(matrix.rows.length, executors.rows.length) + : REQUEST_STATUS.onApproval + ), + validateSubmit + ); const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange }; @@ -321,6 +353,8 @@ const VacationForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index e5ba3ba..31b875e 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -10,6 +10,9 @@ import { ApprovalMatrix, ApprovalHistory, useMatrixEditor, + useViewExecutors, + ExecutorsMatrix, + approvalPhaseRows, notifyActiveStep, useRequestTypeId } from '../../shared/approval'; @@ -78,10 +81,11 @@ const VacationView: React.FC = ({ context, itemId, formKey, // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -180,6 +184,7 @@ const VacationView: React.FC = ({ context, itemId, formKey, approval.searchUsers(q)} /> + approval.searchUsers(q)} /> {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/Vacation/submit.ts b/src/webparts/requestPortal/forms/Vacation/submit.ts index e5bbd27..42d95dd 100644 --- a/src/webparts/requestPortal/forms/Vacation/submit.ts +++ b/src/webparts/requestPortal/forms/Vacation/submit.ts @@ -54,7 +54,7 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить заявку на отпуск: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей. + * executors — исполнители фазы «Исполнение» из маршрута админки (пусто, если в маршруте их нет). * Возвращает Id заявки (+ код при создании). * * listId — партиция, в которую фактически пишем: при редактировании — партиция @@ -70,6 +70,7 @@ export async function saveVacationRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { @@ -79,6 +80,6 @@ export async function saveVacationRequest( const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status); + await writeRoute(vacationApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 2e2ee9a..56b8b04 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -63,11 +63,19 @@ export { notifyActiveStep } from './notify.service'; export type { INotifyTarget } from './notify.service'; export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor'; +export { + approvalPhaseRows, + executionPhaseRows, + executorsToWrite, + useFormExecutors, + useViewExecutors +} from './useRouteExecutors'; export { resolveApproverOrgInfo } from './approverOrgInfo'; export type { IApproverOrgInfo } from './approverOrgInfo'; export { canEditRoute, composeRemoveRowRule } from './routeEditRights'; export { canEditAllFields } from './fullEditRights'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; +export { ExecutorsMatrix } from './ui/ExecutorsMatrix'; export { ApprovalHistory } from './history/ApprovalHistory'; export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed'; export { HISTORY_TEXT } from './history/historyTexts'; diff --git a/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx new file mode 100644 index 0000000..9a4e1bc --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { ISiteUser } from '../models/ISiteUser'; +import { IUseMatrixEditor } from '../useMatrixEditor'; +import { ApprovalMatrix } from './ApprovalMatrix'; + +interface IExecutorsMatrixProps { + editor: IUseMatrixEditor; + search: (query: string) => Promise; + /** Заголовок; по умолчанию «Исполнение». */ + title?: string; +} + +/** + * Таблица исполнителей — рисуется только когда в маршруте из админки они есть: + * у форм без фазы исполнения блока нет вовсе. + */ +export const ExecutorsMatrix: React.FC = ({ editor, search, title = 'Исполнение' }) => { + if (editor.loading || editor.rows.length === 0) return null; + return ( + + ); +}; diff --git a/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts b/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts new file mode 100644 index 0000000..0cd6f38 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts @@ -0,0 +1,102 @@ +import { useCallback } from 'react'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { ApprovalListService, IApproverToWrite } from './approvalList.service'; +import { getPlannedMatrix } from './approvalSubmission.service'; +import { resolveApproverOrgInfo } from './approverOrgInfo'; +import { IResolveFieldValue } from './models/IApprovalRouteApi'; +import { IMatrixRow } from './models/IMatrixRow'; +import { APPROVAL_PHASE } from './models/statuses'; +import { IUseMatrixEditor, useMatrixEditor } from './useMatrixEditor'; + +/** Строки фазы согласования (всё, что не «Исполнение») — для таблицы согласующих. */ +export const approvalPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] => + rows.filter((r) => r.phase !== APPROVAL_PHASE.execution); + +/** Строки фазы исполнения — для таблицы исполнителей. */ +export const executionPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] => + rows.filter((r) => r.phase === APPROVAL_PHASE.execution); + +/** + * Строки матрицы исполнителей → то, что пишет writeRoute. Номер шага + * нормализуется к 1..N внутри фазы: у уже записанных строк он сквозной (со + * смещением на согласующих), а writeRoute смещает фазу сам. + */ +export const executorsToWrite = (rows: IMatrixRow[]): IApproverToWrite[] => { + const base = rows.reduce((min, r) => Math.min(min, r.order), Number.MAX_SAFE_INTEGER); + return rows.map((r) => ({ + email: r.email, + position: r.position, + allowReassign: r.allowReassign, + order: r.order - base + 1, + slaHours: r.slaHours, + askOverdueReason: r.askOverdueReason, + autoApprove: r.autoApprove + })); +}; + +interface IFormExecutorsSource { + context: WebPartContext; + approval: ApprovalListService; + requestTypeId: number | undefined; + /** E-mail, по которому резолвится маршрут (инициатор/выбранный сотрудник). */ + userEmail: string; + /** Редактируемая заявка — исполнители читаются из её строк, а не планируются заново. */ + editId: number | undefined; + /** Те же параметры, что форма отдаёт getPlannedMatrix для согласующих. */ + departmentId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute + fieldValues?: IResolveFieldValue[]; + projectId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute + /** Дополнительный ключ перезагрузки (значение поля, влияющее на маршрут). */ + reloadSuffix?: string; +} + +/** + * Исполнители формы заполнения (фаза «Исполнение»): планируемые по маршруту из + * админки для новой заявки, из записанных строк — для черновика/доработки. + * Только чтение: исполнителя назначает маршрут, не заявитель. + */ +export function useFormExecutors(source: IFormExecutorsSource): IUseMatrixEditor { + const { context, approval, requestTypeId, userEmail, editId, departmentId, fieldValues, projectId, reloadSuffix = '' } = source; + const fieldValuesKey = JSON.stringify(fieldValues ?? []); + const load = useCallback( + () => + editId + ? approval.getMatrixForRequest(editId).then(executionPhaseRows) + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', departmentId, fieldValues, projectId), + // fieldValues сравниваем по содержимому: форма пересоздаёт массив на каждый рендер. + // eslint-disable-next-line react-hooks/exhaustive-deps + [context, approval, editId, requestTypeId, userEmail, departmentId, fieldValuesKey, projectId] + ); + return useMatrixEditor( + load, + `x-${userEmail}-${editId ?? 'new'}-${departmentId ?? ''}-${projectId ?? ''}-${fieldValuesKey}-${reloadSuffix}`, + false, + !!editId || (!!userEmail && !!requestTypeId), + (login) => approval.getJobTitle(login), + resolveApproverOrgInfo(context) + ); +} + +/** + * Исполнители вьюхи просмотра — из фактических строк заявки. decisionSeq в + * ключе: после решения/замены строки на сервере меняются — перечитываем. + */ +export function useViewExecutors( + context: WebPartContext, + approval: ApprovalListService, + itemId: number, + decisionSeq: number +): IUseMatrixEditor { + const load = useCallback( + () => approval.getMatrixForRequest(itemId).then(executionPhaseRows), + [approval, itemId] + ); + return useMatrixEditor( + load, + `e-${itemId}-${decisionSeq}`, + false, + true, + (l) => approval.getJobTitle(l), + resolveApproverOrgInfo(context) + ); +}