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..385ed6f 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/README.md +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/README.md @@ -11,10 +11,18 @@ - **Шаг 1 согласования — непосредственный руководитель** (роль в маршруте). ## Особое поведение -- **Префилл «на основе» заявки на прием** (открывается по `rpsource`): копируются - ФИО, телефон, должность, отдел/участок, вид работ, местоположение, дата приёма - (`loadPrefillFromHire`). Это снимок на момент создания; ссылка на источник - сохраняется в колонке-lookup `SourceHire`. +- **Префилл из заявки на прием** — два входа: кнопка «на основе» в самой заявке на + приём (`rpsource`) или поле «На основе заявки на приём» вверху формы + (`HireRequestPicker`: поиск по утверждённым заявкам на приём во всех партициях — + для тех, кто чужую заявку на приём в портале не видит). Копируются ФИО, телефон, + должность, подразделение, местоположение, дата приёма (`hireLookup.ts` → + `loadHirePrefill`); из заявки на приём читаются только эти поля, оклады в браузер + не уходят. Это снимок на момент создания; ссылка на источник — в колонке-lookup + `SourceHire` (только для заявок из текущей партиции, см. `sourceHireLookupId`). + Нужно право чтения списка «Прием на работу — данные» в SharePoint. +- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол + Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На + согласовании»/«Выполнение» (`useApproverRights.ts`). - **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до **первого** шага согласования и это текущий пользователь; сохраняются кнопкой @@ -24,7 +32,7 @@ - Печать — общий механизм (без «Вложений»). ## Секции формы -EmployeeSection · EquipmentSection +HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection ## Точки входа - rptype: `it-workplace-setup` diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index d01bd5d..766497c 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, @@ -51,8 +54,10 @@ import { } from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { saveWorkplaceRequest, workplaceApprovalService } from './submit'; -import { loadPrefillFromHire } from './prefill'; +import { IHireRequestOption, loadHirePrefill, sourceHireLookupId } from './hireLookup'; +import { HireRequestPicker } from './sections/HireRequestPicker'; 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 +80,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 +109,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); @@ -118,19 +126,25 @@ const WorkplaceSetupForm: React.FC = ({ }; }, [editId, editListId, notifyCode, setSubmitError]); + // Заявка на приём, на основе которой создаётся эта: из кнопки «на основе» в + // самой заявке на приём (rpsource) или из поля выбора в форме (HireRequestPicker). + const [sourceHire, setSourceHire] = useState( + sourceId ? { id: sourceId, listId: '', code: '', candidateName: '', position: '' } : undefined + ); + // Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource). useEffect(() => { if (!sourceId) { return; } let alive = true; - loadPrefillFromHire(sourceId) + loadHirePrefill(sourceId) .then((prefilled) => { if (alive) { // Merge, а не replace: организация заявителя (ниже) резолвится отдельным - // эффектом и могла успеть записаться раньше — не затираем её пустышкой - // из prefilled (в loadPrefillFromHire её нет). - setData((prev) => ({ ...prefilled, Companies: prev.Companies })); + // эффектом и могла успеть записаться раньше — не затираем её. + setData((prev) => ({ ...prev, ...prefilled })); + setSourceHire((prev) => (prev ? { ...prev, candidateName: prefilled.FullName } : prev)); } }) .catch((e) => { @@ -219,13 +233,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; @@ -246,6 +256,17 @@ const WorkplaceSetupForm: React.FC = ({ autoApprove: r.autoApprove })); + // Выбор заявки на приём в форме: поля из неё перезаписывают уже введённые — + // пользователь осознанно выбрал источник. Id отдела сбрасываем вместе с + // названием: в заявке на приём отдел без Id справочника, а старый Id от + // другого отдела увёл бы маршрут не туда. + const handlePickHire = (option: IHireRequestOption): void => { + setSourceHire(option); + loadHirePrefill(option.id, option.listId || undefined) + .then((prefilled) => setData((prev) => ({ ...prev, ...prefilled, DepartmentId: '' }))) + .catch((e) => setSubmitError(`Не удалось заполнить из заявки на приём: ${friendlyErrorMessage(e)}`)); + }; + // Удаление уже прикреплённого файла (в правке) — сразу из заявки. const handleRemoveAttachment = (fileName: string): void => { if (requestId === undefined) { @@ -279,7 +300,7 @@ const WorkplaceSetupForm: React.FC = ({ toApprovers(matrix.rows), toApprovers(executors.rows), status, - sourceId, + sourceHire ? sourceHireLookupId(sourceHire) : undefined, writeListId ); setRequestId(id); @@ -378,10 +399,21 @@ const WorkplaceSetupForm: React.FC = ({ )} {submit.error && {submit.error}} - - - - + {!readOnly && ( + setSourceHire(undefined)} + /> + )} + + + + + + + + {!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/hireLookup.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/hireLookup.ts new file mode 100644 index 0000000..dcbdea9 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/hireLookup.ts @@ -0,0 +1,86 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { dataListTitle } from '../../shared/listTitles'; +import { spDateOnlyToIso } from '../../shared/dateUtils'; +import { REQUEST_STATUS, currentPartitionForPageUrl } from '../../shared/approval'; +import { RequestTypeId } from '../../models/types'; +import { queryAllPartitions, statusFilter } from '../RecruitingHire/partitionQuery'; +import { IFormData } from './model'; + +/** + * Выбор «Заявки на прием», на основе которой создаётся «Организация рабочего + * места». Нужен тем, кто не видит чужую заявку на приём в портале и не может + * нажать «на основе» из неё самой. + * + * Из заявки на приём читаем ТОЛЬКО поля для предзаполнения (и для подписи в + * списке) — оклады и прочие данные кандидата в браузер не уходят. + */ + +/** Слаг формы приёма — ключ реестра форм и pageUrl типа заявки в approval-route. */ +const HIRE_FORM_ID: RequestTypeId = 'recruiting-hire'; + +// Рабочее место готовят под уже утверждённый приём: «Согласовано», затем +// исполнение и закрытие — это тот же утверждённый приём (как isApprovedStatus). +const APPROVED_STATUSES: string[] = [REQUEST_STATUS.approved, REQUEST_STATUS.execution, REQUEST_STATUS.closed]; + +/** Поля заявки на приём, которые переносятся в форму рабочего места. */ +const PREFILL_FIELDS = ['HireCandidateName', 'Phone', 'Position', 'Department', 'SiteLocation', 'HireHireDate']; + +export interface IHireRequestOption { + id: number; + /** Серверный url партиции, где лежит заявка. '' — партиция неизвестна (текущий список по имени). */ + listId: string; + /** Уникальный код заявки (напр. «5-2610-12»). '' — запись до появления поля кода. */ + code: string; + candidateName: string; + position: string; +} + +/** Поля формы рабочего места, которые заполняются из заявки на приём. */ +export type HirePrefill = Pick; + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +/** Утверждённые заявки на приём из всех партиций серии, новые сверху. */ +export async function getApprovedHireRequests(): Promise { + const rows = await queryAllPartitions(HIRE_FORM_ID, statusFilter(APPROVED_STATUSES), [ + 'Id', + 'RequestUniqueCode', + 'HireCandidateName', + 'Position' + ]); + return rows.map(({ listId, row }) => ({ + id: Number(row.Id), + listId, + code: text(row.RequestUniqueCode), + candidateName: text(row.HireCandidateName), + position: text(row.Position) + })); +} + +/** Прочитать из заявки на приём только поля для предзаполнения. */ +export async function loadHirePrefill(hireId: number, listId?: string): Promise { + const sp = getSP(); + const list = listId ? sp.web.getList(listId) : sp.web.lists.getByTitle(dataListTitle(HIRE_FORM_ID)); + const item = (await list.items.getById(hireId).select(...PREFILL_FIELDS)()) as Record; + return { + FullName: text(item.HireCandidateName), + Phone: text(item.Phone), + Position: text(item.Position), + Department: text(item.Department), + Location: text(item.SiteLocation), + StartDate: spDateOnlyToIso(item.HireHireDate) + }; +} + +/** + * Id для колонки-lookup SourceHire. Lookup смотрит только в ТЕКУЩУЮ партицию + * «Прием на работу — данные»: заявка из архивной партиции туда не попадёт, а + * тот же Id в текущем списке — это другая заявка. Поэтому для архивной связь не пишем. + */ +export function sourceHireLookupId(option: Pick): number | undefined { + if (!option.listId) return option.id; + const current = currentPartitionForPageUrl(HIRE_FORM_ID); + return current && current.listId === option.listId ? option.id : undefined; +} 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/prefill.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts deleted file mode 100644 index d917178..0000000 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/prefill.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { loadHireRequest } from '../RecruitingHire/load'; -import { IFormData, EMPTY_DATA } from './model'; - -/** - * Предзаполнить форму «орг. рабочего места» из «Заявки на прием» (снимок на момент создания). - * Копируются согласованные поля; остальное остаётся пустым для ручного заполнения. - */ -export async function loadPrefillFromHire(hireId: number): Promise { - const { data: hire } = await loadHireRequest(hireId); - return { - ...EMPTY_DATA, - FullName: hire.HireCandidateName, - Phone: hire.Phone, - Position: hire.Position, - Department: hire.Department, - Location: hire.SiteLocation, - StartDate: hire.HireHireDate - }; -} 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/sections/HireRequestPicker.module.scss b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.module.scss new file mode 100644 index 0000000..e71608a --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.module.scss @@ -0,0 +1,107 @@ +@import '../../../../../styles/design-tokens'; + +.wrap { + position: relative; +} + +.input { + box-sizing: border-box; + width: 100%; + padding: 11px 13px; + border: 1px solid $gray-300; + border-radius: $r-sm; + background: $white; + font-family: $font-sans; + font-size: 13.5px; + color: $gray-900; + transition: .18s; + + @include input-focus-gold; + + &::placeholder { + color: $gray-400; + } +} + +.list { + position: absolute; + z-index: 20; + left: 0; + right: 0; + margin-top: $sp-1; + max-height: 260px; + overflow-y: auto; + background: $white; + border: 1px solid $gray-200; + border-radius: $r-sm; + box-shadow: $sh-2; +} + +.item { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 9px 12px; + border: 0; + background: transparent; + text-align: left; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-900; + cursor: pointer; + + &:hover { + background: $navy-50; + } +} + +.meta { + font-size: $fs-caption; + color: $gray-500; +} + +.note { + padding: 10px 12px; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-500; +} + +.selected { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $sp-2 $sp-3; +} + +.selectedLabel { + min-width: 0; + font-family: $font-sans; + font-size: 13.5px; + font-weight: 600; + color: $navy-700; + overflow-wrap: anywhere; +} + +.textBtn { + padding: 0; + border: 0; + background: transparent; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-700; + cursor: pointer; + + &:hover { + color: $navy-700; + text-decoration: underline; + } +} + +.hint { + margin: $sp-2 0 0; + font-family: $font-sans; + font-size: $fs-caption; + color: $gray-500; +} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.tsx new file mode 100644 index 0000000..84d46f2 --- /dev/null +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/sections/HireRequestPicker.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import styles from './HireRequestPicker.module.scss'; +import { CollapseSection, FormField } from '../../../shared/ui'; +import { getApprovedHireRequests, IHireRequestOption } from '../hireLookup'; + +const MAX_SUGGESTIONS = 30; +const HINT = + 'ФИО, телефон, должность, подразделение, местоположение и дата приёма заполнятся из выбранной заявки — их можно поправить.'; + +interface Props { + /** Выбранная заявка на приём; undefined — не выбрана. */ + selected: IHireRequestOption | undefined; + onPick: (option: IHireRequestOption) => void; + onClear: () => void; +} + +/** Читаемая подпись: «№ код — ФИО кандидата». */ +function optionLabel(o: IHireRequestOption): string { + const head = `№ ${o.code || o.id}`; + return o.candidateName ? `${head} — ${o.candidateName}` : head; +} + +function matches(o: IHireRequestOption, query: string): boolean { + return [o.code, o.candidateName, o.position].some((v) => v.toLowerCase().indexOf(query) !== -1); +} + +/** + * «На основе заявки на приём»: поиск утверждённой заявки на приём по номеру, + * ФИО кандидата или должности. Выбор предзаполняет форму (делает родитель в onPick). + * Показывается только в редактируемой форме. + */ +export const HireRequestPicker: React.FC = ({ selected, onPick, onClear }) => { + const [options, setOptions] = React.useState([]); + const [loadState, setLoadState] = React.useState<'idle' | 'loading' | 'loaded' | 'error'>('idle'); + const [query, setQuery] = React.useState(''); + const [open, setOpen] = React.useState(false); + + // Список грузим лениво — только когда пользователь начинает выбирать. + const ensureLoaded = (): void => { + if (loadState !== 'idle') return; + setLoadState('loading'); + getApprovedHireRequests() + .then((loaded) => { + setOptions(loaded); + setLoadState('loaded'); + }) + .catch(() => setLoadState('error')); + }; + + const q = query.trim().toLowerCase(); + const suggestions = (q ? options.filter((o) => matches(o, q)) : options).slice(0, MAX_SUGGESTIONS); + + const handlePick = (o: IHireRequestOption): void => { + setOpen(false); + setQuery(''); + onPick(o); + }; + + let emptyText = 'Загрузка…'; + if (loadState === 'error') emptyText = 'Не удалось загрузить заявки на приём.'; + if (loadState === 'loaded') emptyText = 'Утверждённых заявок на приём не найдено.'; + + return ( + + + {selected ? ( +
+ {optionLabel(selected)} + +
+ ) : ( +
+ { + setQuery(e.target.value); + ensureLoaded(); + setOpen(true); + }} + onFocus={() => { + ensureLoaded(); + setOpen(true); + }} + onBlur={() => setOpen(false)} + /> + {open && ( +
+ {suggestions.length > 0 ? ( + suggestions.map((o) => ( + + )) + ) : ( +
{emptyText}
+ )} +
+ )} +
+ )} +

{HINT}

+
+
+ ); +}; 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)} /> )}