feat: согласующий с полным правом правит вложения в подборе и приёме

Алтынай на своём шаге согласования теперь может добавлять и удалять
файлы, а не только поля. Удаление — сразу, новые файлы загружаются
вместе с «Сохранить» или решением. Блоки вложений вынесены из
просмотров в отдельные компоненты на общем хуке useAttachmentEditor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-06 17:23:08 +05:00
parent 271758527e
commit 0b4fbc4487
10 changed files with 207 additions and 56 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
|---|---|---|---|---| |---|---|---|---|---|
| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` | | Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` |
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` | | Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
| Править **все поля** заявки на своём активном шаге согласования (без вложений) | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | | Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` | | Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) | | Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` | | Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
@@ -0,0 +1,7 @@
@import '../../../../styles/design-tokens';
.empty {
font-family: $font-sans;
font-size: 13.5px;
color: $gray-500;
}
@@ -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<Props> = ({ 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 (
<FormField label={label} full>
{items.length > 0 && <AttachmentList items={items} onRemove={editable ? removeWithPrefix : undefined} />}
{items.length === 0 && !editable && <span className={styles.empty}>—</span>}
{editable && (
<FileDropzone files={editor.pending[groupKey] || []} onChange={(files) => editor.setPending(groupKey, files)} />
)}
</FormField>
);
};
@@ -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<string, string | undefined> = {
[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<Props> = ({ editor, editable }) => {
// «Прочие» без правки показываем, только если они есть (старые заявки).
const showOther = editable || uncategorizedAttachments(editor.items).length > 0;
const fieldProps = { editor, editable };
return (
<CollapseSection title="Вложения" icon="upload">
{editor.error && <Alert variant="err">{editor.error}</Alert>}
<HireAttachmentField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
<HireAttachmentField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
{showOther && <HireAttachmentField label="Прочие вложения" groupKey={GROUP_OTHER} {...fieldProps} />}
</CollapseSection>
);
};
@@ -13,12 +13,6 @@
margin-bottom: $sp-4; margin-bottom: $sp-4;
} }
.emptyAtt {
font-family: $font-sans;
font-size: 13.5px;
color: $gray-500;
}
// Полоса «вы можете заполнить оклады» для начальника расчётной группы — // Полоса «вы можете заполнить оклады» для начальника расчётной группы —
// см. approverRights.ts (тот же приём, что и в Clearance/ClearanceView). // см. approverRights.ts (тот же приём, что и в Clearance/ClearanceView).
.reviewBar { .reviewBar {
@@ -8,9 +8,6 @@ import {
FormCard, FormCard,
InitiatorLine, InitiatorLine,
FormModeProvider, FormModeProvider,
CollapseSection,
FormField,
AttachmentList,
Icon, Icon,
PrintButton, PrintButton,
FieldChangesProvider, FieldChangesProvider,
@@ -39,7 +36,8 @@ import {
} from '../../shared/approval'; } from '../../shared/approval';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { effectiveUserEmail } from '../../shared/impersonation'; 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 { friendlyErrorMessage } from '../../shared/diagnostics';
import { import {
approvalListTitle, approvalListTitle,
@@ -49,7 +47,7 @@ import {
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadHireRequest } from './load'; 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 { confirmDiscardFormChanges } from '../../shared/unsavedChanges';
import { hireApprovalService, updateApproverFields, updateAllHireFields, IApproverEditableFields } from './submit'; import { hireApprovalService, updateApproverFields, updateAllHireFields, IApproverEditableFields } from './submit';
import { submitIssues } from './validation'; import { submitIssues } from './validation';
@@ -81,14 +79,14 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.2) — без неё заявку нельзя отметить выполненной.'; const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.2) — без неё заявку нельзя отметить выполненной.';
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).'; const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».'; const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля заявки (кроме вложений).'; const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует // Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
// те же секции, что и форма подачи; редактирование выключено через // те же секции, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly>, КРОМЕ окладов (4.2/4.3) и отдела/участка (2.4) — // <FormModeProvider readOnly>, КРОМЕ окладов (4.2/4.3) и отдела/участка (2.4) —
// их на своём шаге правит начальник расчётной группы (см. approverRights.ts), — // их на своём шаге правит начальник расчётной группы (см. approverRights.ts), —
// и даты приёма (2.2), которую проставляет активный исполнитель. Согласующий // и даты приёма (2.2), которую проставляет активный исполнитель. Согласующий
// из fullEditRights.ts на своём шаге правит все поля (вложения — нет). // из fullEditRights.ts на своём шаге правит все поля и вложения.
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
@@ -98,7 +96,6 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [payrollEditable, setPayrollEditable] = useState<boolean>(false); const [payrollEditable, setPayrollEditable] = useState<boolean>(false);
@@ -137,6 +134,9 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
() => new ApproverService(hireApprovalService(listId), dataListRef), () => new ApproverService(hireApprovalService(listId), dataListRef),
[listId, dataListRef] [listId, dataListRef]
); );
const attachments = useAttachmentEditor(dataListRef, itemId);
// Сеттер стабилен (useState) — безопасно класть в зависимости эффекта загрузки.
const { setItems: setAttachmentItems } = attachments;
// Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам. // Матрица маршрута из фактических строк заявки (read-only), разбитая по фазам.
// //
@@ -207,6 +207,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
await updateAllHireFields(itemId, data, nextChanges, listId); await updateAllHireFields(itemId, data, nextChanges, listId);
setApproverChanges(nextChanges); setApproverChanges(nextChanges);
setSavedData(data); setSavedData(data);
await attachments.uploadPending(prepareHireFiles);
return; return;
} }
const fields: Partial<IApproverEditableFields> = {}; const fields: Partial<IApproverEditableFields> = {};
@@ -278,7 +279,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
notifyCode(loaded.code); notifyCode(loaded.code);
setAttachments(files); setAttachmentItems(files);
setError(null); setError(null);
setLoading(false); setLoading(false);
} }
@@ -292,7 +293,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId, notifyCode]); }, [itemId, listId, notifyCode, setAttachmentItems]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -363,7 +364,6 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FieldChangesBanner changes={approverChanges} /> <FieldChangesBanner changes={approverChanges} />
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
<FieldChangesProvider changes={toChangeMap(approverChanges)}> <FieldChangesProvider changes={toChangeMap(approverChanges)}>
<FormModeProvider readOnly={!fullEditable}> <FormModeProvider readOnly={!fullEditable}>
<ApplicationSection {...sectionProps} /> <ApplicationSection {...sectionProps} />
@@ -375,27 +375,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
</FieldChangesProvider> </FieldChangesProvider>
<div className="rp-no-print"> <div className="rp-no-print">
<CollapseSection title="Вложения" icon="upload"> <HireAttachments editor={attachments} editable={fullEditable} />
<FormField label="Удостоверение" full>
{attachmentsOfCategory(attachments, ATT_CATEGORY_ID).length > 0 ? (
<AttachmentList items={attachmentsOfCategory(attachments, ATT_CATEGORY_ID)} />
) : (
<span className={styles.emptyAtt}>—</span>
)}
</FormField>
<FormField label="Резюме" full>
{attachmentsOfCategory(attachments, ATT_CATEGORY_RESUME).length > 0 ? (
<AttachmentList items={attachmentsOfCategory(attachments, ATT_CATEGORY_RESUME)} />
) : (
<span className={styles.emptyAtt}>—</span>
)}
</FormField>
{uncategorizedAttachments(attachments).length > 0 && (
<FormField label="Прочие вложения" full>
<AttachmentList items={uncategorizedAttachments(attachments)} />
</FormField>
)}
</CollapseSection>
</div> </div>
</FormCard> </FormCard>
@@ -8,9 +8,6 @@ import {
FormCard, FormCard,
InitiatorLine, InitiatorLine,
FormModeProvider, FormModeProvider,
CollapseSection,
FormField,
AttachmentList,
PrintButton, PrintButton,
FieldChangesProvider, FieldChangesProvider,
FieldChangesBanner FieldChangesBanner
@@ -36,7 +33,9 @@ import {
APPROVAL_PHASE, APPROVAL_PHASE,
ApproverDecision ApproverDecision
} from '../../shared/approval'; } 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 { effectiveUserEmail } from '../../shared/impersonation';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { import {
@@ -71,12 +70,12 @@ interface IRecruitingSelectionViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error'; type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля заявки (кроме вложений).'; const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>, // секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
// кроме согласующего из fullEditRights.ts на его активном шаге — он правит все поля // кроме согласующего из fullEditRights.ts на его активном шаге — он правит все поля
// (вложения — нет). // и вложения.
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки // может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
@@ -86,7 +85,6 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Право удалять согласующих из уже поданного маршрута — см. // Право удалять согласующих из уже поданного маршрута — см.
@@ -115,6 +113,9 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID); const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
const attachments = useAttachmentEditor(dataListRef, itemId);
// Сеттер стабилен (useState) — безопасно класть в зависимости эффекта загрузки.
const { setItems: setAttachmentItems } = attachments;
// Матрица маршрута из фактических строк заявки (read-only), разбитая по // Матрица маршрута из фактических строк заявки (read-only), разбитая по
// фазам. decisionSeq в reloadKey: после решения строки на сервере меняются — // фазам. decisionSeq в reloadKey: после решения строки на сервере меняются —
@@ -169,6 +170,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
await updateAllSelectionFields(itemId, data, nextChanges, listId); await updateAllSelectionFields(itemId, data, nextChanges, listId);
setApproverChanges(nextChanges); setApproverChanges(nextChanges);
setSavedData(data); setSavedData(data);
await attachments.uploadPending();
}; };
const handleSaveFields = async (): Promise<void> => { const handleSaveFields = async (): Promise<void> => {
@@ -266,7 +268,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
notifyCode(loaded.code); notifyCode(loaded.code);
setAttachments(files); setAttachmentItems(files);
setError(null); setError(null);
setLoading(false); setLoading(false);
} }
@@ -280,7 +282,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId, notifyCode]); }, [itemId, listId, notifyCode, setAttachmentItems]);
if (loading) { if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>; return <div className={styles.state}>Загрузка заявки…</div>;
@@ -327,7 +329,6 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
<FieldChangesBanner changes={approverChanges} /> <FieldChangesBanner changes={approverChanges} />
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
<FieldChangesProvider changes={toChangeMap(approverChanges)}> <FieldChangesProvider changes={toChangeMap(approverChanges)}>
<FormModeProvider readOnly={!fullEditable}> <FormModeProvider readOnly={!fullEditable}>
<ApplicationSection {...sectionProps} /> <ApplicationSection {...sectionProps} />
@@ -347,11 +348,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
</FieldChangesProvider> </FieldChangesProvider>
<div className="rp-no-print"> <div className="rp-no-print">
<CollapseSection title="Вложения" icon="upload"> <SelectionAttachments editor={attachments} editable={fullEditable} />
<FormField label="Файлы" full>
<AttachmentList items={attachments} />
</FormField>
</CollapseSection>
</div> </div>
</FormCard> </FormCard>
@@ -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<Props> = ({ editor, editable }) => {
const newFiles = editor.pending[SELECTION_FILES_GROUP] || [];
return (
<CollapseSection title="Вложения" icon="upload">
{editor.error && <Alert variant="err">{editor.error}</Alert>}
<FormField label="Файлы" full>
<AttachmentList items={editor.items} onRemove={editable ? editor.remove : undefined} />
</FormField>
{editable && (
<FormField label="Добавить файлы" full>
<FileDropzone files={newFiles} onChange={(files) => editor.setPending(SELECTION_FILES_GROUP, files)} />
</FormField>
)}
</CollapseSection>
);
};
@@ -7,8 +7,9 @@
* нет, как и в routeEditRights.ts. ВРЕМЕННО. * нет, как и в routeEditRights.ts. ВРЕМЕННО.
* *
* Подключено только в «Заявке на подбор» и «Заявке на прием» (Реферальная * Подключено только в «Заявке на подбор» и «Заявке на прием» (Реферальная
* рекомендация — нет, по решению Тимура 2026-09-24). Вложения этим правом не * рекомендация — нет, по решению Тимура 2026-09-24). С 2026-10-06 право
* открываются — только поля. * открывает и вложения: добавлять и удалять файлы (см. shared/attachments/
* useAttachmentEditor).
*/ */
const FULL_FIELD_EDITOR_EMAILS: ReadonlySet<string> = new Set([ const FULL_FIELD_EDITOR_EMAILS: ReadonlySet<string> = new Set([
'a.bulturukova@tnsu.kz' // Алтынай Бултурукова 'a.bulturukova@tnsu.kz' // Алтынай Бултурукова
@@ -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<string, File[]>;
setPending: (groupKey: string, files: File[]) => void;
/** Удалить прикреплённый файл сразу из заявки (по полному имени файла в SP). */
remove: (fileName: string) => void;
/** Загрузить все выбранные файлы и перечитать список вложений. */
uploadPending: (prepare?: PrepareFiles) => Promise<void>;
/** Ошибка последнего удаления — показать рядом со списком. */
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<IAttachmentInfo[]>([]);
const [pending, setPendingState] = useState<Record<string, File[]>>({});
const [error, setError] = useState<string | undefined>(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<void> => {
const files = Object.keys(pending).reduce<File[]>(
(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 };
}