diff --git a/docs/permissions.md b/docs/permissions.md index b0b51f4..f0b6fe3 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -20,7 +20,9 @@ | Удалять любых согласующих **при составлении** заявки | те же (`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` | +| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | +| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` | +| Заполнять «Необходимое ПО» и «Доступы» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) | | Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` | | Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) | | Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` | diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index b9de697..e487cb1 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -183,7 +183,7 @@ const RecruitingHireView: React.FC = ({ context, itemI const isExecution = pending?.phase === APPROVAL_PHASE.execution; setPayrollEditable(isActive && !isExecution && canEditPayrollFields(pending?.position)); setHireDateEditable(isActive && isExecution); - setFullEditable(isActive && !isExecution && canEditAllFields(effectiveUserEmail(context))); + setFullEditable(isActive && !isExecution && canEditAllFields(FORM_ID, effectiveUserEmail(context))); }) .catch(() => { if (!alive) return; diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index 1233b48..949a78b 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -35,7 +35,7 @@ import { } from '../../shared/approval'; import { getAttachments } from '../../shared/attachments/attachments.service'; import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; -import { SelectionAttachments } from './SelectionAttachments'; +import { EditableAttachments } from '../../shared/attachments/EditableAttachments'; import { effectiveUserEmail } from '../../shared/impersonation'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { @@ -152,7 +152,7 @@ const RecruitingSelectionView: React.FC = ({ cont if (!alive) return; const isActiveApproval = !!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution; - setFullEditable(isActiveApproval && canEditAllFields(effectiveUserEmail(context))); + setFullEditable(isActiveApproval && canEditAllFields(FORM_ID, effectiveUserEmail(context))); }) .catch(() => { if (alive) setFullEditable(false); @@ -348,7 +348,7 @@ const RecruitingSelectionView: React.FC = ({ cont
- +
diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/README.md b/src/webparts/requestPortal/forms/WorkplaceSetup/README.md index 38392dc..01cd5ac 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/README.md +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/README.md @@ -15,6 +15,9 @@ ФИО, телефон, должность, отдел/участок, вид работ, местоположение, дата приёма (`loadPrefillFromHire`). Это снимок на момент создания; ссылка на источник сохраняется в колонке-lookup `SourceHire`. +- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол + Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На + согласовании»/«Выполнение» (`useApproverRights.ts`). - **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до **первого** шага согласования и это текущий пользователь; сохраняются кнопкой diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index d01bd5d..9f67c2b 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -14,8 +14,11 @@ import { RequestNumber, SubmitActions, getDraftDeleteHandler, - useRequestSubmit + useRequestSubmit, + FieldChangesProvider, + FieldChangesBanner } from '../../shared/ui'; +import { IFieldChange, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { composeRemoveRowRule, ApprovalMatrix, @@ -53,6 +56,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { saveWorkplaceRequest, workplaceApprovalService } from './submit'; import { loadPrefillFromHire } from './prefill'; import { loadWorkplaceRequest } from './load'; +import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation'; import { EmployeeSection } from './sections/EmployeeSection'; import { EquipmentSection } from './sections/EquipmentSection'; import { useStableCallback } from '../../shared/useStableCallback'; @@ -75,6 +79,8 @@ const WorkplaceSetupForm: React.FC = ({ const [requestId, setRequestId] = useState(editId); const [requestStatus, setRequestStatus] = useState(undefined); const [requestCode, setRequestCode] = useState(undefined); + // Журнал правок согласующего — виден и здесь, когда заявку вернули на доработку. + const [approverChanges, setApproverChanges] = useState([]); const submit = useRequestSubmit(); // onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка // может меняться на каждом рендере, поэтому в зависимости эффектов идёт @@ -102,6 +108,7 @@ const WorkplaceSetupForm: React.FC = ({ .then(([loaded, atts]) => { if (alive) { setData(loaded.data); + setApproverChanges(loaded.approverChanges); setRequestStatus(loaded.status); setRequestCode(loaded.code || undefined); if (loaded.code) notifyCode(loaded.code); @@ -219,13 +226,9 @@ const WorkplaceSetupForm: React.FC = ({ (login) => approval.getJobTitle(login) ); - const isValid = - data.FullName.trim().length > 0 && - data.NameLatin.trim().length > 0 && - data.Position.trim().length > 0 && - data.Companies.length > 0; + const isValid = hasRequiredFields(data); const validate = (): string | undefined => { - if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Фамилия и имя (латиница), Должность, Организация.'; + if (!isValid) return REQUIRED_FIELDS_MESSAGE; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; return undefined; @@ -378,10 +381,13 @@ const WorkplaceSetupForm: React.FC = ({ )} {submit.error && {submit.error}} - - - - + + + + + + + {!readOnly && ( diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss index d96d6bc..675cc6b 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.module.scss @@ -18,6 +18,7 @@ .reviewBar { display: flex; + flex-wrap: wrap; align-items: center; gap: $sp-3; margin-bottom: $sp-4; diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx index be55831..97e56ef 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -8,12 +8,11 @@ import { FormCard, InitiatorLine, FormModeProvider, - CollapseSection, - FormField, - AttachmentList, PrintSplitButton, OrgLetterhead, - Icon + Icon, + FieldChangesProvider, + FieldChangesBanner } from '../../shared/ui'; import { navigate } from '../../shared/routing'; import { @@ -26,9 +25,14 @@ import { notifyActiveStep, useRequestTypeId, isEditableStatus, - APPROVAL_PHASE + APPROVAL_PHASE, + ApproverDecision } from '../../shared/approval'; -import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { getAttachments } from '../../shared/attachments/attachments.service'; +import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; +import { EditableAttachments } from '../../shared/attachments/EditableAttachments'; +import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { approvalListTitle, @@ -38,7 +42,10 @@ import { } from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField, organizationText } from './model'; import { loadWorkplaceRequest } from './load'; -import { workplaceApprovalService, updateReviewFields } from './submit'; +import { workplaceApprovalService, updateReviewFields, updateAllWorkplaceFields } from './submit'; +import { WORKPLACE_CHANGE_TRACKING } from './changeTracking'; +import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation'; +import { useApproverRights } from './useApproverRights'; import { EmployeeSection } from './sections/EmployeeSection'; import { EquipmentSection } from './sections/EquipmentSection'; import { useStableCallback } from '../../shared/useStableCallback'; @@ -56,9 +63,14 @@ interface IWorkplaceSetupViewProps { type SaveState = 'idle' | 'saving' | 'saved' | 'error'; +const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».'; +const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.'; + // Заявка «орг. рабочего места» только для просмотра (режим согласующего). -// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель) -// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает). +// Исключения (оба — только на своём активном шаге, см. useApproverRights): +// - первый согласующий шаг (по маршруту — непосредственный руководитель) может +// заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает); +// - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения. const WorkplaceSetupView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки @@ -66,13 +78,17 @@ const WorkplaceSetupView: React.FC = ({ context, itemI // при каждом рендере родителя. const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); + // То, что сейчас лежит в заявке на сервере (до несохранённых правок), — с ним + // сравниваем при сохранении, чтобы записать в журнал только реальные правки. + const [savedData, setSavedData] = useState(EMPTY_DATA); + // Журнал правок согласующего с полным правом — подсветка полей и «Было: …». + const [approverChanges, setApproverChanges] = useState([]); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); - const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [canEditFields, setCanEditFields] = useState(false); const [saveState, setSaveState] = useState('idle'); + const [saveErrorMessage, setSaveErrorMessage] = useState(''); // Какая из организаций заявителя (см. OrganizationField) идёт в печать — // выбор в тулбаре через PrintSplitButton, по умолчанию первая. const [printOrgIndex, setPrintOrgIndex] = useState(0); @@ -85,10 +101,11 @@ const WorkplaceSetupView: React.FC = ({ context, itemI // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). const dataListRef = listId || dataListTitle(FORM_ID); - const approverService = useMemo( - () => new ApproverService(workplaceApprovalService(listId), dataListRef), - [listId, dataListRef] - ); + const approval = useMemo(() => workplaceApprovalService(listId), [listId]); + const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); + const attachments = useAttachmentEditor(dataListRef, itemId); + // Сеттер стабилен (useState) — безопасно класть в зависимости эффекта загрузки. + const { setItems: setAttachmentItems } = attachments; // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. // @@ -98,7 +115,6 @@ const WorkplaceSetupView: React.FC = ({ context, itemI // перечитывает матрицу. Иначе на экране остались бы данные, загруженные при // открытии формы, и пользователь видел бы свой шаг всё ещё «На согласовании». const [decisionSeq, setDecisionSeq] = useState(0); - const approval = useMemo(() => workplaceApprovalService(listId), [listId]); const loadApprovers = useCallback( () => approval @@ -120,17 +136,28 @@ const WorkplaceSetupView: React.FC = ({ context, itemI approval.getJobTitle(l) ); + const { canEditReviewFields, canEditAll } = useApproverRights( + context, + approval, + approverService, + itemId, + status, + decisionSeq + ); + useEffect(() => { let alive = true; setLoading(true); - Promise.all([loadWorkplaceRequest(itemId, listId), getAttachments(listId || dataListTitle(FORM_ID), itemId)]) + Promise.all([loadWorkplaceRequest(itemId, listId), getAttachments(dataListRef, itemId)]) .then(([loaded, files]) => { if (alive) { setData(loaded.data); + setSavedData(loaded.data); + setApproverChanges(loaded.approverChanges); setStatus(loaded.status); setCode(loaded.code); notifyCode(loaded.code); - setAttachments(files); + setAttachmentItems(files); setError(null); setLoading(false); } @@ -144,47 +171,45 @@ const WorkplaceSetupView: React.FC = ({ context, itemI return () => { alive = false; }; - }, [itemId, listId, notifyCode]); + }, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]); - // Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования - // (по маршруту — непосредственного руководителя) и это текущий пользователь. - useEffect(() => { - let alive = true; - Promise.all([ - approval.getForRequest(itemId), - approverService.getPendingRowForCurrentUser(itemId) - ]) - .then(([rows, pending]) => { - if (!alive) return; - const orders = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution).map((r) => r.order); - const firstOrder = orders.length > 0 ? Math.min(...orders) : undefined; - setCanEditFields( - !!pending && pending.phase !== APPROVAL_PHASE.execution && pending.order === firstOrder - ); - }) - .catch(() => { - if (alive) setCanEditFields(false); - }); - return () => { - alive = false; - }; - }, [itemId, approval, approverService]); + const saveAllFields = async (): Promise => { + const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) }; + const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date()); + await updateAllWorkplaceFields(itemId, data, nextChanges, listId); + setApproverChanges(nextChanges); + setSavedData(data); + await attachments.uploadPending(); + }; - const saveReviewFields = useCallback( - () => updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId), - [itemId, data.Software, data.AccessRights, listId] - ); + const saveReviewFields = (): Promise => + updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId); + + // Полное право шире: оно включает и «ПО»/«Доступы». + const saveFields = (): Promise => (canEditAll ? saveAllFields() : saveReviewFields()); const handleSave = async (): Promise => { setSaveState('saving'); + setSaveErrorMessage(''); try { - await saveReviewFields(); + await saveFields(); setSaveState('saved'); - } catch { + } catch (e) { + setSaveErrorMessage(friendlyErrorMessage(e)); setSaveState('error'); } }; + // Правки сохраняются вместе с решением — иначе решение ушло бы со старыми + // данными. Согласовать с очищенным обязательным полем нельзя; вернуть/ + // отклонить — можно (бросок отменяет решение, текст показывает DecisionPanel). + const handleBeforeDecide = async (decision: ApproverDecision): Promise => { + if (canEditAll && decision === 'approve' && !hasRequiredFields(data)) { + throw new Error(REQUIRED_FIELDS_MESSAGE); + } + await saveFields(); + }; + if (loading) { return
Загрузка заявки…
; } @@ -197,6 +222,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI } const sectionProps = { data, set, context, printOrgIndex }; + const canEditAnything = canEditAll || canEditReviewFields; const notify = (): void => { if (!requestTypeId) return; @@ -222,16 +248,16 @@ const WorkplaceSetupView: React.FC = ({ context, itemI - {canEditFields && ( + {canEditAnything && (
- - Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы». - + {canEditAll ? FULL_EDIT_HINT : REVIEW_FIELDS_HINT} {saveState === 'saved' && Сохранено} - {saveState === 'error' && Ошибка сохранения} + {saveState === 'error' && ( + {saveErrorMessage || 'Ошибка сохранения'} + )}
)} @@ -248,17 +274,16 @@ const WorkplaceSetupView: React.FC = ({ context, itemI } headerRight={status ? : undefined} > - - - - + + + + + + +
- - - - - +
@@ -286,7 +311,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI service={approverService} requestId={itemId} search={(q) => approval.searchUsers(q)} - onBeforeDecide={canEditFields ? saveReviewFields : undefined} + onBeforeDecide={canEditAnything ? handleBeforeDecide : undefined} onDecided={(newStatus) => { setStatus(newStatus); setDecisionSeq((n) => n + 1); diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/changeTracking.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/changeTracking.ts new file mode 100644 index 0000000..b884169 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/changeTracking.ts @@ -0,0 +1,14 @@ +import { IChangeTrackingConfig } from '../../shared/fieldChanges/fieldChanges'; +import { IFormData } from './model'; + +/** Колонка журнала правок согласующего (Note, JSON) — см. provisioning.json. */ +export const APPROVER_CHANGES_FIELD = 'WorkplaceApproverChangesJson'; + +/** + * Что пишем в журнал правок согласующего (shared/fieldChanges). Id отдела и + * проекта своего поля не имеют — меняются вместе с «Подразделение» и «Проект»; + * Organization — текстовая копия Companies (см. model.ts), правка видна по Companies. + */ +export const WORKPLACE_CHANGE_TRACKING: IChangeTrackingConfig = { + ignore: ['DepartmentId', 'ProjectId', 'Organization'] +}; diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts index 3170777..f69ca25 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/load.ts @@ -2,7 +2,9 @@ import { getSP } from '../../shared/pnp/pnpjs'; import { dataListTitle } from '../../shared/listTitles'; import { spDateOnlyToIso } from '../../shared/dateUtils'; import { ICompany } from '../../shared/dictionary'; +import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model'; +import { APPROVER_CHANGES_FIELD } from './changeTracking'; /** * JSON выбранных компаний → массив. Пусто — заявка до мультивыбора (тогда @@ -28,6 +30,8 @@ export interface ILoadedRequest { status: string; /** Уникальный код заявки; '' — если запись создана до появления поля. */ code: string; + /** Журнал правок согласующего (подсветка полей) — см. shared/fieldChanges. */ + approverChanges: IFieldChange[]; } /** @@ -42,7 +46,14 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID)); const item = (await list .items.getById(itemId) - .select(...SCALAR_FIELDS, 'NeedsEquipment', COMPANIES_JSON_FIELD, 'RequestStatus', 'RequestUniqueCode')() + .select( + ...SCALAR_FIELDS, + 'NeedsEquipment', + COMPANIES_JSON_FIELD, + APPROVER_CHANGES_FIELD, + 'RequestStatus', + 'RequestUniqueCode' + )() ) as Record; const data: IFormData = { ...EMPTY_DATA }; @@ -62,5 +73,10 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro data.NeedsEquipment = item.NeedsEquipment === true; data.Companies = parseCompanies(item[COMPANIES_JSON_FIELD]); - return { data, status: (item.RequestStatus as string) || '', code: (item.RequestUniqueCode as string) || '' }; + return { + data, + status: (item.RequestStatus as string) || '', + code: (item.RequestUniqueCode as string) || '', + approverChanges: parseFieldChanges(item[APPROVER_CHANGES_FIELD]) + }; } diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json b/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json index 8474026..9d58b9c 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/provisioning.json @@ -21,6 +21,7 @@ { "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" }, { "internalName": "Software", "title": "Необходимое ПО", "type": "Note" }, { "internalName": "AccessRights", "title": "Доступы", "type": "Note" }, + { "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" }, { "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" } ] diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EquipmentSection.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EquipmentSection.tsx index 4fe6bd6..f312ee8 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EquipmentSection.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/EquipmentSection.tsx @@ -15,7 +15,7 @@ export const EquipmentSection: React.FC = ({ data, set, return ( - + set('NeedsEquipment', v)} onLabel="Да" /> diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts index c9e619a..0222afa 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts @@ -1,6 +1,8 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList'; +import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges'; import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, organizationText } from './model'; +import { APPROVER_CHANGES_FIELD } from './changeTracking'; /** * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над @@ -30,14 +32,31 @@ export async function updateReviewFields( }); } +/** + * Записать все поля заявки, которые поправил согласующий с полным правом + * (см. shared/approval/fullEditRights.ts), — вместе с журналом его правок + * (подсветка полей, см. shared/fieldChanges). Статус, маршрут и вложения не + * трогаются: заявка остаётся на том же шаге согласования. + */ +export async function updateAllWorkplaceFields( + id: number, + data: IFormData, + approverChanges: IFieldChange[], + listId?: string +): Promise { + const item = buildContentFields(data); + item[APPROVER_CHANGES_FIELD] = serializeFieldChanges(approverChanges); + await resolveDataList(FORM_ID, listId).items.getById(id).update(item); +} + /** Осмысленный заголовок элемента (системная колонка Title). */ function buildTitle(data: IFormData): string { return ['Раб. место', data.FullName, data.Position].filter(Boolean).join(' — '); } -/** Поля элемента списка данных из модели формы. */ -function buildItem(data: IFormData, status: string, sourceHireId?: number): Record { - const item: Record = { Title: buildTitle(data), RequestStatus: status }; +/** Содержательные поля элемента списка данных из модели формы (без статуса и источника). */ +function buildContentFields(data: IFormData): Record { + const item: Record = { Title: buildTitle(data) }; (Object.keys(data) as (keyof IFormData)[]).forEach((key) => { if (key === 'Companies') { @@ -58,6 +77,14 @@ function buildItem(data: IFormData, status: string, sourceHireId?: number): Reco } }); + return item; +} + +/** Поля элемента списка данных при сохранении заявки целиком. */ +function buildItem(data: IFormData, status: string, sourceHireId?: number): Record { + const item = buildContentFields(data); + item.RequestStatus = status; + if (sourceHireId) { item.SourceHireId = sourceHireId; // lookup → «Прием на работу — данные» } diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/useApproverRights.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/useApproverRights.ts new file mode 100644 index 0000000..ab6d8db --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/useApproverRights.ts @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import { + ApprovalListService, + ApproverService, + APPROVAL_PHASE, + REQUEST_STATUS, + canEditAllFields +} from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; +import { FORM_ID } from './model'; + +export interface IApproverRights { + /** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО» и «Доступы». */ + canEditReviewFields: boolean; + /** Участник маршрута из shared/approval/fullEditRights.ts правит все поля и вложения. */ + canEditAll: boolean; +} + +const NO_RIGHTS: IApproverRights = { canEditReviewFields: false, canEditAll: false }; + +/** + * Статусы заявки, в которых работает полное право. Закрытую/отклонённую + * задним числом не правим, а на доработке заявку правит автор — правки + * перезаписывали бы друг друга. + */ +const FULL_EDIT_STATUSES: ReadonlyArray = [REQUEST_STATUS.onApproval, REQUEST_STATUS.execution]; + +/** + * Что текущий пользователь может править в поданной заявке. + * - «ПО»/«Доступы» — только на СВОЁМ активном шаге согласования, если это первый шаг. + * - Все поля — если пользователь есть в маршруте (согласование или исполнение) + * НЕЗАВИСИМО от того, чья сейчас очередь, пока заявка в работе (решение + * Тимура 2026-10-07: так в «Организации рабочего места» работает Ербол). + * reloadKey — после решения/делегирования маршрут меняется, права пересчитываются. + */ +export function useApproverRights( + context: WebPartContext, + approval: ApprovalListService, + approverService: ApproverService, + itemId: number, + requestStatus: string, + reloadKey: number +): IApproverRights { + const [rights, setRights] = useState(NO_RIGHTS); + + useEffect(() => { + let alive = true; + Promise.all([ + approval.getForRequest(itemId), + approverService.getPendingRowForCurrentUser(itemId), + approval.getCurrentUserId() + ]) + .then(([rows, pending, userId]) => { + if (!alive) return; + const approvalOrders = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution).map((r) => r.order); + const firstOrder = approvalOrders.length > 0 ? Math.min(...approvalOrders) : undefined; + const isFirstApprovalStep = + !!pending && pending.phase !== APPROVAL_PHASE.execution && pending.order === firstOrder; + const isInRoute = rows.some((r) => r.approverId === userId); + + setRights({ + canEditReviewFields: isFirstApprovalStep, + canEditAll: + isInRoute && + FULL_EDIT_STATUSES.indexOf(requestStatus) !== -1 && + canEditAllFields(FORM_ID, effectiveUserEmail(context)) + }); + }) + .catch(() => { + if (alive) setRights(NO_RIGHTS); + }); + return () => { + alive = false; + }; + }, [context, approval, approverService, itemId, requestStatus, reloadKey]); + + return rights; +} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/validation.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/validation.ts new file mode 100644 index 0000000..33a88c5 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/validation.ts @@ -0,0 +1,14 @@ +import { IFormData } from './model'; + +/** Заполнены ли обязательные поля заявки. */ +export function hasRequiredFields(data: IFormData): boolean { + return ( + data.FullName.trim().length > 0 && + data.NameLatin.trim().length > 0 && + data.Position.trim().length > 0 && + data.Companies.length > 0 + ); +} + +export const REQUIRED_FIELDS_MESSAGE = + 'Заполните обязательные поля: Ф.И.О., Фамилия и имя (латиница), Должность, Организация.'; diff --git a/src/webparts/requestPortal/shared/approval/fullEditRights.ts b/src/webparts/requestPortal/shared/approval/fullEditRights.ts index 1f90794..dbc75fd 100644 --- a/src/webparts/requestPortal/shared/approval/fullEditRights.ts +++ b/src/webparts/requestPortal/shared/approval/fullEditRights.ts @@ -1,21 +1,25 @@ +import { RequestTypeId } from '../../models/types'; + /** * Кто на СВОЁМ АКТИВНОМ шаге согласования может править ВСЕ поля заявки * (а не только отдельные, как расчётная группа в RecruitingHire). * - * Пока фиксированный email одного человека (Алтынай Бултурукова, начальник - * отдела подбора и адаптации персонала) — группы SharePoint для этого ещё - * нет, как и в routeEditRights.ts. ВРЕМЕННО. + * Пока фиксированные email по формам — групп SharePoint для этого ещё нет, + * как и в routeEditRights.ts. ВРЕМЕННО. Список свой у каждой формы: право в + * одной форме не должно молча открывать правку в другой. * - * Подключено только в «Заявке на подбор» и «Заявке на прием» (Реферальная - * рекомендация — нет, по решению Тимура 2026-09-24). С 2026-10-06 право - * открывает и вложения: добавлять и удалять файлы (см. shared/attachments/ - * useAttachmentEditor). + * Реферальная рекомендация — без права (решение Тимура 2026-09-24). С + * 2026-10-06 право открывает и вложения: добавлять и удалять файлы (см. + * shared/attachments/useAttachmentEditor). */ -const FULL_FIELD_EDITOR_EMAILS: ReadonlySet = new Set([ - 'a.bulturukova@tnsu.kz' // Алтынай Бултурукова -]); +const FULL_FIELD_EDITORS: Partial>> = { + 'recruiting-selection': ['a.bulturukova@tnsu.kz'], // Алтынай Бултурукова + 'recruiting-hire': ['a.bulturukova@tnsu.kz'], // Алтынай Бултурукова + 'it-workplace-setup': ['shaimerdenov.yerbol@tnsu.kz'] // Ербол Шаймерденов +}; -/** Может ли этот email править все поля заявки на своём активном шаге (см. выше). */ -export function canEditAllFields(email: string | undefined): boolean { - return !!email && FULL_FIELD_EDITOR_EMAILS.has(email.trim().toLowerCase()); +/** Может ли этот email править все поля заявки этой формы на своём активном шаге (см. выше). */ +export function canEditAllFields(formId: RequestTypeId, email: string | undefined): boolean { + const editors = FULL_FIELD_EDITORS[formId]; + return !!email && !!editors && editors.indexOf(email.trim().toLowerCase()) !== -1; } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/SelectionAttachments.tsx b/src/webparts/requestPortal/shared/attachments/EditableAttachments.tsx similarity index 56% rename from src/webparts/requestPortal/forms/RecruitingSelection/SelectionAttachments.tsx rename to src/webparts/requestPortal/shared/attachments/EditableAttachments.tsx index bc238f9..3b81e11 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/SelectionAttachments.tsx +++ b/src/webparts/requestPortal/shared/attachments/EditableAttachments.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import { Alert, AttachmentList, CollapseSection, FileDropzone, FormField } from '../../shared/ui'; -import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; +import { Alert, AttachmentList, CollapseSection, FileDropzone, FormField } from '../ui'; +import { IAttachmentEditor } from './useAttachmentEditor'; -/** Единственная группа новых файлов в этой форме (категорий нет). */ -export const SELECTION_FILES_GROUP = 'files'; +/** Единственная группа новых файлов (категорий нет — в отличие от заявки на приём). */ +export const ATTACHMENTS_FILES_GROUP = 'files'; interface Props { editor: IAttachmentEditor; @@ -11,9 +11,12 @@ interface Props { editable: boolean; } -/** Блок «Вложения» в просмотре заявки на подбор. */ -export const SelectionAttachments: React.FC = ({ editor, editable }) => { - const newFiles = editor.pending[SELECTION_FILES_GROUP] || []; +/** + * Блок «Вложения» в просмотре заявки без категорий файлов: список, а при + * полном праве правки (shared/approval/fullEditRights.ts) — удаление и загрузка. + */ +export const EditableAttachments: React.FC = ({ editor, editable }) => { + const newFiles = editor.pending[ATTACHMENTS_FILES_GROUP] || []; return ( @@ -23,7 +26,7 @@ export const SelectionAttachments: React.FC = ({ editor, editable }) => { {editable && ( - editor.setPending(SELECTION_FILES_GROUP, files)} /> + editor.setPending(ATTACHMENTS_FILES_GROUP, files)} /> )}