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

Первый разобранный с кадрами вид: текущий и новый оклад, дата, причина
(испытательный срок / расширение функционала / иное) и обоснование — оно
же текст причины «иное». Всё необязательно. Поля вида живут в своей
секции byType/, а колонки невыбранного вида при сохранении очищаются,
чтобы после смены вида в заявке не оставалось скрытых значений.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-28 14:43:35 +05:00
parent 4eece061d1
commit 17f9dec39b
10 changed files with 190 additions and 6 deletions
@@ -47,6 +47,7 @@ import { saveContractChangeRequest, contractChangeApprovalService } from './subm
import { loadContractChangeRequest } from './load'; import { loadContractChangeRequest } from './load';
import { useSelfEmployee } from './useSelfEmployee'; import { useSelfEmployee } from './useSelfEmployee';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
const ContractChangeForm: React.FC<IRequestFormProps> = ({ const ContractChangeForm: React.FC<IRequestFormProps> = ({
@@ -258,6 +259,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection data={data} set={set} context={context} /> <RequestSection data={data} set={set} context={context} />
<ChangeTypeSections data={data} set={set} context={context} />
</FormModeProvider> </FormModeProvider>
<ApprovalMatrix <ApprovalMatrix
@@ -27,6 +27,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadContractChangeRequest } from './load'; import { loadContractChangeRequest } from './load';
import { contractChangeApprovalService } from './submit'; import { contractChangeApprovalService } from './submit';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
interface IContractChangeViewProps { interface IContractChangeViewProps {
@@ -137,6 +138,7 @@ const ContractChangeView: React.FC<IContractChangeViewProps> = ({ context, itemI
> >
<FormModeProvider readOnly> <FormModeProvider readOnly>
<RequestSection data={data} set={NOOP_SET} context={context} /> <RequestSection data={data} set={NOOP_SET} context={context} />
<ChangeTypeSections data={data} set={NOOP_SET} context={context} />
</FormModeProvider> </FormModeProvider>
</FormCard> </FormCard>
@@ -39,13 +39,24 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
менять только вместе с `provisioning.json`. менять только вместе с `provisioning.json`.
- **Заголовок элемента** (`Title`): `Изменение условий ТД — {ФИО} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Изменение условий ТД — {ФИО} — дд.мм.гггг`.
## Поля по видам
Каждый вид — своя секция в `byType/<Вид>/`, подключается строкой в
`byType/ChangeTypeSections.tsx`; ключи модели и колонки SP — с префиксом вида.
Колонки невыбранного вида при сохранении очищаются (`null`), чтобы в заявке не
оставалось скрытых значений после смены вида.
| Вид | Секция | Поля (все необязательные, без ограничений) |
| --- | --- | --- |
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
| Остальные 9 | — | ещё не разобраны с кадрами |
## Открытые вопросы (проверить на тесте) ## Открытые вопросы (проверить на тесте)
- Выдаёт ли бэкенд (вебхуки) права на элемент по People-колонке `Employee`? Если да — - Выдаёт ли бэкенд (вебхуки) права на элемент по People-колонке `Employee`? Если да —
сотрудник, за которого подали заявку, её не увидит (такой колонки здесь нет). сотрудник, за которого подали заявку, её не увидит (такой колонки здесь нет).
- `Employee/FullBase`: ищет ли `Fio` по подстроке; что фильтрует `Status`. - `Employee/FullBase`: ищет ли `Fio` по подстроке; что фильтрует `Status`.
## Секции формы ## Секции формы
RequestSection (сотрудник · вид изменения) RequestSection (сотрудник · вид изменения) · ChangeTypeSections (секция выбранного вида, см. «Поля по видам»)
## Точки входа ## Точки входа
- rptype: `hr-contract-change` - rptype: `hr-contract-change`
@@ -0,0 +1,23 @@
import * as React from 'react';
import { ChangeType } from '../changeTypes';
import { ISectionProps } from '../model';
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
/**
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
* согласования с кадрами; у вида без секции под выбором просто ничего не появится.
* Новый вид = папка byType/<Вид>/ + строка здесь.
*/
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
'salary-increase': SalaryIncreaseSection
};
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES). */
export const ChangeTypeSections: React.FC<ISectionProps> = (props) => (
<>
{props.data.ChangeTypes.map((type) => {
const Section = SECTION_BY_TYPE[type];
return Section ? <Section key={type} {...props} /> : undefined;
})}
</>
);
@@ -0,0 +1,56 @@
import * as React from 'react';
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model';
import { SALARY_INCREASE_REASONS } from './reasons';
/**
* Поля вида «Повышение должностного оклада». Состав согласован с Тимуром по ТЗ
* кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные,
* без ограничений — по решению от 2026-10-28.
*
* Поля — прямо в CollapseSection, без FormGrid: тело секции само является
* двухколоночной сеткой, вложенная FormGrid сжала бы всё в одну её ячейку.
*/
export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => (
<CollapseSection title={CHANGE_TYPE_LABELS['salary-increase']} icon="calculator">
<FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent">
<MoneyInput
id="SalaryIncreaseCurrent"
value={data.SalaryIncreaseCurrent}
onChange={(v) => set('SalaryIncreaseCurrent', v)}
/>
</FormField>
<FormField label="Новый оклад, ₸" htmlFor="SalaryIncreaseNew">
<MoneyInput
id="SalaryIncreaseNew"
value={data.SalaryIncreaseNew}
onChange={(v) => set('SalaryIncreaseNew', v)}
/>
</FormField>
<FormField label="Дата изменения" htmlFor="SalaryIncreaseDate">
<TextInput
id="SalaryIncreaseDate"
type="date"
value={data.SalaryIncreaseDate}
onChange={(v) => set('SalaryIncreaseDate', v)}
/>
</FormField>
<FormField label="Причина" htmlFor="SalaryIncreaseReason">
<Select
id="SalaryIncreaseReason"
value={data.SalaryIncreaseReason}
onChange={(v) => set('SalaryIncreaseReason', v)}
options={SALARY_INCREASE_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Обоснование" htmlFor="SalaryIncreaseJustification" full>
<TextArea
id="SalaryIncreaseJustification"
value={data.SalaryIncreaseJustification}
onChange={(v) => set('SalaryIncreaseJustification', v)}
/>
</FormField>
</CollapseSection>
);
@@ -0,0 +1,16 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины повышения оклада — по шаблону текста ТЗ кадров (а/б/в). Значения
* совпадают с choices колонки SalaryIncreaseReason (provisioning.json).
* «Иное» отдельного текста не имеет — его раскрывает поле «Обоснование».
*/
export const SALARY_REASON_PROBATION = 'Успешное прохождение испытательного срока';
export const SALARY_REASON_RESPONSIBILITY = 'Расширение функционала / зоны ответственности';
export const SALARY_REASON_OTHER = 'Иное';
export const SALARY_INCREASE_REASONS: ISelectOption[] = [
{ value: SALARY_REASON_PROBATION, label: SALARY_REASON_PROBATION },
{ value: SALARY_REASON_RESPONSIBILITY, label: SALARY_REASON_RESPONSIBILITY },
{ value: SALARY_REASON_OTHER, label: SALARY_REASON_OTHER }
];
@@ -1,5 +1,6 @@
import { getSP } from '../../shared/pnp/pnpjs'; import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles'; import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary'; import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType, changeTypeFromLabel } from './changeTypes'; import { ChangeType, changeTypeFromLabel } from './changeTypes';
import { FORM_ID, IFormData, EMPTY_DATA } from './model'; import { FORM_ID, IFormData, EMPTY_DATA } from './model';
@@ -15,6 +16,11 @@ function text(raw: unknown): string {
return typeof raw === 'string' ? raw : ''; return typeof raw === 'string' ? raw : '';
} }
/** Number-колонка → «сырые» цифры для MoneyInput; пусто → ''. */
function moneyText(raw: unknown): string {
return typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : '';
}
/** /**
* Колонки Employee* → запись сотрудника (см. employeeColumns в submit.ts). * Колонки Employee* → запись сотрудника (см. employeeColumns в submit.ts).
* Нет ни id, ни Ф.И.О. — сотрудник не был выбран (черновик) → undefined. * Нет ни id, ни Ф.И.О. — сотрудник не был выбран (черновик) → undefined.
@@ -80,13 +86,24 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
'EmployeeCompany', 'EmployeeCompany',
'EmployeePosition', 'EmployeePosition',
'EmployeeDepartment', 'EmployeeDepartment',
'ChangeTypes' 'ChangeTypes',
'SalaryIncreaseCurrent',
'SalaryIncreaseNew',
'SalaryIncreaseDate',
'SalaryIncreaseReason',
'SalaryIncreaseJustification'
)()) as Record<string, unknown>; )()) as Record<string, unknown>;
const data: IFormData = { const data: IFormData = {
...EMPTY_DATA, ...EMPTY_DATA,
Employee: employeeFromItem(item), Employee: employeeFromItem(item),
ChangeTypes: changeTypesFromItem(item.ChangeTypes) ChangeTypes: changeTypesFromItem(item.ChangeTypes),
SalaryIncreaseCurrent: moneyText(item.SalaryIncreaseCurrent),
SalaryIncreaseNew: moneyText(item.SalaryIncreaseNew),
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
SalaryIncreaseDate: spDateOnlyToIso(text(item.SalaryIncreaseDate)),
SalaryIncreaseReason: text(item.SalaryIncreaseReason),
SalaryIncreaseJustification: text(item.SalaryIncreaseJustification)
}; };
return { return {
@@ -28,6 +28,18 @@ export interface IFormData {
* задел под несколько, см. changeTypes.ts. [] — не выбран. * задел под несколько, см. changeTypes.ts. [] — не выбран.
*/ */
ChangeTypes: ChangeType[]; ChangeTypes: ChangeType[];
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
SalaryIncreaseCurrent: string;
/** Новый оклад — как SalaryIncreaseCurrent. */
SalaryIncreaseNew: string;
/** С какой даты, ISO YYYY-MM-DD. */
SalaryIncreaseDate: string;
/** Причина — одно из SALARY_INCREASE_REASONS (Choice-колонка); '' — не выбрана. */
SalaryIncreaseReason: string;
/** Обоснование; при причине «Иное» — её текст. */
SalaryIncreaseJustification: string;
} }
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -42,7 +54,12 @@ export interface ISectionProps {
export const EMPTY_DATA: IFormData = { export const EMPTY_DATA: IFormData = {
Employee: undefined, Employee: undefined,
ChangeTypes: [] ChangeTypes: [],
SalaryIncreaseCurrent: '',
SalaryIncreaseNew: '',
SalaryIncreaseDate: '',
SalaryIncreaseReason: '',
SalaryIncreaseJustification: ''
}; };
/** Плейсхолдер для поля выбора сотрудника. */ /** Плейсхолдер для поля выбора сотрудника. */
@@ -24,6 +24,16 @@
"Изменение трудовой функции (без смены должности)", "Изменение трудовой функции (без смены должности)",
"Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)", "Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)",
"Временный перевод при производственной необходимости / простое" "Временный перевод при производственной необходимости / простое"
] } ] },
{ "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 },
{ "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 },
{ "internalName": "SalaryIncreaseDate", "title": "Повышение оклада: дата изменения", "type": "DateTime", "dateOnly": true },
{ "internalName": "SalaryIncreaseReason", "title": "Повышение оклада: причина", "type": "Choice", "choices": [
"Успешное прохождение испытательного срока",
"Расширение функционала / зоны ответственности",
"Иное"
] },
{ "internalName": "SalaryIncreaseJustification", "title": "Повышение оклада: обоснование", "type": "Note" }
] ]
} }
@@ -41,6 +41,35 @@ function employeeColumns(employee: IEmployeeFullBaseRecord | undefined): Record<
}; };
} }
/** «Сырые» цифры MoneyInput → число для Number-колонки; '' или мусор → undefined (не заполнено). */
function moneyColumn(raw: string): number | undefined {
const amount = Number(raw);
return raw.trim() !== '' && Number.isFinite(amount) ? amount : undefined;
}
/**
* Колонки вида «Повышение оклада». Вид не выбран — колонки очищаются: иначе
* после смены вида в заявке остались бы скрытые поля прежнего, которых никто не видит.
*/
function salaryIncreaseColumns(data: IFormData): Record<string, unknown> {
if (!data.ChangeTypes.includes('salary-increase')) {
return {
SalaryIncreaseCurrent: null,
SalaryIncreaseNew: null,
SalaryIncreaseDate: null,
SalaryIncreaseReason: null,
SalaryIncreaseJustification: null
};
}
return {
SalaryIncreaseCurrent: moneyColumn(data.SalaryIncreaseCurrent) ?? null,
SalaryIncreaseNew: moneyColumn(data.SalaryIncreaseNew) ?? null,
SalaryIncreaseDate: data.SalaryIncreaseDate || null,
SalaryIncreaseReason: data.SalaryIncreaseReason || null,
SalaryIncreaseJustification: data.SalaryIncreaseJustification || null
};
}
/** Поля элемента списка данных из модели формы. */ /** Поля элемента списка данных из модели формы. */
function buildItem(data: IFormData, status: string): Record<string, unknown> { function buildItem(data: IFormData, status: string): Record<string, unknown> {
return { return {
@@ -48,7 +77,8 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
RequestStatus: status, RequestStatus: status,
...employeeColumns(data.Employee), ...employeeColumns(data.Employee),
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку. // MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]) ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
...salaryIncreaseColumns(data)
}; };
} }