feat: согласующий с полным правом правит вложения в подборе и приёме
Алтынай на своём шаге согласования теперь может добавлять и удалять файлы, а не только поля. Удаление — сразу, новые файлы загружаются вместе с «Сохранить» или решением. Блоки вложений вынесены из просмотров в отдельные компоненты на общем хуке useAttachmentEditor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
.emptyAtt {
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
// Полоса «вы можете заполнить оклады» для начальника расчётной группы —
|
||||
// см. approverRights.ts (тот же приём, что и в Clearance/ClearanceView).
|
||||
.reviewBar {
|
||||
|
||||
@@ -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 = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||
|
||||
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
|
||||
// те же секции, что и форма подачи; редактирование выключено через
|
||||
// <FormModeProvider readOnly>, КРОМЕ окладов (4.2/4.3) и отдела/участка (2.4) —
|
||||
// их на своём шаге правит начальник расчётной группы (см. approverRights.ts), —
|
||||
// и даты приёма (2.2), которую проставляет активный исполнитель. Согласующий
|
||||
// из fullEditRights.ts на своём шаге правит все поля (вложения — нет).
|
||||
// из fullEditRights.ts на своём шаге правит все поля и вложения.
|
||||
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||
@@ -98,7 +96,6 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [payrollEditable, setPayrollEditable] = useState<boolean>(false);
|
||||
@@ -137,6 +134,9 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ 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<IRecruitingHireViewProps> = ({ context, itemI
|
||||
await updateAllHireFields(itemId, data, nextChanges, listId);
|
||||
setApproverChanges(nextChanges);
|
||||
setSavedData(data);
|
||||
await attachments.uploadPending(prepareHireFiles);
|
||||
return;
|
||||
}
|
||||
const fields: Partial<IApproverEditableFields> = {};
|
||||
@@ -278,7 +279,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ 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<IRecruitingHireViewProps> = ({ context, itemI
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId, listId, notifyCode]);
|
||||
}, [itemId, listId, notifyCode, setAttachmentItems]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
@@ -363,7 +364,6 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FieldChangesBanner changes={approverChanges} />
|
||||
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
|
||||
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
|
||||
<FormModeProvider readOnly={!fullEditable}>
|
||||
<ApplicationSection {...sectionProps} />
|
||||
@@ -375,27 +375,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
</FieldChangesProvider>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<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>
|
||||
<HireAttachments editor={attachments} editable={fullEditable} />
|
||||
</div>
|
||||
</FormCard>
|
||||
|
||||
|
||||
@@ -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 = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||
|
||||
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
||||
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
|
||||
// кроме согласующего из fullEditRights.ts на его активном шаге — он правит все поля
|
||||
// (вложения — нет).
|
||||
// и вложения.
|
||||
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||
@@ -86,7 +85,6 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [attachments, setAttachments] = useState<IAttachmentInfo[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Право удалять согласующих из уже поданного маршрута — см.
|
||||
@@ -115,6 +113,9 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ 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<IRecruitingSelectionViewProps> = ({ cont
|
||||
await updateAllSelectionFields(itemId, data, nextChanges, listId);
|
||||
setApproverChanges(nextChanges);
|
||||
setSavedData(data);
|
||||
await attachments.uploadPending();
|
||||
};
|
||||
|
||||
const handleSaveFields = async (): Promise<void> => {
|
||||
@@ -266,7 +268,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ 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<IRecruitingSelectionViewProps> = ({ cont
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId, listId, notifyCode]);
|
||||
}, [itemId, listId, notifyCode, setAttachmentItems]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
@@ -327,7 +329,6 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FieldChangesBanner changes={approverChanges} />
|
||||
{/* Вложения ниже — вне этого блока: полное право открывает только поля. */}
|
||||
<FieldChangesProvider changes={toChangeMap(approverChanges)}>
|
||||
<FormModeProvider readOnly={!fullEditable}>
|
||||
<ApplicationSection {...sectionProps} />
|
||||
@@ -347,11 +348,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
</FieldChangesProvider>
|
||||
|
||||
<div className="rp-no-print">
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
<FormField label="Файлы" full>
|
||||
<AttachmentList items={attachments} />
|
||||
</FormField>
|
||||
</CollapseSection>
|
||||
<SelectionAttachments editor={attachments} editable={fullEditable} />
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user