diff --git a/docs/permissions.md b/docs/permissions.md index eebf95b..f72cd68 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -18,7 +18,7 @@ |---|---|---|---|---| | Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` | | Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` | -| Править **все поля** заявки на своём активном шаге согласования (без вложений) | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | +| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | | Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `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/HireAttachmentField.module.scss b/src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.module.scss new file mode 100644 index 0000000..516e793 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.module.scss @@ -0,0 +1,7 @@ +@import '../../../../styles/design-tokens'; + +.empty { + font-family: $font-sans; + font-size: 13.5px; + color: $gray-500; +} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.tsx b/src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.tsx new file mode 100644 index 0000000..e9d1c83 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/HireAttachmentField.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { AttachmentList, FileDropzone, FormField } from '../../shared/ui'; +import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; +import { attachmentsOfCategory, categoryPrefix, uncategorizedAttachments } from './attachmentCategories'; +import styles from './HireAttachmentField.module.scss'; + +interface Props { + label: string; + editor: IAttachmentEditor; + editable: boolean; + /** Группа новых файлов в editor.pending. */ + groupKey: string; + /** Категория в имени файла; не задана — «прочие» вложения без префикса. */ + category?: string; +} + +/** Поле одной категории вложений: прикреплённые файлы + (в правке) выбор новых. */ +export const HireAttachmentField: React.FC = ({ label, editor, editable, groupKey, category }) => { + const items = category ? attachmentsOfCategory(editor.items, category) : uncategorizedAttachments(editor.items); + // В списке имена показаны без префикса категории — для удаления возвращаем его. + const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name); + + return ( + + {items.length > 0 && } + {items.length === 0 && !editable && —} + {editable && ( + editor.setPending(groupKey, files)} /> + )} + + ); +}; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/HireAttachments.tsx b/src/webparts/requestPortal/forms/RecruitingHire/HireAttachments.tsx new file mode 100644 index 0000000..54c3595 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingHire/HireAttachments.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Alert, CollapseSection } from '../../shared/ui'; +import { IAttachmentEditor, PrepareFiles } from '../../shared/attachments/useAttachmentEditor'; +import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, prefixFiles, uncategorizedAttachments } from './attachmentCategories'; +import { HireAttachmentField } from './HireAttachmentField'; + +/** + * Группы новых файлов → категория в имени файла (см. attachmentCategories). + * У «прочих» категории нет — имя остаётся как есть. + */ +const GROUP_ID = 'id'; +const GROUP_RESUME = 'resume'; +const GROUP_OTHER = 'other'; +const GROUP_CATEGORY: Record = { + [GROUP_ID]: ATT_CATEGORY_ID, + [GROUP_RESUME]: ATT_CATEGORY_RESUME +}; + +/** Префикс категории для новых файлов группы — передаётся в editor.uploadPending. */ +export const prepareHireFiles: PrepareFiles = (groupKey, files) => { + const category = GROUP_CATEGORY[groupKey]; + return category ? prefixFiles(files, category) : files; +}; + +interface Props { + editor: IAttachmentEditor; + /** Согласующий с полным правом на своём шаге — может добавлять и удалять файлы. */ + editable: boolean; +} + +/** Блок «Вложения» в просмотре заявки на прием: удостоверение, резюме, прочие. */ +export const HireAttachments: React.FC = ({ editor, editable }) => { + // «Прочие» без правки показываем, только если они есть (старые заявки). + const showOther = editable || uncategorizedAttachments(editor.items).length > 0; + const fieldProps = { editor, editable }; + + return ( + + {editor.error && {editor.error}} + + + {showOther && } + + ); +}; diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.module.scss b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.module.scss index 6c12d9a..edd3f1d 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.module.scss +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.module.scss @@ -13,12 +13,6 @@ margin-bottom: $sp-4; } -.emptyAtt { - font-family: $font-sans; - font-size: 13.5px; - color: $gray-500; -} - // Полоса «вы можете заполнить оклады» для начальника расчётной группы — // см. approverRights.ts (тот же приём, что и в Clearance/ClearanceView). .reviewBar { diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 3ad3142..b9de697 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -8,9 +8,6 @@ import { FormCard, InitiatorLine, FormModeProvider, - CollapseSection, - FormField, - AttachmentList, Icon, PrintButton, FieldChangesProvider, @@ -39,7 +36,8 @@ import { } from '../../shared/approval'; import { navigate } from '../../shared/routing'; import { effectiveUserEmail } from '../../shared/impersonation'; -import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { getAttachments } from '../../shared/attachments/attachments.service'; +import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { approvalListTitle, @@ -49,7 +47,7 @@ import { } from '../../shared/listTitles'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadHireRequest } from './load'; -import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, attachmentsOfCategory, uncategorizedAttachments } from './attachmentCategories'; +import { HireAttachments, prepareHireFiles } from './HireAttachments'; import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; import { hireApprovalService, updateApproverFields, updateAllHireFields, IApproverEditableFields } from './submit'; import { submitIssues } from './validation'; @@ -81,14 +79,14 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error'; const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.2) — без неё заявку нельзя отметить выполненной.'; const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).'; const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».'; -const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля заявки (кроме вложений).'; +const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.'; // Заявка «на прием» только для просмотра (режим согласующего). Переиспользует // те же секции, что и форма подачи; редактирование выключено через // , КРОМЕ окладов (4.2/4.3) и отдела/участка (2.4) — // их на своём шаге правит начальник расчётной группы (см. approverRights.ts), — // и даты приёма (2.2), которую проставляет активный исполнитель. Согласующий -// из fullEditRights.ts на своём шаге правит все поля (вложения — нет). +// из fullEditRights.ts на своём шаге правит все поля и вложения. const RecruitingHireView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки @@ -98,7 +96,6 @@ const RecruitingHireView: React.FC = ({ context, itemI const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); - const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [payrollEditable, setPayrollEditable] = useState(false); @@ -137,6 +134,9 @@ const RecruitingHireView: React.FC = ({ context, itemI () => new ApproverService(hireApprovalService(listId), dataListRef), [listId, dataListRef] ); + const attachments = useAttachmentEditor(dataListRef, itemId); + // Сеттер стабилен (useState) — безопасно класть в зависимости эффекта загрузки. + const { setItems: setAttachmentItems } = attachments; // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. // @@ -207,6 +207,7 @@ const RecruitingHireView: React.FC = ({ context, itemI await updateAllHireFields(itemId, data, nextChanges, listId); setApproverChanges(nextChanges); setSavedData(data); + await attachments.uploadPending(prepareHireFiles); return; } const fields: Partial = {}; @@ -278,7 +279,7 @@ const RecruitingHireView: React.FC = ({ context, itemI setStatus(loaded.status); setCode(loaded.code); notifyCode(loaded.code); - setAttachments(files); + setAttachmentItems(files); setError(null); setLoading(false); } @@ -292,7 +293,7 @@ const RecruitingHireView: React.FC = ({ context, itemI return () => { alive = false; }; - }, [itemId, listId, notifyCode]); + }, [itemId, listId, notifyCode, setAttachmentItems]); if (loading) { return
Загрузка заявки…
; @@ -363,7 +364,6 @@ const RecruitingHireView: React.FC = ({ context, itemI headerRight={status ? : undefined} > - {/* Вложения ниже — вне этого блока: полное право открывает только поля. */} @@ -375,27 +375,7 @@ const RecruitingHireView: React.FC = ({ context, itemI
- - - {attachmentsOfCategory(attachments, ATT_CATEGORY_ID).length > 0 ? ( - - ) : ( - — - )} - - - {attachmentsOfCategory(attachments, ATT_CATEGORY_RESUME).length > 0 ? ( - - ) : ( - — - )} - - {uncategorizedAttachments(attachments).length > 0 && ( - - - - )} - +
diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index 96516d7..1233b48 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -8,9 +8,6 @@ import { FormCard, InitiatorLine, FormModeProvider, - CollapseSection, - FormField, - AttachmentList, PrintButton, FieldChangesProvider, FieldChangesBanner @@ -36,7 +33,9 @@ import { 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 { SelectionAttachments } from './SelectionAttachments'; import { effectiveUserEmail } from '../../shared/impersonation'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { @@ -71,12 +70,12 @@ interface IRecruitingSelectionViewProps { type SaveState = 'idle' | 'saving' | 'saved' | 'error'; -const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля заявки (кроме вложений).'; +const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.'; // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // секции, что и форма подачи; редактирование выключено через , // кроме согласующего из fullEditRights.ts на его активном шаге — он правит все поля -// (вложения — нет). +// и вложения. const RecruitingSelectionView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки @@ -86,7 +85,6 @@ const RecruitingSelectionView: React.FC = ({ cont const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); - const [attachments, setAttachments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Право удалять согласующих из уже поданного маршрута — см. @@ -115,6 +113,9 @@ const RecruitingSelectionView: React.FC = ({ cont // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). const dataListRef = listId || dataListTitle(FORM_ID); const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); + const attachments = useAttachmentEditor(dataListRef, itemId); + // Сеттер стабилен (useState) — безопасно класть в зависимости эффекта загрузки. + const { setItems: setAttachmentItems } = attachments; // Матрица маршрута из фактических строк заявки (read-only), разбитая по // фазам. decisionSeq в reloadKey: после решения строки на сервере меняются — @@ -169,6 +170,7 @@ const RecruitingSelectionView: React.FC = ({ cont await updateAllSelectionFields(itemId, data, nextChanges, listId); setApproverChanges(nextChanges); setSavedData(data); + await attachments.uploadPending(); }; const handleSaveFields = async (): Promise => { @@ -266,7 +268,7 @@ const RecruitingSelectionView: React.FC = ({ cont setStatus(loaded.status); setCode(loaded.code); notifyCode(loaded.code); - setAttachments(files); + setAttachmentItems(files); setError(null); setLoading(false); } @@ -280,7 +282,7 @@ const RecruitingSelectionView: React.FC = ({ cont return () => { alive = false; }; - }, [itemId, listId, notifyCode]); + }, [itemId, listId, notifyCode, setAttachmentItems]); if (loading) { return
Загрузка заявки…
; @@ -327,7 +329,6 @@ const RecruitingSelectionView: React.FC = ({ cont headerRight={status ? : undefined} > - {/* Вложения ниже — вне этого блока: полное право открывает только поля. */} @@ -347,11 +348,7 @@ const RecruitingSelectionView: React.FC = ({ cont
- - - - - +
diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/SelectionAttachments.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/SelectionAttachments.tsx new file mode 100644 index 0000000..bc238f9 --- /dev/null +++ b/src/webparts/requestPortal/forms/RecruitingSelection/SelectionAttachments.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { Alert, AttachmentList, CollapseSection, FileDropzone, FormField } from '../../shared/ui'; +import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor'; + +/** Единственная группа новых файлов в этой форме (категорий нет). */ +export const SELECTION_FILES_GROUP = 'files'; + +interface Props { + editor: IAttachmentEditor; + /** Согласующий с полным правом на своём шаге — может добавлять и удалять файлы. */ + editable: boolean; +} + +/** Блок «Вложения» в просмотре заявки на подбор. */ +export const SelectionAttachments: React.FC = ({ editor, editable }) => { + const newFiles = editor.pending[SELECTION_FILES_GROUP] || []; + + return ( + + {editor.error && {editor.error}} + + + + {editable && ( + + editor.setPending(SELECTION_FILES_GROUP, files)} /> + + )} + + ); +}; diff --git a/src/webparts/requestPortal/shared/approval/fullEditRights.ts b/src/webparts/requestPortal/shared/approval/fullEditRights.ts index 52361c6..1f90794 100644 --- a/src/webparts/requestPortal/shared/approval/fullEditRights.ts +++ b/src/webparts/requestPortal/shared/approval/fullEditRights.ts @@ -7,8 +7,9 @@ * нет, как и в routeEditRights.ts. ВРЕМЕННО. * * Подключено только в «Заявке на подбор» и «Заявке на прием» (Реферальная - * рекомендация — нет, по решению Тимура 2026-09-24). Вложения этим правом не - * открываются — только поля. + * рекомендация — нет, по решению Тимура 2026-09-24). С 2026-10-06 право + * открывает и вложения: добавлять и удалять файлы (см. shared/attachments/ + * useAttachmentEditor). */ const FULL_FIELD_EDITOR_EMAILS: ReadonlySet = new Set([ 'a.bulturukova@tnsu.kz' // Алтынай Бултурукова diff --git a/src/webparts/requestPortal/shared/attachments/useAttachmentEditor.ts b/src/webparts/requestPortal/shared/attachments/useAttachmentEditor.ts new file mode 100644 index 0000000..a947b11 --- /dev/null +++ b/src/webparts/requestPortal/shared/attachments/useAttachmentEditor.ts @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { deleteAttachment, getAttachments, IAttachmentInfo, uploadAttachments } from './attachments.service'; +import { friendlyErrorMessage } from '../diagnostics'; + +/** + * Подготовка новых файлов группы к загрузке — напр. префикс категории в имени + * (см. forms/RecruitingHire/attachmentCategories). По умолчанию — как есть. + */ +export type PrepareFiles = (groupKey: string, files: File[]) => File[]; + +export interface IAttachmentEditor { + /** Вложения, уже прикреплённые к заявке. */ + items: IAttachmentInfo[]; + setItems: (items: IAttachmentInfo[]) => void; + /** Выбранные, но ещё не загруженные файлы — по группам (категориям) формы. */ + pending: Record; + setPending: (groupKey: string, files: File[]) => void; + /** Удалить прикреплённый файл сразу из заявки (по полному имени файла в SP). */ + remove: (fileName: string) => void; + /** Загрузить все выбранные файлы и перечитать список вложений. */ + uploadPending: (prepare?: PrepareFiles) => Promise; + /** Ошибка последнего удаления — показать рядом со списком. */ + error: string | undefined; +} + +const keepAsIs: PrepareFiles = (_groupKey, files) => files; + +/** + * Правка вложений уже поданной заявки — для согласующего с полным правом + * (см. shared/approval/fullEditRights.ts). Удаление — сразу, как в форме + * автора; новые файлы копятся и загружаются вместе с сохранением полей + * (кнопка «Сохранить» или решение), чтобы не было второй кнопки сохранения. + * + * listRef — Title текущей партиции или url архивной (см. attachments.service). + */ +export function useAttachmentEditor(listRef: string, itemId: number): IAttachmentEditor { + const [items, setItems] = useState([]); + const [pending, setPendingState] = useState>({}); + const [error, setError] = useState(undefined); + + const setPending = (groupKey: string, files: File[]): void => + setPendingState((prev) => ({ ...prev, [groupKey]: files })); + + const remove = (fileName: string): void => { + setError(undefined); + deleteAttachment(listRef, itemId, fileName) + .then(() => setItems((prev) => prev.filter((a) => a.fileName !== fileName))) + .catch((e) => setError(`Не удалось удалить файл «${fileName}»: ${friendlyErrorMessage(e)}`)); + }; + + const uploadPending = async (prepare: PrepareFiles = keepAsIs): Promise => { + const files = Object.keys(pending).reduce( + (acc, groupKey) => acc.concat(prepare(groupKey, pending[groupKey])), + [] + ); + if (files.length === 0) return; + await uploadAttachments(listRef, itemId, files); + // Загруженные убираем из выбора, иначе повторное сохранение продублирует их. + setPendingState({}); + setItems(await getAttachments(listRef, itemId)); + }; + + return { items, setItems, pending, setPending, remove, uploadPending, error }; +}