feat: вложения в изменении условий ТД — «Результаты аттестации»

Колонка ТЗ «Что приложить» становится подписанными слотами вложений по
виду; обязательны там, где ТЗ прямо требует документ. Первый слот —
«Результаты аттестации» при доплате за вредные условия. Обязательность
проверяется только при отправке, черновик сохраняется и без файла; файлы
скрытого слота не загружаются.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-28 15:41:15 +05:00
parent c59b0ad208
commit ddf313c951
7 changed files with 189 additions and 79 deletions
@@ -48,6 +48,9 @@ import { loadContractChangeRequest } from './load';
import { useContractEmployee } from './useContractEmployee';
import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { missingRequiredAttachments, prepareFilesFor } from './byType/attachmentSlots';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { getAttachments } from '../../shared/attachments/attachments.service';
import { useStableCallback } from '../../shared/useStableCallback';
const ContractChangeForm: React.FC<IRequestFormProps> = ({
@@ -76,17 +79,22 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
const approval = useMemo(() => contractChangeApprovalService(editListId), [editListId]);
const requestTypeIdState = useRequestTypeId(context, requestType.id);
const requestTypeId = requestTypeIdState.id;
// Вложения (подписанные слоты по ТЗ «Что приложить», см. byType/attachmentSlots).
const dataListRef = editListId || dataListTitle(FORM_ID);
const attachments = useAttachmentEditor(dataListRef, requestId);
const { setItems: setAttachmentItems } = attachments;
// Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое и статус.
// Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое, статус и вложения.
useEffect(() => {
if (!editId) {
return;
}
let alive = true;
loadContractChangeRequest(editId, editListId)
.then((loaded) => {
Promise.all([loadContractChangeRequest(editId, editListId), getAttachments(dataListRef, editId)])
.then(([loaded, files]) => {
if (!alive) return;
setData(loaded.data);
setAttachmentItems(files);
setRequestStatus(loaded.status);
setRequestCode(loaded.code || undefined);
if (loaded.code) notifyCode(loaded.code);
@@ -99,7 +107,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
return () => {
alive = false;
};
}, [editId, editListId, notifyCode, setSubmitError]);
}, [editId, editListId, dataListRef, notifyCode, setSubmitError, setAttachmentItems]);
// Сотрудник + его организация (как в командировке); новая заявка — по умолчанию на себя.
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
@@ -170,9 +178,20 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
status,
writeListId
);
// id фиксируем ДО загрузки файлов: если она упадёт, повторное сохранение
// обновит эту же заявку, а не создаст вторую.
setRequestId(id);
setRequestStatus(status);
if (code) setRequestCode(code);
// Файлы — после записи элемента (нужен его id), с подписью-префиксом слота.
try {
await attachments.uploadPending(prepareFilesFor(data), {
listRef: writeListId || dataListTitle(FORM_ID),
itemId: id
});
} catch (e) {
throw new Error(`Заявка сохранена, но файлы не прикрепились: ${friendlyErrorMessage(e)}. Сохраните ещё раз.`);
}
if (onSaved) onSaved();
// Отправка — уведомляем первый активный шаг; списки той партиции, куда записали.
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
@@ -188,6 +207,9 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
// Обязательные документы — только на отправку: черновик можно сохранить и без файла.
const missingFiles = missingRequiredAttachments(data, attachments);
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
@@ -258,7 +280,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
<FormModeProvider readOnly={readOnly}>
<RequestSection data={data} set={set} context={context} employeeControl={employeeControl} />
<ChangeTypeSections data={data} set={set} context={context} />
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
</FormModeProvider>
<ApprovalMatrix
@@ -28,6 +28,8 @@ import { loadContractChangeRequest } from './load';
import { contractChangeApprovalService } from './submit';
import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { getAttachments } from '../../shared/attachments/attachments.service';
import { useStableCallback } from '../../shared/useStableCallback';
interface IContractChangeViewProps {
@@ -64,6 +66,9 @@ const ContractChangeView: React.FC<IContractChangeViewProps> = ({ context, itemI
// listId (url) для архивной партиции, Title текущей — см. ApproverService.dataListRef.
const dataListRef = listId || dataListTitle(FORM_ID);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
// Вложения — только для показа (секции рисуют их под FormModeProvider readOnly).
const attachments = useAttachmentEditor(dataListRef, itemId);
const { setItems: setAttachmentItems } = attachments;
// decisionSeq в reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0);
@@ -76,10 +81,11 @@ const ContractChangeView: React.FC<IContractChangeViewProps> = ({ context, itemI
useEffect(() => {
let alive = true;
setLoading(true);
loadContractChangeRequest(itemId, listId)
.then((loaded) => {
Promise.all([loadContractChangeRequest(itemId, listId), getAttachments(dataListRef, itemId)])
.then(([loaded, files]) => {
if (!alive) return;
setData(loaded.data);
setAttachmentItems(files);
setStatus(loaded.status);
setCode(loaded.code);
notifyCode(loaded.code);
@@ -94,7 +100,7 @@ const ContractChangeView: React.FC<IContractChangeViewProps> = ({ context, itemI
return () => {
alive = false;
};
}, [itemId, listId, notifyCode]);
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
if (loading) {
return <div className={styles.state}>Загрузка заявки…</div>;
@@ -138,7 +144,7 @@ const ContractChangeView: React.FC<IContractChangeViewProps> = ({ context, itemI
>
<FormModeProvider readOnly>
<RequestSection data={data} set={NOOP_SET} context={context} />
<ChangeTypeSections data={data} set={NOOP_SET} context={context} />
<ChangeTypeSections data={data} set={NOOP_SET} context={context} attachments={attachments} />
</FormModeProvider>
</FormCard>
@@ -58,7 +58,7 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
| Вид | Секция | Поля (все необязательные, без ограничений) |
| --- | --- | --- |
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение «Результаты аттестации» (обязательно при основании «г») — следующим шагом |
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
| Остальные 8 | — | ещё не разобраны с кадрами |
**Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные
@@ -66,6 +66,14 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
прямо требует документ (вид 2 «г» — аттестация, вид 3 — ДИ, вид 8 — новая ДИ;
вид 9 — текст ИЛИ файл), «можно приложить» — необязательно (вид 7).
Как устроено: вложения элемента списка данных, подпись — префикс в имени файла
(`shared/attachments/attachmentCategories`, тот же механизм, что в «Приёме»), поле
слота — `AttachmentSlotField`. Какие слоты видны и обязательны — одна функция
`byType/attachmentSlots.ts` (`activeAttachmentSlots`); новый слот = строка там +
`AttachmentSlotField` в секции вида. Файлы грузятся после записи элемента; файлы
скрытого слота (сменили вид/основание) не грузятся. Обязательность проверяется
только при отправке — черновик сохраняется и без файла.
## Открытые вопросы (проверить на тесте)
- Выдаёт ли бэкенд (вебхуки) права на элемент по People-колонке `Employee`? Если да —
сотрудник, за которого подали заявку, её не увидит (такой колонки здесь нет).
@@ -1,8 +1,10 @@
import * as React from 'react';
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model';
import { ALLOWANCE_REASONS } from './options';
import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options';
import { UnitSwitch } from './UnitSwitch';
import { sanitizePercentInput, amountForUnit } from './amount';
import styles from './AllowanceSection.module.scss';
@@ -10,78 +12,93 @@ import styles from './AllowanceSection.module.scss';
/**
* Поля вида «Установление надбавки / доплаты». Состав согласован с Тимуром по ТЗ
* кадров: основание (а–д), срок с/по или бессрочно, размер в % или ₸, обоснование
* (в т.ч. объём доп. работы при совмещении). Все необязательные.
* (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение
* «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ).
*
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
*/
export const AllowanceSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title={CHANGE_TYPE_LABELS.allowance} icon="calculator">
<FormField label="Основание" htmlFor="AllowanceReason" full>
<Select
id="AllowanceReason"
value={data.AllowanceReason}
onChange={(v) => set('AllowanceReason', v)}
options={ALLOWANCE_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Дата начала" htmlFor="AllowanceStartDate">
<TextInput
id="AllowanceStartDate"
type="date"
value={data.AllowanceStartDate}
onChange={(v) => set('AllowanceStartDate', v)}
/>
</FormField>
<FormField label="Дата окончания" htmlFor="AllowanceEndDate">
{/* «Бессрочно» прячет дату окончания — и очищает её, чтобы не сохранить скрытое значение. */}
{!data.AllowanceIndefinite && (
export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
const readOnly = useFormReadOnly();
return (
<CollapseSection title={CHANGE_TYPE_LABELS.allowance} icon="calculator">
<FormField label="Основание" htmlFor="AllowanceReason" full>
<Select
id="AllowanceReason"
value={data.AllowanceReason}
onChange={(v) => set('AllowanceReason', v)}
options={ALLOWANCE_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Дата начала" htmlFor="AllowanceStartDate">
<TextInput
id="AllowanceEndDate"
id="AllowanceStartDate"
type="date"
value={data.AllowanceEndDate}
onChange={(v) => set('AllowanceEndDate', v)}
value={data.AllowanceStartDate}
onChange={(v) => set('AllowanceStartDate', v)}
/>
</FormField>
<FormField label="Дата окончания" htmlFor="AllowanceEndDate">
{/* «Бессрочно» прячет дату окончания — и очищает её, чтобы не сохранить скрытое значение. */}
{!data.AllowanceIndefinite && (
<TextInput
id="AllowanceEndDate"
type="date"
value={data.AllowanceEndDate}
onChange={(v) => set('AllowanceEndDate', v)}
/>
)}
<Checkbox
checked={data.AllowanceIndefinite}
onChange={(checked) => {
set('AllowanceIndefinite', checked);
if (checked) set('AllowanceEndDate', '');
}}
label="Бессрочно"
/>
</FormField>
<FormField label="Размер" htmlFor="AllowanceAmount">
<div className={styles.amount}>
<div className={styles.amountInput}>
{/* Проценты бывают дробными (7,5 %), тенге — целые с разделителем тысяч. */}
{data.AllowanceUnit === '%' ? (
<TextInput
id="AllowanceAmount"
value={data.AllowanceAmount}
onChange={(v) => set('AllowanceAmount', sanitizePercentInput(v))}
placeholder="например, 7.5"
/>
) : (
<MoneyInput id="AllowanceAmount" value={data.AllowanceAmount} onChange={(v) => set('AllowanceAmount', v)} />
)}
</div>
<UnitSwitch
value={data.AllowanceUnit}
onChange={(unit) => {
set('AllowanceUnit', unit);
set('AllowanceAmount', amountForUnit(data.AllowanceAmount, unit));
}}
/>
</div>
</FormField>
<FormField label="Обоснование" htmlFor="AllowanceJustification" full>
<TextArea
id="AllowanceJustification"
value={data.AllowanceJustification}
onChange={(v) => set('AllowanceJustification', v)}
/>
</FormField>
{attachments && requiresAttestation(data.AllowanceReason) && (
<AttachmentSlotField
label={ATTESTATION_ATTACHMENT}
editor={attachments}
editable={!readOnly}
groupKey={ATTESTATION_ATTACHMENT}
category={ATTESTATION_ATTACHMENT}
required
/>
)}
<Checkbox
checked={data.AllowanceIndefinite}
onChange={(checked) => {
set('AllowanceIndefinite', checked);
if (checked) set('AllowanceEndDate', '');
}}
label="Бессрочно"
/>
</FormField>
<FormField label="Размер" htmlFor="AllowanceAmount">
<div className={styles.amount}>
<div className={styles.amountInput}>
{/* Проценты бывают дробными (7,5 %), тенге — целые с разделителем тысяч. */}
{data.AllowanceUnit === '%' ? (
<TextInput
id="AllowanceAmount"
value={data.AllowanceAmount}
onChange={(v) => set('AllowanceAmount', sanitizePercentInput(v))}
placeholder="например, 7.5"
/>
) : (
<MoneyInput id="AllowanceAmount" value={data.AllowanceAmount} onChange={(v) => set('AllowanceAmount', v)} />
)}
</div>
<UnitSwitch
value={data.AllowanceUnit}
onChange={(unit) => {
set('AllowanceUnit', unit);
set('AllowanceAmount', amountForUnit(data.AllowanceAmount, unit));
}}
/>
</div>
</FormField>
<FormField label="Обоснование" htmlFor="AllowanceJustification" full>
<TextArea
id="AllowanceJustification"
value={data.AllowanceJustification}
onChange={(v) => set('AllowanceJustification', v)}
/>
</FormField>
</CollapseSection>
);
</CollapseSection>
);
};
@@ -19,6 +19,16 @@ export const ALLOWANCE_REASONS: ISelectOption[] = [
ALLOWANCE_REASON_OTHER
].map((reason) => ({ value: reason, label: reason }));
/**
* Вложение «Результаты аттестации» — по ТЗ прикладывается документ при доплате
* за вредные условия (пункт «г»); там же оно обязательно.
*/
export const ATTESTATION_ATTACHMENT = 'Результаты аттестации';
export function requiresAttestation(reason: string): boolean {
return reason === ALLOWANCE_REASON_HAZARDOUS;
}
/** Единица размера; значения = choices колонки AllowanceUnit. */
export type AllowanceUnit = '%' | '₸';
export const ALLOWANCE_UNITS: AllowanceUnit[] = ['%', '₸'];
@@ -0,0 +1,44 @@
import { PrepareFiles, IAttachmentEditor } from '../../../shared/attachments/useAttachmentEditor';
import { attachmentsOfCategory, prefixFiles } from '../../../shared/attachments/attachmentCategories';
import { IFormData } from '../model';
import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/options';
/**
* Подписанный слот вложений по колонке ТЗ «Что приложить». Подпись (category)
* одновременно и префикс в имени файла, и ключ группы в editor.pending.
*/
export interface IAttachmentSlot {
category: string;
/** Обязателен там, где ТЗ прямо требует документ. */
required: boolean;
}
/** Слоты, видимые при текущих данных формы. Новый слот вида = строка здесь. */
export function activeAttachmentSlots(data: IFormData): IAttachmentSlot[] {
const slots: IAttachmentSlot[] = [];
if (data.ChangeTypes.includes('allowance') && requiresAttestation(data.AllowanceReason)) {
slots.push({ category: ATTESTATION_ATTACHMENT, required: true });
}
return slots;
}
/**
* Подготовка выбранных файлов к загрузке: префикс-подпись в имени. Файлы слота,
* который сейчас скрыт (сменили вид или основание), не загружаем — их не видно.
*/
export function prepareFilesFor(data: IFormData): PrepareFiles {
const visible = activeAttachmentSlots(data).map((slot) => slot.category);
return (groupKey, files) => (visible.indexOf(groupKey) >= 0 ? prefixFiles(files, groupKey) : []);
}
/** Подписи обязательных слотов, где нет ни прикреплённого, ни выбранного файла. */
export function missingRequiredAttachments(data: IFormData, editor: IAttachmentEditor): string[] {
return activeAttachmentSlots(data)
.filter((slot) => slot.required)
.filter((slot) => {
const uploaded = attachmentsOfCategory(editor.items, slot.category).length;
const selected = (editor.pending[slot.category] || []).length;
return uploaded + selected === 0;
})
.map((slot) => slot.category);
}
@@ -3,6 +3,7 @@ import { RequestTypeId } from '../../models/types';
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType } from './changeTypes';
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -72,6 +73,8 @@ export interface ISectionProps {
data: IFormData;
set: SetField;
context: WebPartContext;
/** Вложения заявки — для подписанных слотов в секциях видов (см. byType/attachmentSlots). */
attachments?: IAttachmentEditor;
}
export const EMPTY_DATA: IFormData = {