feat: поля вида «Перевод на другую должность» в изменении условий ТД

Третий разобранный вид: новая должность из справочника с ручным вводом,
дата перевода, причина (карьерный рост / вакантная должность / новый
объект, перераспределение функций / иное) и обоснование. Должностная
инструкция по ТЗ прикладывается всегда — обязательный слот вложений.
Работника и работодателя не различаем: причины видны всем.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-28 16:48:09 +05:00
parent ddf313c951
commit 72e330e659
9 changed files with 151 additions and 7 deletions
@@ -59,7 +59,8 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
| --- | --- | --- |
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
| Остальные 8 | — | ещё не разобраны с кадрами |
| Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно |
| Остальные 7 | — | ещё не разобраны с кадрами |
**Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
@@ -3,6 +3,7 @@ import { ChangeType } from '../changeTypes';
import { ISectionProps } from '../model';
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
import { AllowanceSection } from './Allowance/AllowanceSection';
import { PositionTransferSection } from './PositionTransfer/PositionTransferSection';
/**
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
@@ -11,7 +12,8 @@ import { AllowanceSection } from './Allowance/AllowanceSection';
*/
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
'salary-increase': SalaryIncreaseSection,
allowance: AllowanceSection
allowance: AllowanceSection,
'position-transfer': PositionTransferSection
};
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES). */
@@ -0,0 +1,68 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../../shared/ui';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { PositionPicker } from '../../../../shared/dictionary';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model';
import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options';
/**
* Поля вида «Перевод на другую должность (постоянный)». Состав согласован с
* Тимуром по ТЗ кадров: новая должность (справочник с ручным вводом), дата,
* причина (а–г), обоснование и обязательная должностная инструкция. Текущая
* должность уже видна в «Организации» — отдельного «было» нет.
*
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
*/
export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, context, attachments }) => {
const readOnly = useFormReadOnly();
return (
<CollapseSection title={CHANGE_TYPE_LABELS['position-transfer']} icon="people">
<FormField label="Новая должность" htmlFor="PositionTransferNewPosition">
<PositionPicker
context={context}
id="PositionTransferNewPosition"
value={data.PositionTransferNewPosition}
onChange={(v) => set('PositionTransferNewPosition', v)}
placeholder="Начните вводить название должности…"
/>
</FormField>
<FormField label="Дата перевода" htmlFor="PositionTransferDate">
<TextInput
id="PositionTransferDate"
type="date"
value={data.PositionTransferDate}
onChange={(v) => set('PositionTransferDate', v)}
/>
</FormField>
<FormField label="Причина" htmlFor="PositionTransferReason" full>
<Select
id="PositionTransferReason"
value={data.PositionTransferReason}
onChange={(v) => set('PositionTransferReason', v)}
options={POSITION_TRANSFER_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Обоснование" htmlFor="PositionTransferJustification" full>
<TextArea
id="PositionTransferJustification"
value={data.PositionTransferJustification}
onChange={(v) => set('PositionTransferJustification', v)}
/>
</FormField>
{attachments && (
<AttachmentSlotField
label={JOB_DESCRIPTION_ATTACHMENT}
editor={attachments}
editable={!readOnly}
groupKey={JOB_DESCRIPTION_ATTACHMENT}
category={JOB_DESCRIPTION_ATTACHMENT}
required
/>
)}
</CollapseSection>
);
};
@@ -0,0 +1,17 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины перевода на другую должность — по шаблону текста ТЗ кадров (а–г).
* Работника и работодателя не различаем (решение 2026-10-28): причины видны
* всем, работник при необходимости выбирает «Иное» и пишет текст в обоснование.
* Значения совпадают с choices колонки PositionTransferReason (provisioning.json).
*/
export const POSITION_TRANSFER_REASONS: ISelectOption[] = [
'Карьерный рост (успешное выполнение обязанностей, высокие результаты)',
'Вакантная должность, соответствующая квалификации работника',
'Открытие нового объекта / проекта, перераспределение функций',
'Иное'
].map((reason) => ({ value: reason, label: reason }));
/** Вложение «Должностная инструкция» — по ТЗ ПРИКЛАДЫВАЕТСЯ всегда, обязательно. */
export const JOB_DESCRIPTION_ATTACHMENT = 'Должностная инструкция';
@@ -2,6 +2,7 @@ import { PrepareFiles, IAttachmentEditor } from '../../../shared/attachments/use
import { attachmentsOfCategory, prefixFiles } from '../../../shared/attachments/attachmentCategories';
import { IFormData } from '../model';
import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/options';
import { JOB_DESCRIPTION_ATTACHMENT } from './PositionTransfer/options';
/**
* Подписанный слот вложений по колонке ТЗ «Что приложить». Подпись (category)
@@ -19,6 +20,9 @@ export function activeAttachmentSlots(data: IFormData): IAttachmentSlot[] {
if (data.ChangeTypes.includes('allowance') && requiresAttestation(data.AllowanceReason)) {
slots.push({ category: ATTESTATION_ATTACHMENT, required: true });
}
if (data.ChangeTypes.includes('position-transfer')) {
slots.push({ category: JOB_DESCRIPTION_ATTACHMENT, required: true });
}
return slots;
}
@@ -99,7 +99,11 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
'AllowanceIndefinite',
'AllowanceAmount',
'AllowanceUnit',
'AllowanceJustification'
'AllowanceJustification',
'PositionTransferNewPosition',
'PositionTransferDate',
'PositionTransferReason',
'PositionTransferJustification'
)()) as Record<string, unknown>;
const data: IFormData = {
@@ -118,7 +122,11 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
AllowanceIndefinite: item.AllowanceIndefinite === true,
AllowanceAmount: numberText(item.AllowanceAmount),
AllowanceUnit: allowanceUnitFromText(text(item.AllowanceUnit)),
AllowanceJustification: text(item.AllowanceJustification)
AllowanceJustification: text(item.AllowanceJustification),
PositionTransferNewPosition: text(item.PositionTransferNewPosition),
PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)),
PositionTransferReason: text(item.PositionTransferReason),
PositionTransferJustification: text(item.PositionTransferJustification)
};
return {
@@ -63,6 +63,17 @@ export interface IFormData {
AllowanceUnit: AllowanceUnit;
/** Обоснование; при совмещении — в т.ч. объём доп. работы, при «иное» — его текст. */
AllowanceJustification: string;
// ── Вид «Перевод на другую должность (постоянный)» (byType/PositionTransfer).
// Поля необязательные; вложение «Должностная инструкция» — обязательно (attachmentSlots).
/** Новая должность — из справочника или вручную (PositionPicker). */
PositionTransferNewPosition: string;
/** С какой даты, ISO YYYY-MM-DD. */
PositionTransferDate: string;
/** Причина — одно из POSITION_TRANSFER_REASONS (Choice-колонка); '' — не выбрана. */
PositionTransferReason: string;
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
PositionTransferJustification: string;
}
/** Обновление одного поля формы с сохранением типа значения. */
@@ -91,7 +102,11 @@ export const EMPTY_DATA: IFormData = {
AllowanceIndefinite: false,
AllowanceAmount: '',
AllowanceUnit: DEFAULT_ALLOWANCE_UNIT,
AllowanceJustification: ''
AllowanceJustification: '',
PositionTransferNewPosition: '',
PositionTransferDate: '',
PositionTransferReason: '',
PositionTransferJustification: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
@@ -48,6 +48,16 @@
{ "internalName": "AllowanceIndefinite", "title": "Надбавка/доплата: бессрочно", "type": "Boolean" },
{ "internalName": "AllowanceAmount", "title": "Надбавка/доплата: размер", "type": "Number", "decimals": 2 },
{ "internalName": "AllowanceUnit", "title": "Надбавка/доплата: единица размера", "type": "Choice", "choices": ["%", "₸"] },
{ "internalName": "AllowanceJustification", "title": "Надбавка/доплата: обоснование", "type": "Note" }
{ "internalName": "AllowanceJustification", "title": "Надбавка/доплата: обоснование", "type": "Note" },
{ "internalName": "PositionTransferNewPosition", "title": "Перевод на должность: новая должность", "type": "Text" },
{ "internalName": "PositionTransferDate", "title": "Перевод на должность: дата перевода", "type": "DateTime", "dateOnly": true },
{ "internalName": "PositionTransferReason", "title": "Перевод на должность: причина", "type": "Choice", "choices": [
"Карьерный рост (успешное выполнение обязанностей, высокие результаты)",
"Вакантная должность, соответствующая квалификации работника",
"Открытие нового объекта / проекта, перераспределение функций",
"Иное"
] },
{ "internalName": "PositionTransferJustification", "title": "Перевод на должность: обоснование", "type": "Note" }
]
}
@@ -95,6 +95,24 @@ function allowanceColumns(data: IFormData): Record<string, unknown> {
};
}
/** Колонки вида «Перевод на другую должность»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
function positionTransferColumns(data: IFormData): Record<string, unknown> {
if (!data.ChangeTypes.includes('position-transfer')) {
return {
PositionTransferNewPosition: null,
PositionTransferDate: null,
PositionTransferReason: null,
PositionTransferJustification: null
};
}
return {
PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null,
PositionTransferDate: data.PositionTransferDate || null,
PositionTransferReason: data.PositionTransferReason || null,
PositionTransferJustification: data.PositionTransferJustification || null
};
}
/** Поля элемента списка данных из модели формы. */
function buildItem(data: IFormData, status: string): Record<string, unknown> {
return {
@@ -104,7 +122,8 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
...salaryIncreaseColumns(data),
...allowanceColumns(data)
...allowanceColumns(data),
...positionTransferColumns(data)
};
}