feat: поля вида «Надбавка / доплата» в изменении условий ТД

Второй разобранный вид: основание (а–д из шаблона ТЗ — надбавка это или
доплата, видно из пункта), срок с/по или «бессрочно», размер с
переключателем «% / ₸» и обоснование, куда пишется и объём доп. работы при
совмещении. Проценты принимают дробные значения (7,5 %), тенге — целые;
при переходе на тенге дробная часть отбрасывается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-28 15:18:04 +05:00
parent 4c018154d3
commit 6aef9a5ba9
13 changed files with 369 additions and 15 deletions
@@ -58,7 +58,13 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
| Вид | Секция | Поля (все необязательные, без ограничений) |
| --- | --- | --- |
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
| Остальные 9 | — | ещё не разобраны с кадрами |
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение «Результаты аттестации» (обязательно при основании «г») — следующим шагом |
| Остальные 8 | — | ещё не разобраны с кадрами |
**Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
прямо требует документ (вид 2 «г» — аттестация, вид 3 — ДИ, вид 8 — новая ДИ;
вид 9 — текст ИЛИ файл), «можно приложить» — необязательно (вид 7).
## Открытые вопросы (проверить на тесте)
- Выдаёт ли бэкенд (вебхуки) права на элемент по People-колонке `Employee`? Если да —
@@ -0,0 +1,13 @@
@import '../../../../../../styles/design-tokens';
// Сумма + переключатель «% / ₸» в одну строку: поле тянется, переключатель — по содержимому.
.amount {
display: flex;
align-items: stretch;
gap: $sp-2;
}
.amountInput {
flex: 1;
min-width: 0;
}
@@ -0,0 +1,87 @@
import * as React from 'react';
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model';
import { ALLOWANCE_REASONS } from './options';
import { UnitSwitch } from './UnitSwitch';
import { sanitizePercentInput, amountForUnit } from './amount';
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 && (
<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>
</CollapseSection>
);
@@ -0,0 +1,40 @@
@import '../../../../../../styles/design-tokens';
// Две кнопки впритык, как сегментный контрол; высота — как у полей ввода рядом.
.switch {
display: inline-flex;
flex-shrink: 0;
border: 1px solid $gray-300;
border-radius: $r-sm;
overflow: hidden;
}
.option {
min-width: 44px;
padding: 0 $sp-3;
border: 0;
background: $white;
font-family: $font-sans;
font-size: 13.5px;
color: $gray-700;
cursor: pointer;
transition: .15s;
& + & {
border-left: 1px solid $gray-300;
}
&:hover:not(:disabled) {
background: $gray-100;
}
&:disabled {
cursor: default;
}
}
.active,
.active:hover:not(:disabled) {
background: $navy-700;
color: $white;
}
@@ -0,0 +1,36 @@
import * as React from 'react';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { AllowanceUnit, ALLOWANCE_UNITS } from './options';
import styles from './UnitSwitch.module.scss';
interface Props {
value: AllowanceUnit;
onChange: (unit: AllowanceUnit) => void;
}
/**
* Переключатель единицы размера «% / ₸» рядом с полем суммы. Сегментного
* контрола в ui-kit нет; если понадобится ещё где-то — перенести туда.
* В режиме чтения кнопки неактивны, выбранная остаётся подсвеченной.
*/
export const UnitSwitch: React.FC<Props> = ({ value, onChange }) => {
const readOnly = useFormReadOnly();
return (
<div className={styles.switch} role="radiogroup" aria-label="Единица размера">
{ALLOWANCE_UNITS.map((unit) => (
<button
key={unit}
type="button"
role="radio"
aria-checked={unit === value}
className={unit === value ? `${styles.option} ${styles.active}` : styles.option}
disabled={readOnly}
onClick={() => onChange(unit)}
>
{unit}
</button>
))}
</div>
);
};
@@ -0,0 +1,33 @@
import { sanitizePercentInput, amountForUnit } from './amount';
describe('sanitizePercentInput', () => {
it('оставляет целое как есть', () => {
expect(sanitizePercentInput('10')).toBe('10');
});
it('запятую превращает в точку', () => {
expect(sanitizePercentInput('7,5')).toBe('7.5');
});
it('выбрасывает буквы, пробелы и знак процента', () => {
expect(sanitizePercentInput(' 1a2 %')).toBe('12');
});
it('оставляет только первый разделитель и не больше двух знаков дроби', () => {
expect(sanitizePercentInput('1.2.345')).toBe('1.23');
});
it('разрешает разделитель в конце — человек ещё вводит дробь', () => {
expect(sanitizePercentInput('7,')).toBe('7.');
});
});
describe('amountForUnit', () => {
it('при переходе в тенге отбрасывает дробную часть', () => {
expect(amountForUnit('7.5', '₸')).toBe('7');
});
it('в процентах значение не меняет', () => {
expect(amountForUnit('7.5', '%')).toBe('7.5');
});
});
@@ -0,0 +1,25 @@
import { AllowanceUnit } from './options';
/** Знаков после разделителя у процента (7,25 %). */
const PERCENT_FRACTION_DIGITS = 2;
/**
* Ввод процента → «сырое» число строкой с точкой: «7,5» → «7.5». Пропускаем
* только цифры и один разделитель (запятая или точка), дробь — до 2 знаков.
* Не type=number: у TextInput нет step, и браузер считал бы 7.5 невалидным при шаге 1.
*/
export function sanitizePercentInput(text: string): string {
const normalized = text.replace(/,/g, '.').replace(/[^\d.]/g, '');
const [whole, ...rest] = normalized.split('.');
if (rest.length === 0) return whole;
return `${whole}.${rest.join('').slice(0, PERCENT_FRACTION_DIGITS)}`;
}
/**
* Значение суммы после смены единицы: в тенге сумма целая (MoneyInput),
* поэтому дробную часть процента отбрасываем, а не показываем «75» из «7.5».
*/
export function amountForUnit(amount: string, unit: AllowanceUnit): string {
if (unit === '₸') return amount.split('.')[0];
return amount;
}
@@ -0,0 +1,31 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Основания надбавки/доплаты — по шаблону текста ТЗ кадров (а–д). Что это —
* надбавка или доплата — видно из самого пункта, отдельного поля нет. Значения
* совпадают с choices колонки AllowanceReason (provisioning.json).
*/
export const ALLOWANCE_REASON_MENTORING = 'Надбавка: наставничество и обучение новых работников';
export const ALLOWANCE_REASON_TRAVEL = 'Надбавка: разъездной характер работы';
export const ALLOWANCE_REASON_COMBINING = 'Доплата: совмещение должностей / расширение зоны обслуживания';
export const ALLOWANCE_REASON_HAZARDOUS = 'Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда';
export const ALLOWANCE_REASON_OTHER = 'Доплата: иное по решению первого руководителя';
export const ALLOWANCE_REASONS: ISelectOption[] = [
ALLOWANCE_REASON_MENTORING,
ALLOWANCE_REASON_TRAVEL,
ALLOWANCE_REASON_COMBINING,
ALLOWANCE_REASON_HAZARDOUS,
ALLOWANCE_REASON_OTHER
].map((reason) => ({ value: reason, label: reason }));
/** Единица размера; значения = choices колонки AllowanceUnit. */
export type AllowanceUnit = '%' | '₸';
export const ALLOWANCE_UNITS: AllowanceUnit[] = ['%', '₸'];
/** По умолчанию — проценты: пример в ТЗ «Надбавка 10%». */
export const DEFAULT_ALLOWANCE_UNIT: AllowanceUnit = '%';
/** Строка из SP → единица; неизвестное/пустое — единица по умолчанию. */
export function allowanceUnitFromText(text: string): AllowanceUnit {
return ALLOWANCE_UNITS.find((unit) => unit === text) ?? DEFAULT_ALLOWANCE_UNIT;
}
@@ -2,6 +2,7 @@ import * as React from 'react';
import { ChangeType } from '../changeTypes';
import { ISectionProps } from '../model';
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
import { AllowanceSection } from './Allowance/AllowanceSection';
/**
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
@@ -9,7 +10,8 @@ import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
* Новый вид = папка byType/<Вид>/ + строка здесь.
*/
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
'salary-increase': SalaryIncreaseSection
'salary-increase': SalaryIncreaseSection,
allowance: AllowanceSection
};
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES). */
@@ -3,6 +3,7 @@ import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType, changeTypeFromLabel } from './changeTypes';
import { allowanceUnitFromText } from './byType/Allowance/options';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
export interface ILoadedRequest {
@@ -16,8 +17,8 @@ function text(raw: unknown): string {
return typeof raw === 'string' ? raw : '';
}
/** Number-колонка → «сырые» цифры для MoneyInput; пусто → ''. */
function moneyText(raw: unknown): string {
/** Number-колонка → «сырое» число строкой (для MoneyInput / ввода процента); пусто → ''. */
function numberText(raw: unknown): string {
return typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : '';
}
@@ -91,19 +92,33 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
'SalaryIncreaseNew',
'SalaryIncreaseDate',
'SalaryIncreaseReason',
'SalaryIncreaseJustification'
'SalaryIncreaseJustification',
'AllowanceReason',
'AllowanceStartDate',
'AllowanceEndDate',
'AllowanceIndefinite',
'AllowanceAmount',
'AllowanceUnit',
'AllowanceJustification'
)()) as Record<string, unknown>;
const data: IFormData = {
...EMPTY_DATA,
Employee: employeeFromItem(item),
ChangeTypes: changeTypesFromItem(item.ChangeTypes),
SalaryIncreaseCurrent: moneyText(item.SalaryIncreaseCurrent),
SalaryIncreaseNew: moneyText(item.SalaryIncreaseNew),
SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent),
SalaryIncreaseNew: numberText(item.SalaryIncreaseNew),
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
SalaryIncreaseDate: spDateOnlyToIso(text(item.SalaryIncreaseDate)),
SalaryIncreaseReason: text(item.SalaryIncreaseReason),
SalaryIncreaseJustification: text(item.SalaryIncreaseJustification)
SalaryIncreaseJustification: text(item.SalaryIncreaseJustification),
AllowanceReason: text(item.AllowanceReason),
AllowanceStartDate: spDateOnlyToIso(text(item.AllowanceStartDate)),
AllowanceEndDate: spDateOnlyToIso(text(item.AllowanceEndDate)),
AllowanceIndefinite: item.AllowanceIndefinite === true,
AllowanceAmount: numberText(item.AllowanceAmount),
AllowanceUnit: allowanceUnitFromText(text(item.AllowanceUnit)),
AllowanceJustification: text(item.AllowanceJustification)
};
return {
@@ -2,6 +2,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType } from './changeTypes';
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
/**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -43,6 +44,24 @@ export interface IFormData {
SalaryIncreaseReason: string;
/** Обоснование; при причине «Иное» — её текст. */
SalaryIncreaseJustification: string;
// ── Вид «Установление надбавки / доплаты» (byType/Allowance). Все необязательные.
/** Основание — одно из ALLOWANCE_REASONS (Choice-колонка); '' — не выбрано. */
AllowanceReason: string;
/** С какой даты, ISO YYYY-MM-DD. */
AllowanceStartDate: string;
/** По какую дату, ISO; при AllowanceIndefinite всегда ''. */
AllowanceEndDate: string;
/** Бессрочно — тогда даты окончания нет. */
AllowanceIndefinite: boolean;
/**
* Размер — «сырое» число строкой: в ₸ целое (MoneyInput), в % может быть дробным
* с точкой, «7.5» (см. byType/Allowance/amount.ts); единица — в AllowanceUnit.
*/
AllowanceAmount: string;
AllowanceUnit: AllowanceUnit;
/** Обоснование; при совмещении — в т.ч. объём доп. работы, при «иное» — его текст. */
AllowanceJustification: string;
}
/** Обновление одного поля формы с сохранением типа значения. */
@@ -62,7 +81,14 @@ export const EMPTY_DATA: IFormData = {
SalaryIncreaseNew: '',
SalaryIncreaseDate: '',
SalaryIncreaseReason: '',
SalaryIncreaseJustification: ''
SalaryIncreaseJustification: '',
AllowanceReason: '',
AllowanceStartDate: '',
AllowanceEndDate: '',
AllowanceIndefinite: false,
AllowanceAmount: '',
AllowanceUnit: DEFAULT_ALLOWANCE_UNIT,
AllowanceJustification: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
@@ -34,6 +34,20 @@
"Расширение функционала / зоны ответственности",
"Иное"
] },
{ "internalName": "SalaryIncreaseJustification", "title": "Повышение оклада: обоснование", "type": "Note" }
{ "internalName": "SalaryIncreaseJustification", "title": "Повышение оклада: обоснование", "type": "Note" },
{ "internalName": "AllowanceReason", "title": "Надбавка/доплата: основание", "type": "Choice", "choices": [
"Надбавка: наставничество и обучение новых работников",
"Надбавка: разъездной характер работы",
"Доплата: совмещение должностей / расширение зоны обслуживания",
"Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда",
"Доплата: иное по решению первого руководителя"
] },
{ "internalName": "AllowanceStartDate", "title": "Надбавка/доплата: дата начала", "type": "DateTime", "dateOnly": true },
{ "internalName": "AllowanceEndDate", "title": "Надбавка/доплата: дата окончания", "type": "DateTime", "dateOnly": true },
{ "internalName": "AllowanceIndefinite", "title": "Надбавка/доплата: бессрочно", "type": "Boolean" },
{ "internalName": "AllowanceAmount", "title": "Надбавка/доплата: размер", "type": "Number", "decimals": 2 },
{ "internalName": "AllowanceUnit", "title": "Надбавка/доплата: единица размера", "type": "Choice", "choices": ["%", "₸"] },
{ "internalName": "AllowanceJustification", "title": "Надбавка/доплата: обоснование", "type": "Note" }
]
}
@@ -41,8 +41,8 @@ function employeeColumns(employee: IEmployeeFullBaseRecord | undefined): Record<
};
}
/** «Сырые» цифры MoneyInput → число для Number-колонки; '' или мусор → undefined (не заполнено). */
function moneyColumn(raw: string): number | undefined {
/** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */
function numberColumn(raw: string): number | undefined {
const amount = Number(raw);
return raw.trim() !== '' && Number.isFinite(amount) ? amount : undefined;
}
@@ -62,14 +62,39 @@ function salaryIncreaseColumns(data: IFormData): Record<string, unknown> {
};
}
return {
SalaryIncreaseCurrent: moneyColumn(data.SalaryIncreaseCurrent) ?? null,
SalaryIncreaseNew: moneyColumn(data.SalaryIncreaseNew) ?? null,
SalaryIncreaseCurrent: numberColumn(data.SalaryIncreaseCurrent) ?? null,
SalaryIncreaseNew: numberColumn(data.SalaryIncreaseNew) ?? null,
SalaryIncreaseDate: data.SalaryIncreaseDate || null,
SalaryIncreaseReason: data.SalaryIncreaseReason || null,
SalaryIncreaseJustification: data.SalaryIncreaseJustification || null
};
}
/** Колонки вида «Надбавка / доплата»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
function allowanceColumns(data: IFormData): Record<string, unknown> {
if (!data.ChangeTypes.includes('allowance')) {
return {
AllowanceReason: null,
AllowanceStartDate: null,
AllowanceEndDate: null,
AllowanceIndefinite: null,
AllowanceAmount: null,
AllowanceUnit: null,
AllowanceJustification: null
};
}
return {
AllowanceReason: data.AllowanceReason || null,
AllowanceStartDate: data.AllowanceStartDate || null,
// Бессрочно — даты окончания нет, даже если она осталась в состоянии формы.
AllowanceEndDate: data.AllowanceIndefinite ? null : data.AllowanceEndDate || null,
AllowanceIndefinite: data.AllowanceIndefinite,
AllowanceAmount: numberColumn(data.AllowanceAmount) ?? null,
AllowanceUnit: data.AllowanceUnit,
AllowanceJustification: data.AllowanceJustification || null
};
}
/** Поля элемента списка данных из модели формы. */
function buildItem(data: IFormData, status: string): Record<string, unknown> {
return {
@@ -78,7 +103,8 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
...employeeColumns(data.Employee),
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
...salaryIncreaseColumns(data)
...salaryIncreaseColumns(data),
...allowanceColumns(data)
};
}