feat: инициатор (работник / работодатель) в изменении условий ТД

По ТЗ у видов, которые может начать и сам работник (3, 4, 5, 7), шаблон
разный: у работника — только текст заявления, у работодателя — причины
из списка и обоснование. Разделяем строго по ТЗ полем «Инициатор»: по
умолчанию «на себя — работник, за другого — работодатель» (с подсказкой
под полем), следует за сменой сотрудника, пока его не выбрали руками.
Причина у работника при сохранении очищается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-29 15:11:34 +05:00
parent c4b2cb3c3d
commit 040ab475b5
14 changed files with 298 additions and 74 deletions
@@ -36,17 +36,11 @@ import {
dataListTitle,
dataListTitleForListId
} from '../../shared/listTitles';
import {
FORM_ID,
IFormData,
EMPTY_DATA,
SetField,
REQUIRED_FIELDS_MESSAGE,
routeEmail
} from './model';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, REQUIRED_FIELDS_MESSAGE, routeEmail } from './model';
import { saveContractChangeRequest, contractChangeApprovalService } from './submit';
import { loadContractChangeRequest } from './load';
import { useContractEmployee } from './useContractEmployee';
import { useInitiatorAutofill } from './useInitiatorAutofill';
import { RequestSection } from './sections/RequestSection';
import { ChangeTypeSections } from './byType/ChangeTypeSections';
import { prepareFilesFor } from './byType/attachmentSlots';
@@ -116,10 +110,11 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
set('Employee', record)
);
// Пропсы шапки: сотрудник + инициатор, который следует за сотрудником, пока его не выбрали руками.
const headerProps = useInitiatorAutofill(!!editId, data, set, applicantEmail, employeeControl);
// Маршрут — по сотруднику, без учётной записи — по заявителю (см. routeEmail); при
// переводе в другое подразделение принимающий руководитель — первым шагом.
const isDepartmentTransfer = data.ChangeTypes.includes('department-transfer');
const { matrix, executors } = useContractRoute({
context,
approval,
@@ -128,7 +123,8 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
editId,
requestStatus,
readOnly,
receivingHeadEmail: !readOnly && isDepartmentTransfer ? data.DepartmentTransferReceivingHead : ''
receivingHeadEmail:
!readOnly && data.ChangeTypes.includes('department-transfer') ? data.DepartmentTransferReceivingHead : ''
});
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
@@ -257,7 +253,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
{submit.error && <Alert variant="err">{submit.error}</Alert>}
<FormModeProvider readOnly={readOnly}>
<RequestSection data={data} set={set} context={context} employeeControl={employeeControl} />
<RequestSection data={data} set={set} context={context} {...headerProps} />
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
</FormModeProvider>
@@ -64,8 +64,20 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
| Изменение места работы (другая местность) | `WorkplaceChange` | место работы «было → стало» парой **организация + проект**: текущая организация — из «Организации» сотрудника (только чтение, отдельно не хранится — это `EmployeeCompany`), текущий проект (`ProjectPicker`); новая организация (`Dictionary/Companies`, **обязательна при отправке**, не подставляется) и новый проект; адрес нового места, дата, причина (новый объект / переезд подразделения или работодателя / иное), условия переезда и компенсации; переключатель **«Вахта»** → «График работы» (тот же набор, что в подборе и приёме, «Другое» → ручной ввод; без вахты график очищается); обоснование |
| Остальные 5 | — | ещё не разобраны с кадрами |
Обязательное по виду при отправке (документы, принимающий руководитель, новая
организация) — одна функция `byType/submitChecks.ts`; черновик сохраняется без них.
Обязательное по виду при отправке (инициатор, документы, принимающий руководитель,
новая организация) — одна функция `byType/submitChecks.ts`; черновик сохраняется без них.
**Инициатор (работник / работодатель)** — решение 2026-10-29: разделяем строго по
ТЗ, чтобы у кадров не было вопросов. Поле `ChangeInitiator` в шапке — только у
видов, которые по ТЗ может начать и работник (3, 4, 5, 7; `hasInitiatorChoice`).
По умолчанию при выборе вида: подаёт на себя — «Работник», за другого —
«Работодатель» (`defaultInitiatorRole`), под полем подсказка об этом; можно
поменять. Пока инициатора не выбирали руками, он следует за сотрудником: сменили
сотрудника — пересчитался (`useInitiatorAutofill`); после ручного выбора и у
открытого черновика — не трогается. От роли (`byType/InitiatorReasonFields`): работник — только «Текст
заявления» (по ТЗ «От работника: заявление (текст)»), работодатель — причина из
списка + «Обоснование». Текст пишется в ту же колонку `*Justification`; причина у
работника при сохранении очищается. Остальные поля вида — у обеих ролей.
**Принимающий руководитель — временное решение (2026-10-29).** По ТЗ (лист
«Маршрут»: А шаг 2, Б шаг 3 параллельно с непосредственным руководителем) его
@@ -1,5 +1,6 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select, PeopleField } from '../../../../shared/ui';
import { CollapseSection, FormField, TextInput, PeopleField } from '../../../../shared/ui';
import { InitiatorReasonFields } from '../InitiatorReasonFields';
import { DepartmentPicker } from '../../../../shared/dictionary';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model';
@@ -44,21 +45,19 @@ export const DepartmentTransferSection: React.FC<ISectionProps> = ({ data, set,
placeholder="Начните вводить Ф.И.О. руководителя, который принимает работника…"
/>
</FormField>
<FormField label="Причина" htmlFor="DepartmentTransferReason" full>
<Select
id="DepartmentTransferReason"
value={data.DepartmentTransferReason}
onChange={(v) => set('DepartmentTransferReason', v)}
options={DEPARTMENT_TRANSFER_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Обоснование" htmlFor="DepartmentTransferJustification" full>
<TextArea
id="DepartmentTransferJustification"
value={data.DepartmentTransferJustification}
onChange={(v) => set('DepartmentTransferJustification', v)}
/>
</FormField>
<InitiatorReasonFields
initiator={data.ChangeInitiator}
reason={{
id: 'DepartmentTransferReason',
value: data.DepartmentTransferReason,
options: DEPARTMENT_TRANSFER_REASONS,
onChange: (v) => set('DepartmentTransferReason', v)
}}
justification={{
id: 'DepartmentTransferJustification',
value: data.DepartmentTransferJustification,
onChange: (v) => set('DepartmentTransferJustification', v)
}}
/>
</CollapseSection>
);
@@ -0,0 +1,41 @@
import * as React from 'react';
import { FormField, Select, TextArea, ISelectOption } from '../../../shared/ui';
import { InitiatorRole, INITIATOR_EMPLOYEE } from '../changeTypes';
import { EMPLOYEE_STATEMENT_LABEL } from '../model';
interface Props {
initiator: InitiatorRole | '';
/** Причина из списка — только у работодателя. */
reason: { id: string; value: string; options: ISelectOption[]; onChange: (value: string) => void };
/** Обоснование работодателя / текст заявления работника — одна и та же колонка вида. */
justification: { id: string; value: string; onChange: (value: string) => void };
}
/**
* Окончание секции вида, где по ТЗ шаблон зависит от инициатора:
* - работник — «заявление (текст)»: только «Текст заявления», без списка причин;
* - работодатель (и пока инициатор не выбран) — «в связи с: а) б) в)»: причина + обоснование.
* Поля кладутся прямо в сетку CollapseSection (без обёртки) — как и остальные поля вида.
*/
export const InitiatorReasonFields: React.FC<Props> = ({ initiator, reason, justification }) => {
const isEmployee = initiator === INITIATOR_EMPLOYEE;
return (
<>
{!isEmployee && (
<FormField label="Причина" htmlFor={reason.id} full>
<Select
id={reason.id}
value={reason.value}
onChange={reason.onChange}
options={reason.options}
placeholder="— выберите —"
/>
</FormField>
)}
<FormField label={isEmployee ? EMPLOYEE_STATEMENT_LABEL : 'Обоснование'} htmlFor={justification.id} full>
<TextArea id={justification.id} value={justification.value} onChange={justification.onChange} />
</FormField>
</>
);
};
@@ -1,5 +1,6 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../../shared/ui';
import { CollapseSection, FormField, TextInput } from '../../../../shared/ui';
import { InitiatorReasonFields } from '../InitiatorReasonFields';
import { useFormReadOnly } from '@tnsu/ui-kit';
import { PositionPicker } from '../../../../shared/dictionary';
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
@@ -37,22 +38,20 @@ export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, co
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>
<InitiatorReasonFields
initiator={data.ChangeInitiator}
reason={{
id: 'PositionTransferReason',
value: data.PositionTransferReason,
options: POSITION_TRANSFER_REASONS,
onChange: (v) => set('PositionTransferReason', v)
}}
justification={{
id: 'PositionTransferJustification',
value: data.PositionTransferJustification,
onChange: (v) => set('PositionTransferJustification', v)
}}
/>
{attachments && (
<AttachmentSlotField
label={JOB_DESCRIPTION_ATTACHMENT}
@@ -5,6 +5,7 @@ import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model';
import { WORKPLACE_CHANGE_REASONS, WORK_SCHEDULE_OPTIONS, SCHEDULE_OTHER } from './options';
import { useCompanyOptions } from './useCompanyOptions';
import { InitiatorReasonFields } from '../InitiatorReasonFields';
import styles from './WorkplaceChangeSection.module.scss';
const PROJECT_PLACEHOLDER = 'Начните вводить название или код проекта…';
@@ -78,15 +79,6 @@ export const WorkplaceChangeSection: React.FC<ISectionProps> = ({ data, set, con
onChange={(v) => set('WorkplaceChangeDate', v)}
/>
</FormField>
<FormField label="Причина" htmlFor="WorkplaceChangeReason">
<Select
id="WorkplaceChangeReason"
value={data.WorkplaceChangeReason}
onChange={(v) => set('WorkplaceChangeReason', v)}
options={WORKPLACE_CHANGE_REASONS}
placeholder="— выберите —"
/>
</FormField>
<FormField label="Условия переезда и компенсации" htmlFor="WorkplaceChangeRelocation" full>
<TextArea
id="WorkplaceChangeRelocation"
@@ -132,13 +124,20 @@ export const WorkplaceChangeSection: React.FC<ISectionProps> = ({ data, set, con
/>
</FormField>
)}
<FormField label="Обоснование" htmlFor="WorkplaceChangeJustification" full>
<TextArea
id="WorkplaceChangeJustification"
value={data.WorkplaceChangeJustification}
onChange={(v) => set('WorkplaceChangeJustification', v)}
/>
</FormField>
<InitiatorReasonFields
initiator={data.ChangeInitiator}
reason={{
id: 'WorkplaceChangeReason',
value: data.WorkplaceChangeReason,
options: WORKPLACE_CHANGE_REASONS,
onChange: (v) => set('WorkplaceChangeReason', v)
}}
justification={{
id: 'WorkplaceChangeJustification',
value: data.WorkplaceChangeJustification,
onChange: (v) => set('WorkplaceChangeJustification', v)
}}
/>
</CollapseSection>
);
};
@@ -1,5 +1,11 @@
import { IAttachmentEditor } from '../../../shared/attachments/useAttachmentEditor';
import { IFormData, NEW_COMPANY_REQUIRED_MESSAGE, RECEIVING_HEAD_REQUIRED_MESSAGE } from '../model';
import {
IFormData,
INITIATOR_REQUIRED_MESSAGE,
NEW_COMPANY_REQUIRED_MESSAGE,
RECEIVING_HEAD_REQUIRED_MESSAGE
} from '../model';
import { hasInitiatorChoice } from '../changeTypes';
import { missingRequiredAttachments } from './attachmentSlots';
/**
@@ -8,6 +14,8 @@ import { missingRequiredAttachments } from './attachmentSlots';
* Новое обязательное поле вида = строка здесь.
*/
export function changeTypeSubmitError(data: IFormData, attachments: IAttachmentEditor): string | undefined {
if (hasInitiatorChoice(data.ChangeTypes) && !data.ChangeInitiator) return INITIATOR_REQUIRED_MESSAGE;
const missingFiles = missingRequiredAttachments(data, attachments);
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
@@ -49,6 +49,39 @@ export const CHANGE_TYPE_OPTIONS: ISelectOption[] = (Object.keys(CHANGE_TYPE_LAB
(type) => ({ value: type, label: CHANGE_TYPE_LABELS[type] })
);
/**
* Кто инициирует изменение. Значения = choices колонки ChangeInitiator. По ТЗ у
* части видов шаблон разный: «От работника: заявление (текст). От работодателя:
* прошу… в связи с: а) б) в)» — форма показывает поля той роли, что выбрана.
*/
export type InitiatorRole = 'Работник' | 'Работодатель';
export const INITIATOR_EMPLOYEE: InitiatorRole = 'Работник';
export const INITIATOR_EMPLOYER: InitiatorRole = 'Работодатель';
export const INITIATOR_OPTIONS: ISelectOption[] = [INITIATOR_EMPLOYEE, INITIATOR_EMPLOYER].map((role) => ({
value: role,
label: role
}));
/**
* Виды, которые по ТЗ («Кто инициирует») может начать и руководитель, и сам
* работник. У остальных инициатор — только руководитель, выбора нет.
*/
const TYPES_WITH_INITIATOR_CHOICE: ChangeType[] = [
'position-transfer',
'department-transfer',
'workplace-change',
'schedule-change'
];
export function hasInitiatorChoice(types: ChangeType[]): boolean {
return types.some((type) => TYPES_WITH_INITIATOR_CHOICE.indexOf(type) >= 0);
}
/** Строка из SP → роль; пусто/неизвестное — '' (не выбрана). */
export function initiatorRoleFromText(text: string): InitiatorRole | '' {
return text === INITIATOR_EMPLOYEE || text === INITIATOR_EMPLOYER ? text : '';
}
/** Подпись из SP → ключ вида. Неизвестная подпись (переименовали choice) — undefined. */
export function changeTypeFromLabel(label: string): ChangeType | undefined {
const normalized = label.trim();
@@ -2,7 +2,7 @@ import { getSP } from '../../shared/pnp/pnpjs';
import { dataListTitle } from '../../shared/listTitles';
import { spDateOnlyToIso } from '../../shared/dateUtils';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType, changeTypeFromLabel } from './changeTypes';
import { ChangeType, changeTypeFromLabel, initiatorRoleFromText } from './changeTypes';
import { allowanceUnitFromText } from './byType/Allowance/options';
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
@@ -88,6 +88,7 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
'EmployeePosition',
'EmployeeDepartment',
'ChangeTypes',
'ChangeInitiator',
'SalaryIncreaseCurrent',
'SalaryIncreaseNew',
'SalaryIncreaseDate',
@@ -132,6 +133,7 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
...EMPTY_DATA,
Employee: employeeFromItem(item),
ChangeTypes: changeTypesFromItem(item.ChangeTypes),
ChangeInitiator: initiatorRoleFromText(text(item.ChangeInitiator)),
SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent),
SalaryIncreaseNew: numberText(item.SalaryIncreaseNew),
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
@@ -1,7 +1,7 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { RequestTypeId } from '../../models/types';
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ChangeType } from './changeTypes';
import { ChangeType, InitiatorRole, INITIATOR_EMPLOYEE, INITIATOR_EMPLOYER } from './changeTypes';
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
@@ -33,6 +33,12 @@ export interface IFormData {
* задел под несколько, см. changeTypes.ts. [] — не выбран.
*/
ChangeTypes: ChangeType[];
/**
* Инициатор — только у видов, где по ТЗ им может быть и работник
* (hasInitiatorChoice). От роли зависят поля: у работника — текст заявления,
* у работодателя — причины + обоснование. '' — не выбран / у вида выбора нет.
*/
ChangeInitiator: InitiatorRole | '';
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
@@ -137,6 +143,7 @@ export interface ISectionProps {
export const EMPTY_DATA: IFormData = {
Employee: undefined,
ChangeTypes: [],
ChangeInitiator: '',
SalaryIncreaseCurrent: '',
SalaryIncreaseNew: '',
SalaryIncreaseDate: '',
@@ -188,6 +195,12 @@ export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид измене
export const ROUTE_BY_APPLICANT_NOTE =
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
/** Подсказка под полем «Инициатор» — откуда взялось значение по умолчанию. */
export const INITIATOR_HINT =
'Подставляется автоматически: подаёте на себя — «Работник», за другого сотрудника — «Работодатель». Можно изменить.';
export const INITIATOR_REQUIRED_MESSAGE = 'Укажите инициатора изменения: работник или работодатель.';
/** Подпись поля текста у работника — вместо причин и обоснования (шаблон ТЗ «От работника: заявление»). */
export const EMPLOYEE_STATEMENT_LABEL = 'Текст заявления';
export const NEW_COMPANY_REQUIRED_MESSAGE = 'Выберите новую организацию — в какой организации будет новое место работы.';
export const RECEIVING_HEAD_REQUIRED_MESSAGE =
'Укажите принимающего руководителя — его согласие входит в маршрут перевода в другое подразделение.';
@@ -197,6 +210,21 @@ export const RECEIVING_HEAD_REQUIRED_MESSAGE =
* записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
*/
/**
* Инициатор по умолчанию (см. INITIATOR_HINT): подаёт на себя — работник, за
* другого — работодатель. Пользователь может поменять.
*/
export function defaultInitiatorRole(
person: IEmployeeFullBasePerson | undefined,
applicantEmail: string
): InitiatorRole {
// Сотрудник ещё не выбран/не подгрузился — новая заявка по умолчанию на себя.
if (!person) return INITIATOR_EMPLOYEE;
// У сотрудника нет учётной записи — это точно не сам заявитель.
if (!person.email) return INITIATOR_EMPLOYER;
return person.email.toLowerCase() === applicantEmail.toLowerCase() ? INITIATOR_EMPLOYEE : INITIATOR_EMPLOYER;
}
export function routeEmail(person: IEmployeeFullBasePerson | undefined, applicantEmail: string): string {
if (!person) return '';
return person.email || applicantEmail;
@@ -25,6 +25,7 @@
"Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)",
"Временный перевод при производственной необходимости / простое"
] },
{ "internalName": "ChangeInitiator", "title": "Инициатор изменения", "type": "Choice", "choices": ["Работник", "Работодатель"] },
{ "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 },
{ "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 },
@@ -7,13 +7,21 @@ import {
personFromWorkplace,
workplaceKey
} from '../../../shared/dictionary';
import { ChangeType, CHANGE_TYPE_OPTIONS } from '../changeTypes';
import {
ChangeType,
CHANGE_TYPE_OPTIONS,
INITIATOR_OPTIONS,
InitiatorRole,
hasInitiatorChoice,
initiatorRoleFromText
} from '../changeTypes';
import {
ISectionProps,
EMPLOYEE_PLACEHOLDER,
WORKPLACE_PLACEHOLDER,
CHANGE_TYPE_PLACEHOLDER,
ROUTE_BY_APPLICANT_NOTE
ROUTE_BY_APPLICANT_NOTE,
INITIATOR_HINT
} from '../model';
import { IContractEmployee } from '../useContractEmployee';
import styles from './RequestSection.module.scss';
@@ -21,6 +29,13 @@ import styles from './RequestSection.module.scss';
interface IRequestSectionProps extends ISectionProps {
/** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */
employeeControl?: IContractEmployee;
/**
* Инициатор по умолчанию при выборе вида с выбором инициатора: подаёт на себя —
* работник, за другого — работодатель (см. ContractChangeForm). В просмотре не нужен.
*/
defaultInitiator?: InitiatorRole;
/** Ручной выбор инициатора (см. useInitiatorAutofill); не задан — просто set. */
onInitiatorChange?: (role: InitiatorRole | '') => void;
}
const NOOP = (): void => undefined;
@@ -30,7 +45,14 @@ const NOOP = (): void => undefined;
* в какой организации (у совместителя их несколько) и что именно меняется.
* Поля конкретных видов — отдельными секциями (byType/).
*/
export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, context, employeeControl }) => {
export const RequestSection: React.FC<IRequestSectionProps> = ({
data,
set,
context,
employeeControl,
defaultInitiator,
onInitiatorChange
}) => {
const readOnly = useFormReadOnly();
// В просмотре человека нет в состоянии — собираем из сохранённого места работы.
const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined);
@@ -45,7 +67,12 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, cont
// встанет MultiChoiceField, модель и SP уже готовы.
const selectedType = data.ChangeTypes[0] || '';
const handleTypeChange = (value: string): void => {
set('ChangeTypes', value ? [value as ChangeType] : []);
const types: ChangeType[] = value ? [value as ChangeType] : [];
set('ChangeTypes', types);
// Инициатор есть только у видов с выбором: у остальных очищаем, у этих —
// подставляем по умолчанию, если ещё не выбран.
if (!hasInitiatorChoice(types)) set('ChangeInitiator', '');
else if (!data.ChangeInitiator && defaultInitiator) set('ChangeInitiator', defaultInitiator);
};
return (
@@ -82,6 +109,22 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, cont
placeholder={CHANGE_TYPE_PLACEHOLDER}
/>
</FormField>
{hasInitiatorChoice(data.ChangeTypes) && (
<FormField label="Инициатор" htmlFor="ChangeInitiator" required>
<Select
id="ChangeInitiator"
value={data.ChangeInitiator}
onChange={(v) => {
const role = initiatorRoleFromText(v);
if (onInitiatorChange) onInitiatorChange(role);
else set('ChangeInitiator', role);
}}
options={INITIATOR_OPTIONS}
placeholder="— выберите —"
/>
{!readOnly && <div className={styles.note}>{INITIATOR_HINT}</div>}
</FormField>
)}
</FormGrid>
);
};
@@ -2,7 +2,7 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute }
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ensureEmployee } from '../../shared/employee';
import { CHANGE_TYPE_LABELS } from './changeTypes';
import { CHANGE_TYPE_LABELS, INITIATOR_EMPLOYEE, hasInitiatorChoice } from './changeTypes';
import { FORM_ID, IFormData } from './model';
/**
@@ -42,6 +42,16 @@ function employeeColumns(employee: IEmployeeFullBaseRecord | undefined): Record<
};
}
/**
* Причина из списка — только у работодателя: у работника по ТЗ вместо причин
* текст заявления, и списка причин на форме нет. Иначе в заявке осталась бы
* причина, выбранная до смены инициатора, которую никто не видит.
*/
function employerReason(data: IFormData, reason: string): string | undefined {
if (data.ChangeInitiator === INITIATOR_EMPLOYEE) return undefined;
return reason || undefined;
}
/** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */
function numberColumn(raw: string): number | undefined {
const amount = Number(raw);
@@ -109,7 +119,7 @@ function positionTransferColumns(data: IFormData): Record<string, unknown> {
return {
PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null,
PositionTransferDate: data.PositionTransferDate || null,
PositionTransferReason: data.PositionTransferReason || null,
PositionTransferReason: employerReason(data, data.PositionTransferReason) ?? null,
PositionTransferJustification: data.PositionTransferJustification || null
};
}
@@ -133,7 +143,7 @@ function departmentTransferColumns(data: IFormData, receivingHeadId: number | un
DepartmentTransferNewDepartment: data.DepartmentTransferNewDepartment.trim() || null,
DepartmentTransferNewDepartmentId: data.DepartmentTransferNewDepartmentId || null,
DepartmentTransferDate: data.DepartmentTransferDate || null,
DepartmentTransferReason: data.DepartmentTransferReason || null,
DepartmentTransferReason: employerReason(data, data.DepartmentTransferReason) ?? null,
DepartmentTransferJustification: data.DepartmentTransferJustification || null,
DepartmentTransferReceivingHeadId: receivingHeadId ?? null
};
@@ -150,7 +160,7 @@ function workplaceChangeColumns(data: IFormData): Record<string, unknown> {
WorkplaceChangeNewProjectId: data.WorkplaceChangeNewProjectId || null,
WorkplaceChangeAddress: data.WorkplaceChangeAddress.trim() || null,
WorkplaceChangeDate: data.WorkplaceChangeDate || null,
WorkplaceChangeReason: data.WorkplaceChangeReason || null,
WorkplaceChangeReason: employerReason(data, data.WorkplaceChangeReason) ?? null,
WorkplaceChangeRelocation: data.WorkplaceChangeRelocation || null,
WorkplaceChangeRotation: data.WorkplaceChangeRotation,
// Без вахты графика нет, даже если он остался в состоянии формы.
@@ -171,6 +181,7 @@ function buildItem(data: IFormData, status: string, receivingHeadId: number | un
...employeeColumns(data.Employee),
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
ChangeInitiator: hasInitiatorChoice(data.ChangeTypes) ? data.ChangeInitiator || null : null,
...salaryIncreaseColumns(data),
...allowanceColumns(data),
...positionTransferColumns(data),
@@ -0,0 +1,52 @@
import { useRef } from 'react';
import { IEmployeeFullBasePerson } from '../../shared/dictionary';
import { InitiatorRole, hasInitiatorChoice } from './changeTypes';
import { IFormData, SetField, defaultInitiatorRole } from './model';
import { IContractEmployee } from './useContractEmployee';
/** Пропсы шапки (RequestSection), связанные с сотрудником и инициатором. */
export interface IInitiatorAutofill {
/** Тот же выбор сотрудника, но со сменой инициатора вслед за сотрудником. */
employeeControl: IContractEmployee;
/** Инициатор по умолчанию при выборе вида — для текущего сотрудника. */
defaultInitiator: InitiatorRole;
/** Ручной выбор инициатора — после него автоподстановка больше не вмешивается. */
onInitiatorChange: (role: InitiatorRole | '') => void;
}
/**
* Инициатор следует за сотрудником, пока его не выбрали руками: сменили
* сотрудника на себя — «Работник», на другого — «Работодатель» (см.
* defaultInitiatorRole). Без этого инициатор, подставленный при выборе вида,
* оставался бы прежним после смены сотрудника.
*
* У открытой заявки (isEdit) инициатор уже сохранён — считаем его выбранным
* руками и не перебиваем.
*/
export function useInitiatorAutofill(
isEdit: boolean,
data: IFormData,
set: SetField,
applicantEmail: string,
employeeControl: IContractEmployee
): IInitiatorAutofill {
const isChosenByHandRef = useRef(isEdit);
const onPersonChange = (person: IEmployeeFullBasePerson | undefined): void => {
employeeControl.onPersonChange(person);
if (!isChosenByHandRef.current && hasInitiatorChoice(data.ChangeTypes)) {
set('ChangeInitiator', defaultInitiatorRole(person, applicantEmail));
}
};
const onInitiatorChange = (role: InitiatorRole | ''): void => {
isChosenByHandRef.current = true;
set('ChangeInitiator', role);
};
return {
employeeControl: { ...employeeControl, onPersonChange },
defaultInitiator: defaultInitiatorRole(employeeControl.person, applicantEmail),
onInitiatorChange
};
}