diff --git a/docs/permissions.md b/docs/permissions.md index 17d045c..43e9487 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -18,6 +18,7 @@ |---|---|---|---|---| | Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | 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/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx index f31a471..d619235 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx @@ -191,6 +191,7 @@ const ConcreteRequestForm: React.FC = ({ // Исполнители — из маршрута админки (заявитель их не правит); те же параметры // резолва, что и у согласующих. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx index 151d50e..0600f05 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -181,6 +181,7 @@ const DismissalForm: React.FC = ({ // Исполнители самого заявления (фаза «Исполнение» маршрута из админки) — по // тому же сотруднику и тем же fieldValues, что и согласующие выше. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx index 6a0ab99..07f4471 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx @@ -175,6 +175,7 @@ const EmployeeTransferForm: React.FC = ({ // Исполнители — по тому же сотруднику, что и маршрут согласования. Только чтение: // их назначает маршрут из админки, не заявитель. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 22e01f7..c86d129 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -24,6 +24,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -204,7 +205,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) @@ -460,12 +461,13 @@ 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="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index f15cb44..b6342ad 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -24,6 +24,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -233,7 +234,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) @@ -450,12 +451,13 @@ 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="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx index 9c55fe6..5d41623 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, @@ -240,7 +241,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) @@ -469,12 +470,13 @@ 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="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx index 405faf6..b2aa3c8 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -196,6 +196,7 @@ const ServiceRequestForm: React.FC = ({ // Исполнители резолвятся тем же маршрутом и с тем же проектом, что и согласующие: // пока проект не выбран, e-mail пустой — useFormExecutors не грузит маршрут (404). const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx index 359b075..a06538d 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx @@ -15,6 +15,7 @@ import { import { composeRemoveRowRule, ApprovalMatrix, + canEditExecutors, ApprovalHistory, StatusBadge, notifyActiveStep, @@ -136,7 +137,8 @@ const SkudExplanationForm: React.FC = ({ requestId: editId, employeeEmail: data.Employee, requestTypeId, - editable: !readOnly + editable: !readOnly, + executorsEditable: canEditExecutors(context, readOnly) }); const validate = (): string | undefined => { @@ -276,12 +278,13 @@ const SkudExplanationForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts b/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts index e798198..fd3d7d3 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts @@ -14,7 +14,7 @@ type MatrixEditor = ReturnType; export interface ISkudRoute { /** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */ approvers: MatrixEditor; - /** Исполнители по маршруту (фаза «Исполнение») — только чтение. */ + /** Исполнители по маршруту (фаза «Исполнение») — только чтение, кроме редактора маршрута. */ executors: MatrixEditor; } @@ -27,6 +27,8 @@ interface IRouteSource { requestTypeId: number | undefined; /** Можно ли править согласующих. */ editable: boolean; + /** Можно ли править исполнителей (только редактору маршрута, см. canEditExecutors). */ + executorsEditable?: boolean; /** Меняется после решения согласующего — перечитать строки. */ reloadSeq?: number; } @@ -40,7 +42,7 @@ const byPhase = (execution: boolean) => (rows: IMatrixRow[]): IMatrixRow[] => * маршрута «пропадал» бы. Паттерн тот же, что в Referral. */ export function useSkudRoute(source: IRouteSource): ISkudRoute { - const { context, approval, requestId, employeeEmail, requestTypeId, editable, reloadSeq = 0 } = source; + const { context, approval, requestId, employeeEmail, requestTypeId, editable, executorsEditable = false, reloadSeq = 0 } = source; const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId); const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`; @@ -62,6 +64,6 @@ export function useSkudRoute(source: IRouteSource): ISkudRoute { const jobTitle = (login: string): Promise => approval.getJobTitle(login); const approvers = useMatrixEditor(loadApprovers, reloadKey, editable, canLoad, jobTitle, resolveApproverOrgInfo(context)); - const executors = useMatrixEditor(loadExecutors, reloadKey, false, canLoad, jobTitle, resolveApproverOrgInfo(context)); + const executors = useMatrixEditor(loadExecutors, reloadKey, executorsEditable, canLoad, jobTitle, resolveApproverOrgInfo(context)); return { approvers, executors }; } diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index a2af1ef..e394b6a 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -194,6 +194,7 @@ const Ticket1CForm: React.FC = ({ // Исполнители (фаза «Исполнение») резолвятся с теми же fieldValues, что и // согласующие: условные шаги маршрута зависят от вида работ и отдела. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 6676050..bfb23d4 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -22,6 +22,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, notifyActiveStep, useRequestTypeId, @@ -314,7 +315,7 @@ const TmcTransferForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}-${projectId}-${targetProjectId}`, - false, + canEditExecutors(context, readOnly), executorsReady, (login) => approval.getJobTitle(login) ); @@ -507,12 +508,13 @@ 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="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx index cd5ac84..c737fab 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx @@ -175,6 +175,7 @@ const UnpaidLeaveForm: React.FC = ({ // Исполнители фазы «Исполнение» — по маршруту из админки, только чтение. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index 2220059..0911ea8 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -177,6 +177,7 @@ const VacationForm: React.FC = ({ // Исполнители из маршрута админки (фаза «Исполнение»): только чтение, таблица // и запись в заявку — когда они в маршруте есть. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 77c2199..0947f02 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -22,6 +22,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -213,7 +214,7 @@ const WorkplaceSetupForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`, - false, + canEditExecutors(context, readOnly), !!editId || !!requestTypeId, (login) => approval.getJobTitle(login) ); @@ -398,12 +399,13 @@ const WorkplaceSetupForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 56b8b04..d089037 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -72,7 +72,7 @@ export { } from './useRouteExecutors'; export { resolveApproverOrgInfo } from './approverOrgInfo'; export type { IApproverOrgInfo } from './approverOrgInfo'; -export { canEditRoute, composeRemoveRowRule } from './routeEditRights'; +export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights'; export { canEditAllFields } from './fullEditRights'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { ExecutorsMatrix } from './ui/ExecutorsMatrix'; diff --git a/src/webparts/requestPortal/shared/approval/routeEditRights.ts b/src/webparts/requestPortal/shared/approval/routeEditRights.ts index e780671..ce16e29 100644 --- a/src/webparts/requestPortal/shared/approval/routeEditRights.ts +++ b/src/webparts/requestPortal/shared/approval/routeEditRights.ts @@ -1,3 +1,6 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { effectiveUserEmail } from '../impersonation'; + /** * Кто может удалять согласующих из маршрута (и при составлении заявки, и на * своём шаге согласования) — сверх обычных прав инициатора/согласующего. @@ -30,3 +33,15 @@ export function canEditRoute(email: string | undefined): boolean { export function composeRemoveRowRule(email: string | undefined): (() => boolean) | undefined { return canEditRoute(email) ? () => true : undefined; } + +/** + * Может ли текущий пользователь править состав ИСПОЛНИТЕЛЕЙ (добавлять и удалять) + * при составлении заявки: те же редакторы маршрута, что и для согласующих. + * Исполнителей по умолчанию назначает маршрут из админки, заявителю они только + * показываются; редактору маршрута матрица «Исполнение» становится редактируемой. + * Учитывает подмену личности (effectiveUserEmail). readOnly — заявка уже не + * редактируется (отправлена), тогда править нельзя никому. + */ +export function canEditExecutors(context: WebPartContext, readOnly: boolean): boolean { + return !readOnly && canEditRoute(effectiveUserEmail(context)); +} diff --git a/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx index 9a4e1bc..369f457 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx @@ -12,16 +12,18 @@ interface IExecutorsMatrixProps { /** * Таблица исполнителей — рисуется только когда в маршруте из админки они есть: - * у форм без фазы исполнения блока нет вовсе. + * у форм без фазы исполнения блока нет вовсе (кроме редактора маршрута, см. canEditExecutors). */ export const ExecutorsMatrix: React.FC = ({ editor, search, title = 'Исполнение' }) => { - if (editor.loading || editor.rows.length === 0) return null; + // Редактор маршрута видит блок и пустым — иначе исполнителя некуда добавить. + if (editor.loading || (editor.rows.length === 0 && !editor.editable)) return null; return ( ); diff --git a/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts b/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts index 0cd6f38..6e87107 100644 --- a/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts +++ b/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts @@ -6,6 +6,7 @@ import { resolveApproverOrgInfo } from './approverOrgInfo'; import { IResolveFieldValue } from './models/IApprovalRouteApi'; import { IMatrixRow } from './models/IMatrixRow'; import { APPROVAL_PHASE } from './models/statuses'; +import { canEditExecutors } from './routeEditRights'; import { IUseMatrixEditor, useMatrixEditor } from './useMatrixEditor'; /** Строки фазы согласования (всё, что не «Исполнение») — для таблицы согласующих. */ @@ -46,6 +47,8 @@ interface IFormExecutorsSource { departmentId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute fieldValues?: IResolveFieldValue[]; projectId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute + /** Заявка не редактируется — правка состава исполнителей закрыта для всех. */ + readOnly?: boolean; /** Дополнительный ключ перезагрузки (значение поля, влияющее на маршрут). */ reloadSuffix?: string; } @@ -53,10 +56,11 @@ interface IFormExecutorsSource { /** * Исполнители формы заполнения (фаза «Исполнение»): планируемые по маршруту из * админки для новой заявки, из записанных строк — для черновика/доработки. - * Только чтение: исполнителя назначает маршрут, не заявитель. + * Заявителю — только чтение: исполнителя назначает маршрут. Редактор маршрута + * (см. canEditExecutors) может добавлять и удалять исполнителей. */ export function useFormExecutors(source: IFormExecutorsSource): IUseMatrixEditor { - const { context, approval, requestTypeId, userEmail, editId, departmentId, fieldValues, projectId, reloadSuffix = '' } = source; + const { context, approval, requestTypeId, userEmail, editId, departmentId, fieldValues, projectId, reloadSuffix = '', readOnly = false } = source; const fieldValuesKey = JSON.stringify(fieldValues ?? []); const load = useCallback( () => @@ -70,7 +74,7 @@ export function useFormExecutors(source: IFormExecutorsSource): IUseMatrixEditor return useMatrixEditor( load, `x-${userEmail}-${editId ?? 'new'}-${departmentId ?? ''}-${projectId ?? ''}-${fieldValuesKey}-${reloadSuffix}`, - false, + canEditExecutors(context, readOnly), !!editId || (!!userEmail && !!requestTypeId), (login) => approval.getJobTitle(login), resolveApproverOrgInfo(context)