diff --git a/docs/permissions.md b/docs/permissions.md index eebf95b..43e9487 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -16,7 +16,9 @@ | Право | Кто | Как определяется | Где действует | Файл с проверкой | |---|---|---|---|---| -| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` | +| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` | +| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` | +| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` | | Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` | | Править **все поля** заявки на своём активном шаге согласования (без вложений) | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | | Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` | diff --git a/package-lock.json b/package-lock.json index f67ed01..7e37810 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@pnp/sp": "^4.20.0", "@pnp/spfx-controls-react": "^3.25.0", "@tnsu/ui-kit": "^0.12.0", + "jszip": "~3.8.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" diff --git a/package.json b/package.json index f6a56db..27d97b3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@pnp/sp": "^4.20.0", "@pnp/spfx-controls-react": "^3.25.0", "@tnsu/ui-kit": "^0.12.0", + "jszip": "~3.8.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" diff --git a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json index 4c47d65..9c5e9f3 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json +++ b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json @@ -16,6 +16,6 @@ "title": { "default": "request-portal" }, "description": { "default": "request-portal description" }, "officeFabricIconFontName": "Page", - "properties": { "notificationsEnabled": true } + "properties": { "notificationsEnabled": true, "edoApiBaseUrl": "https://10.0.1.225:8443" } }] } diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 7de97bf..32e26c2 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -19,7 +19,10 @@ import { setImpersonatedIdentity } from './shared/impersonation'; import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, + EDO_API_BASE_URL_LOCAL, + EDO_API_BASE_URL_STAGE, setApprovalApiBaseUrl, + setEdoApiBaseUrl, setNotificationSettings } from './shared/config'; import { initListPartitions } from './shared/approval'; @@ -38,6 +41,11 @@ export interface IRequestPortalWebPartProps { debugUserDisplayName: string; /** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */ approvalApiBaseUrl: string; + /** + * С каким ЭДО работаем при отправке документов на подписание — локальным или + * стейджинговым. У веб-частей, добавленных до появления свойства, оно пустое — это стейдж. + */ + edoApiBaseUrl: string; /** * Уведомления согласующим (поток Power Automate). По умолчанию включены: * у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл». @@ -58,6 +66,8 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> {/* Блок исполнителя показываем, только когда по маршруту реально есть исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */} diff --git a/src/webparts/requestPortal/forms/Clearance/README.md b/src/webparts/requestPortal/forms/Clearance/README.md index 048fa68..646084b 100644 --- a/src/webparts/requestPortal/forms/Clearance/README.md +++ b/src/webparts/requestPortal/forms/Clearance/README.md @@ -36,7 +36,7 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова - **Сумма обучения** — `MoneyInput` (разделитель тысяч в UI, в списке — «сырое» число), колонка `TrainingAmount` (Number, 2 знака), поле необязательное. - **Дата расторжения договора** — обычный `input type=date`, без вычислений - (в отличие от `EndDate` в «Заявке на отпуск», которая считается). + (в отличие от даты окончания отпуска, которой нет совсем). - **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`. ## Связь с заявлением на увольнение diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx index c3e7f14..d619235 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx @@ -13,7 +13,9 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, @@ -24,8 +26,13 @@ import { REQUEST_STATUS, isEditableStatus, IApproverToWrite, - unassignedStepMessage + unassignedStepMessage, + useFormExecutors, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -169,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] ); @@ -181,6 +188,18 @@ const ConcreteRequestForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители — из маршрута админки (заявитель их не правит); те же параметры + // резолва, что и у согласующих. + const executors = useFormExecutors({ + readOnly, + 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 => { @@ -222,6 +241,7 @@ const ConcreteRequestForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -236,7 +256,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), @@ -258,11 +278,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, @@ -323,11 +353,14 @@ const ConcreteRequestForm: React.FC = ({ approval.searchUsers(q)} waiting={!matrixReady} 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 9d41b88..0600f05 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -13,7 +13,9 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, @@ -24,8 +26,13 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - resolveWriteListId + resolveWriteListId, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, + useFormExecutors } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -157,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) } ]), @@ -171,6 +178,18 @@ const DismissalForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители самого заявления (фаза «Исполнение» маршрута из админки) — по + // тому же сотруднику и тем же fieldValues, что и согласующие выше. + const executors = useFormExecutors({ + readOnly, + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId, + fieldValues: [{ fieldName: 'Position', value: primaryPosition(data.Organization) }] + }); + // Убрать компанию из заявления (совместитель, реально не увольняется из // ВСЕХ) — см. OrganizationsTable. Снимает компанию из data.Organization И, по // возможности, соответствующего согласующего (директора/должностного @@ -243,6 +262,7 @@ const DismissalForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -257,7 +277,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), @@ -279,11 +299,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, @@ -340,7 +376,13 @@ const DismissalForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + 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 9be0af3..07f4471 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx @@ -13,10 +13,16 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, + useFormExecutors, + ExecutorsMatrix, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -26,6 +32,7 @@ import { IApproverToWrite, unassignedStepMessage } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -153,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] ); @@ -165,6 +172,17 @@ const EmployeeTransferForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители — по тому же сотруднику, что и маршрут согласования. Только чтение: + // их назначает маршрут из админки, не заявитель. + const executors = useFormExecutors({ + readOnly, + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId + }); + const isValid = !!( data.Employee && data.Position.trim() && @@ -211,6 +229,7 @@ const EmployeeTransferForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -225,7 +244,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), @@ -247,11 +266,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 }; @@ -302,7 +337,13 @@ const EmployeeTransferForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> + + approval.searchUsers(q)} /> {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} 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/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index be642be..d65ed13 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -26,6 +26,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -206,7 +207,7 @@ const RecruitingHireForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`, - false, + canEditExecutors(context, readOnly), matrixReady, (login) => approval.getJobTitle(login), resolveApproverOrgInfo(context) @@ -448,12 +449,14 @@ const RecruitingHireForm: React.FC = ({ canRemoveRow={canRemoveApprover ? () => true : undefined} /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 08a0735..11efda5 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -26,6 +26,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -236,7 +237,7 @@ const RecruitingSelectionForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`, - false, + canEditExecutors(context, readOnly), matrixReady, (login) => approval.getJobTitle(login), resolveApproverOrgInfo(context) @@ -439,12 +440,14 @@ const RecruitingSelectionForm: React.FC = ({ canRemoveRow={canRemoveApprover ? () => true : undefined} /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx index db7d1e0..531d410 100644 --- a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx +++ b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx @@ -21,6 +21,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, notifyActiveStep, useRequestTypeId, @@ -241,7 +242,7 @@ const ReferralForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${data.Referrer}-${editId ?? 'new'}`, - false, + canEditExecutors(context, readOnly), !!editId || (!!data.Referrer && !!requestTypeId), (login) => approval.getJobTitle(login), resolveApproverOrgInfo(context) @@ -470,12 +471,14 @@ const ReferralForm: React.FC = ({ canRemoveRow={canRemoveApprover ? () => true : undefined} /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title={noApprovalPhase ? 'Исполнение' : 'Исполнение (после согласования)'} roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx index 7cd9029..9a5e95d 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -13,6 +13,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -24,7 +25,12 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - resolveWriteListId + resolveWriteListId, + useFormExecutors, + ExecutorsMatrix, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus } from '../../shared/approval'; import { uploadAttachments, @@ -181,13 +187,24 @@ 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({ + readOnly, + context, + approval, + requestTypeId, + userEmail: matrixReady ? userEmail : '', + editId, + projectId + }); const hasCosts = hasAnyCostItem(data.Costs); const isValid = @@ -273,7 +290,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 +311,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 +331,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, @@ -384,11 +416,14 @@ const ServiceRequestForm: React.FC = ({ approval.searchUsers(q)} waiting={!matrixReady} 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..120d91e 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx @@ -20,12 +20,16 @@ import { ApprovalMatrix, ApprovalHistory, useMatrixEditor, + useViewExecutors, + ExecutorsMatrix, + approvalPhaseRows, notifyActiveStep, useRequestTypeId, isEditableStatus } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { useStableCallback } from '../../shared/useStableCallback'; import { approvalListTitle, approvalListTitleForListId, @@ -54,6 +58,9 @@ interface IServiceRequestViewProps { // Заявка «Заявка на услуги» только для просмотра (режим согласующего). const ServiceRequestView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта + // загрузки идёт стабильная обёртка, иначе заявка перезапрашивалась бы при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -77,10 +84,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; @@ -91,7 +99,7 @@ const ServiceRequestView: React.FC = ({ context, itemI setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -106,7 +114,7 @@ const ServiceRequestView: React.FC = ({ context, itemI return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); // Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection). // Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров, @@ -180,6 +188,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/SkudExplanation/SkudExplanationForm.tsx b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx index 9f5c7c7..7f69916 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx @@ -13,7 +13,9 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, + canEditExecutors, ApprovalHistory, StatusBadge, notifyActiveStep, @@ -26,6 +28,7 @@ import { resolveWriteListId, usePhasedRoute } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -132,7 +135,8 @@ const SkudExplanationForm: React.FC = ({ requestId: editId, employeeEmail: data.Employee, requestTypeId, - editable: !readOnly + editable: !readOnly, + executorsEditable: canEditExecutors(context, readOnly) }); const validate = (): string | undefined => { @@ -266,14 +270,20 @@ const SkudExplanationForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts index 6a95c96..ec0dd56 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts @@ -87,8 +87,8 @@ describe('cellKind / toMissedKind', () => { }); it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => { - expect(cells[0]).toBeNull(); - expect(cells[1]).toBeNull(); + expect(cells[0]).toBeUndefined(); + expect(cells[1]).toBeUndefined(); expect(cells[2]?.day).toBe(1); }); }); diff --git a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts index 8057192..051a6bd 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts @@ -130,12 +130,12 @@ export function monthWorkedHours(byDay: Map): number { return total; } -/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */ -export function buildMonthCells(year: number, month: number, byDay: Map): (IDayInfo | null)[] { +/** Ячейки месяца: ведущие undefined — пустые места до первого числа (неделя с понедельника). */ +export function buildMonthCells(year: number, month: number, byDay: Map): (IDayInfo | undefined)[] { const daysInMonth = new Date(year, month + 1, 0).getDate(); const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK; - const cells: (IDayInfo | null)[] = []; - for (let i = 0; i < firstColumn; i += 1) cells.push(null); + const cells: (IDayInfo | undefined)[] = []; + for (let i = 0; i < firstColumn; i += 1) cells.push(undefined); for (let d = 1; d <= daysInMonth; d += 1) { const key = toDateKey(year, month, d); cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' }); diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index da6e9c3..f166573 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -29,7 +29,8 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - APPROVAL_PHASE + APPROVAL_PHASE, + executorsToWrite } from '../../shared/approval'; import { uploadAttachments, @@ -306,7 +307,7 @@ const Ticket1CForm: React.FC = ({ requestId, data, toApprovers(matrixRows), - toApprovers(executors.rows), + executorsToWrite(executors.rows), finalStatus, writeListId ); diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 071562f..c80904d 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -17,10 +17,12 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, notifyActiveStep, useRequestTypeId, @@ -327,7 +329,7 @@ const TmcTransferForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}-${projectId}-${targetProjectId}`, - false, + canEditExecutors(context, readOnly), executorsReady, (login) => approval.getJobTitle(login) ); @@ -512,6 +514,7 @@ const TmcTransferForm: React.FC = ({ approval.searchUsers(q)} waiting={!matrixReady} waitingText="Укажите заявителя и проект-источник («Откуда»), чтобы построить маршрут согласования." @@ -519,12 +522,14 @@ const TmcTransferForm: React.FC = ({ {/* Блок исполнителя показываем, только когда по маршруту реально есть исполнители (грузится по проектам, см. executorsReady). */} - {executorsReady && !executors.loading && executors.rows.length > 0 && ( + {executorsReady && !executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/README.md b/src/webparts/requestPortal/forms/UnpaidLeave/README.md index 277c2cb..f3ad67f 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/README.md +++ b/src/webparts/requestPortal/forms/UnpaidLeave/README.md @@ -25,11 +25,11 @@ - **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`. ## Отличия от «Заявка на отпуск» (hr-vacation) -- Даты с/по вместо «дата начала + количество дней». +- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются. - Добавлены поля «В связи с» (`Reason`) и «Лицо замещающее» (`Substitute`). ## Секции формы -RequestSection (сотрудник · должность · дата начала · дата окончания · основание · замещающий) +RequestSection (сотрудник · должность · дата начала · количество дней · основание · замещающий) ## Точки входа - rptype: `hr-unpaid-leave` diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx index 181407b..ca38166 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx @@ -13,10 +13,16 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, + useFormExecutors, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, getPlannedMatrix, notifyActiveStep, useRequestTypeId, @@ -26,6 +32,7 @@ import { unassignedStepMessage, resolveWriteListId } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -154,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] ); @@ -166,8 +173,19 @@ const UnpaidLeaveForm: React.FC = ({ (login) => approval.getJobTitle(login) ); - // Даты корректны, если конец не раньше начала (обе обязательны). - const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate); + // Исполнители фазы «Исполнение» — по маршруту из админки, только чтение. + const executors = useFormExecutors({ + readOnly, + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId + }); + + // Продолжительность: целое положительное число календарных дней. + const daysNum = Number(data.DaysCount); + const daysValid = Number.isFinite(daysNum) && daysNum > 0; // Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала // отпуска — календарь (min у input type=date, см. RequestSection) не даёт // выбрать более раннюю дату, но проверяем и здесь на случай обхода UI. @@ -176,7 +194,8 @@ const UnpaidLeaveForm: React.FC = ({ const isValid = !!( data.Employee && data.Position.trim() && - datesValid && + data.StartDate && + daysValid && isStartDateValid && data.Reason.trim() ); @@ -184,10 +203,10 @@ const UnpaidLeaveForm: React.FC = ({ if (data.StartDate && !isStartDateValid) { return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`; } - if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.EndDate || !data.Reason.trim()) { - return 'Заполните обязательные поля: сотрудник, должность, дата начала, дата окончания, основание.'; + if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.Reason.trim()) { + return 'Заполните обязательные поля: сотрудник, должность, дата начала, основание.'; } - if (!datesValid) return 'Дата окончания не может быть раньше даты начала.'; + if (!daysValid) return 'Количество дней должно быть больше нуля.'; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; return undefined; @@ -223,6 +242,7 @@ const UnpaidLeaveForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -237,7 +257,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), @@ -259,11 +279,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 }; @@ -314,7 +345,13 @@ const UnpaidLeaveForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> + + approval.searchUsers(q)} /> {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} 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/load.ts b/src/webparts/requestPortal/forms/UnpaidLeave/load.ts index 4d2867b..605a479 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/load.ts +++ b/src/webparts/requestPortal/forms/UnpaidLeave/load.ts @@ -28,7 +28,7 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P 'Id', 'Position', 'StartDate', - 'EndDate', + 'DaysCount', 'Reason', 'Organization', 'Department', @@ -46,14 +46,14 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P const employee = item.Employee as { Title?: string; EMail?: string } | undefined; const substitute = item.Substitute as { EMail?: string } | undefined; const start = (item.StartDate as string) || ''; - const end = (item.EndDate as string) || ''; + const days = item.DaysCount; const data: IFormData = { ...EMPTY_DATA, Employee: employee?.EMail || '', Position: (item.Position as string) || '', StartDate: spDateOnlyToIso(start), - EndDate: spDateOnlyToIso(end), + DaysCount: days === null || days === undefined ? '' : String(days), Reason: (item.Reason as string) || '', Substitute: substitute?.EMail || '', Organization: (item.Organization as string) || '', diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/model.ts b/src/webparts/requestPortal/forms/UnpaidLeave/model.ts index 4259f10..b70bd49 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/model.ts +++ b/src/webparts/requestPortal/forms/UnpaidLeave/model.ts @@ -12,8 +12,8 @@ export const FORM_ID: RequestTypeId = 'hr-unpaid-leave'; * по возможности = internalName колонок списка данных (см. shared/listTitles.ts). * Employee/Substitute хранят e-mail выбранного сотрудника (то, что отдаёт * PeopleField); по Employee резолвится маршрут согласования, поэтому заявку можно - * подать и за другого сотрудника. В отличие от «Заявки на отпуск» здесь обе даты - * (с/по) вводятся явно, а DaysCount считается при сохранении. + * подать и за другого сотрудника. Как и в «Заявке на отпуск», даты окончания нет: + * отпуск задаётся началом и количеством дней, чтобы не считать праздники и выходные. */ export interface IFormData { /** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */ @@ -21,8 +21,8 @@ export interface IFormData { Position: string; /** ISO YYYY-MM-DD (input type=date). */ StartDate: string; - /** ISO YYYY-MM-DD (input type=date). */ - EndDate: string; + /** Количество календарных дней (строка из input type=number). */ + DaysCount: string; /** Основание «В связи с …». */ Reason: string; /** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */ @@ -68,7 +68,7 @@ export const EMPTY_DATA: IFormData = { Employee: '', Position: '', StartDate: '', - EndDate: '', + DaysCount: '', Reason: '', Substitute: '', Organization: '', diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/provisioning.json b/src/webparts/requestPortal/forms/UnpaidLeave/provisioning.json index 8c994ba..c53f544 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/provisioning.json +++ b/src/webparts/requestPortal/forms/UnpaidLeave/provisioning.json @@ -7,7 +7,6 @@ { "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, { "internalName": "Position", "title": "Должность", "type": "Text" }, { "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true }, - { "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true }, { "internalName": "DaysCount", "title": "Количество дней", "type": "Number" }, { "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" }, { "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" }, diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/sections/RequestSection.tsx index bf35601..ece13c1 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/sections/RequestSection.tsx @@ -51,8 +51,8 @@ export const RequestSection: React.FC = ({ data, set, context, on min={isoDatePlusDays(MIN_NOTICE_DAYS)} /> - - set('EndDate', v)} /> + + set('DaysCount', v)} />