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

Четвёртый вид: новое подразделение из справочника, дата, причина а–г и
обоснование. По ТЗ согласие принимающего руководителя — шаг маршрута, а
approval-route руководителя нового подразделения найти не умеет, поэтому
пока форма сама ставит выбранного принимающего руководителя первым шагом
согласования (временно, до шага в админке маршрутов). Логика маршрута
вынесена в useContractRoute — форма перерастала 300 строк.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-29 14:20:41 +05:00
parent b77261079b
commit 87c9f92d48
12 changed files with 427 additions and 53 deletions
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useState, useMemo, useEffect } from 'react';
import styles from './ContractChangeForm.module.scss';
import {
Alert,
@@ -18,20 +18,14 @@ import {
ExecutorsMatrix,
ApprovalHistory,
StatusBadge,
useMatrixEditor,
getPlannedMatrix,
loadRoutePhase,
isOpenedRouteReady,
notifyActiveStep,
useRequestTypeId,
REQUEST_STATUS,
isEditableStatus,
IApproverToWrite,
unassignedStepMessage,
resolveWriteListId,
executorsToWrite,
initialSubmitStatus,
useFormExecutors
initialSubmitStatus
} from '../../shared/approval';
import { effectiveUserEmail } from '../../shared/impersonation';
import { IRequestFormProps } from '../../models/types';
@@ -42,13 +36,22 @@ 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,
RECEIVING_HEAD_REQUIRED_MESSAGE,
routeEmail
} from './model';
import { saveContractChangeRequest, contractChangeApprovalService } from './submit';
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 { useContractRoute, matrixToApprovers } from './useContractRoute';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { getAttachments } from '../../shared/attachments/attachments.service';
import { useStableCallback } from '../../shared/useStableCallback';
@@ -114,32 +117,18 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
set('Employee', record)
);
// Маршрут — по сотруднику, а без учётной записи — по заявителю (см. routeEmail).
// У заявки на редактировании маршрут читается из уже записанных строк.
const routeUserEmail = routeEmail(employeeControl.person, applicantEmail);
const loadMatrix = useCallback(
() =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, routeUserEmail, approval, 'Approval')
),
[context, approval, editId, requestStatus, routeUserEmail, requestTypeId]
);
const matrix = useMatrixEditor(
loadMatrix,
`${routeUserEmail}-${editId ?? 'new'}`,
!readOnly,
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!routeUserEmail && !!requestTypeId)),
(login) => approval.getJobTitle(login)
);
const executors = useFormExecutors({
readOnly,
// Маршрут — по сотруднику, без учётной записи — по заявителю (см. routeEmail); при
// переводе в другое подразделение принимающий руководитель — первым шагом.
const isDepartmentTransfer = data.ChangeTypes.includes('department-transfer');
const { matrix, executors } = useContractRoute({
context,
approval,
requestTypeId,
userEmail: routeUserEmail,
routeUserEmail: routeEmail(employeeControl.person, applicantEmail),
editId,
requestStatus
requestStatus,
readOnly,
receivingHeadEmail: !readOnly && isDepartmentTransfer ? data.DepartmentTransferReceivingHead : ''
});
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
@@ -150,17 +139,6 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
return undefined;
};
const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] =>
rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order,
slaHours: r.slaHours,
askOverdueReason: r.askOverdueReason,
autoApprove: r.autoApprove
}));
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
// validate() уже гарантировал requestTypeId, но TS этого через замыкание не видит.
const rtId = requestTypeId;
@@ -173,7 +151,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
requestId,
rtId,
data,
toApprovers(matrix.rows),
matrixToApprovers(matrix.rows),
executorsToWrite(executors.rows),
status,
writeListId
@@ -210,6 +188,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
// Обязательные документы — только на отправку: черновик можно сохранить и без файла.
const missingFiles = missingRequiredAttachments(data, attachments);
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
if (isDepartmentTransfer && !data.DepartmentTransferReceivingHead) return RECEIVING_HEAD_REQUIRED_MESSAGE;
if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
}
@@ -60,7 +60,23 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
| Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно |
| Остальные 7 | — | ещё не разобраны с кадрами |
| Перевод в другое подразделение | `DepartmentTransfer` | новое подразделение (`DepartmentPicker`, имя + id), дата перевода, причина (усиление подразделения / перераспределение функций / вакансия / иное), обоснование, **принимающий руководитель** (People, обязателен при отправке) — см. ниже |
| Остальные 6 | — | ещё не разобраны с кадрами |
**Принимающий руководитель — временное решение (2026-10-29).** По ТЗ (лист
«Маршрут»: А шаг 2, Б шаг 3 параллельно с непосредственным руководителем) его
согласие — шаг маршрута. Approval-route строит маршрут по сотруднику и найти
руководителя НОВОГО подразделения не умеет, поэтому шаг вставляет форма:
`byType/DepartmentTransfer/receivingHead.ts` (`withReceivingHead`, с тестами) —
первым шагом, с должностью-пометкой «Принимающий руководитель» (по ней шаг
узнаётся у черновика); вызывается из `useContractRoute`. Смена человека/вида
пересобирает маршрут. Ограничения: человек выбирается вручную; шаг всегда первым и
последовательным (для маршрута Б параллельность инициатор может включить в матрице);
SLA и настроек из админки у шага нет. **Правильное решение** — тип шага в
approval-route «руководитель подразделения из заявки» по `departmentId` (его
форма уже хранит в `DepartmentTransferNewDepartmentId`). Когда появится — удалить
поле, `receivingHead.ts` и `receivingHeadEmail` в `useContractRoute`, передать
`departmentId` в резолв.
**Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
@@ -4,6 +4,7 @@ import { ISectionProps } from '../model';
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
import { AllowanceSection } from './Allowance/AllowanceSection';
import { PositionTransferSection } from './PositionTransfer/PositionTransferSection';
import { DepartmentTransferSection } from './DepartmentTransfer/DepartmentTransferSection';
/**
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
@@ -13,7 +14,8 @@ import { PositionTransferSection } from './PositionTransfer/PositionTransferSect
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
'salary-increase': SalaryIncreaseSection,
allowance: AllowanceSection,
'position-transfer': PositionTransferSection
'position-transfer': PositionTransferSection,
'department-transfer': DepartmentTransferSection
};
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES). */
@@ -0,0 +1,64 @@
import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select, PeopleField } from '../../../../shared/ui';
import { DepartmentPicker } from '../../../../shared/dictionary';
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
import { ISectionProps } from '../../model';
import { DEPARTMENT_TRANSFER_REASONS } from './options';
/**
* Поля вида «Перевод в другое подразделение». Состав согласован с Тимуром по ТЗ
* кадров: новое подразделение (справочник), дата, причина (а–г), обоснование и
* принимающий руководитель. Его согласие по ТЗ — шаг маршрута: форма сама ставит
* его первым шагом согласования (см. receivingHead.ts). Обязателен при отправке.
*
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
*/
export const DepartmentTransferSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title={CHANGE_TYPE_LABELS['department-transfer']} icon="people">
<FormField label="Новое подразделение" htmlFor="DepartmentTransferNewDepartment">
<DepartmentPicker
context={context}
id="DepartmentTransferNewDepartment"
value={data.DepartmentTransferNewDepartment}
valueId={data.DepartmentTransferNewDepartmentId}
onChange={(name, id) => {
set('DepartmentTransferNewDepartment', name);
set('DepartmentTransferNewDepartmentId', id);
}}
placeholder="Начните вводить название подразделения…"
/>
</FormField>
<FormField label="Дата перевода" htmlFor="DepartmentTransferDate">
<TextInput
id="DepartmentTransferDate"
type="date"
value={data.DepartmentTransferDate}
onChange={(v) => set('DepartmentTransferDate', v)}
/>
</FormField>
<FormField label="Принимающий руководитель" htmlFor="DepartmentTransferReceivingHead" required full>
<PeopleField
context={context}
value={data.DepartmentTransferReceivingHead}
onChange={(v) => set('DepartmentTransferReceivingHead', v)}
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>
</CollapseSection>
);
@@ -0,0 +1,13 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины перевода в другое подразделение — по шаблону текста ТЗ кадров (а–г).
* Работника и работодателя не различаем (как в переводе на должность).
* Значения совпадают с choices колонки DepartmentTransferReason (provisioning.json).
*/
export const DEPARTMENT_TRANSFER_REASONS: ISelectOption[] = [
'Усиление подразделения (рост объёма работ / запуск нового объекта)',
'Перераспределение функций между подразделениями',
'Вакансия, соответствующая квалификации работника',
'Иное'
].map((reason) => ({ value: reason, label: reason }));
@@ -0,0 +1,55 @@
import { IMatrixRow } from '../../../../shared/approval';
import { withReceivingHead, RECEIVING_HEAD_POSITION } from './receivingHead';
const row = (order: number, email: string, position = 'Директор'): IMatrixRow => ({
order,
who: email,
email,
position,
status: '—',
date: '',
comment: '',
allowReassign: false
});
const head = { email: 'head@tnsu.kz', name: 'Принимающий Иван' };
describe('withReceivingHead', () => {
it('вставляет принимающего первым шагом и сдвигает остальные', () => {
const result = withReceivingHead([row(1, 'hrd@tnsu.kz'), row(2, 'dir@tnsu.kz')], head);
expect(result.map((r) => [r.order, r.email, r.position])).toEqual([
[1, 'head@tnsu.kz', RECEIVING_HEAD_POSITION],
[2, 'hrd@tnsu.kz', 'Директор'],
[3, 'dir@tnsu.kz', 'Директор']
]);
});
it('сохраняет параллельные шаги параллельными', () => {
const result = withReceivingHead([row(1, 'a@tnsu.kz'), row(1, 'b@tnsu.kz'), row(2, 'c@tnsu.kz')], head);
expect(result.map((r) => r.order)).toEqual([1, 2, 2, 3]);
});
it('без принимающего убирает его шаг и перенумеровывает', () => {
const withHead = withReceivingHead([row(1, 'hrd@tnsu.kz')], head);
expect(withReceivingHead(withHead, undefined).map((r) => [r.order, r.email])).toEqual([[1, 'hrd@tnsu.kz']]);
});
it('при смене человека заменяет шаг, а не добавляет второй', () => {
const withHead = withReceivingHead([row(1, 'hrd@tnsu.kz')], head);
const result = withReceivingHead(withHead, { email: 'new@tnsu.kz', name: 'Новый' });
expect(result.filter((r) => r.position === RECEIVING_HEAD_POSITION).map((r) => r.email)).toEqual(['new@tnsu.kz']);
expect(result).toHaveLength(2);
});
it('тот же человек — строку не трогает (статус и комментарий доработки сохраняются)', () => {
const decided = { ...row(1, 'head@tnsu.kz', RECEIVING_HEAD_POSITION), status: 'Согласовано', comment: 'ок' };
const rows = [decided, row(2, 'hrd@tnsu.kz')];
expect(withReceivingHead(rows, head)).toBe(rows);
});
it('не дублирует человека, который уже есть в маршруте другой ролью', () => {
const result = withReceivingHead([row(1, 'HEAD@tnsu.kz', 'Руководитель')], head);
expect(result).toHaveLength(1);
expect(result[0].position).toBe('Руководитель');
});
});
@@ -0,0 +1,68 @@
import { IMatrixRow } from '../../../../shared/approval';
/**
* Принимающий руководитель в маршруте согласования (вид «Перевод в другое
* подразделение»). По ТЗ (лист «Маршрут», А шаг 2 / Б шаг 3) это отдельный шаг
* согласия. Approval-route строит маршрут по сотруднику и найти руководителя
* НОВОГО подразделения не может — поэтому шаг вставляет сама форма.
*
* Шаг узнаётся по должности-пометке: она сохраняется в строке маршрута (Title
* списка согласования) и читается обратно, поэтому у черновика шаг тоже находится.
*/
export const RECEIVING_HEAD_POSITION = 'Принимающий руководитель';
export interface IReceivingHead {
email: string;
name: string;
}
function isReceivingHeadRow(row: IMatrixRow): boolean {
return row.position === RECEIVING_HEAD_POSITION;
}
function sameEmail(a: string, b: string): boolean {
return !!a && a.toLowerCase() === b.toLowerCase();
}
/**
* Перенумеровать шаги подряд с 1, сохранив параллельность: строки с одинаковым
* order (параллельный шаг) снова получают одинаковый номер.
*/
function renumber(rows: IMatrixRow[]): IMatrixRow[] {
let order = 0;
let prevOrder: number | undefined;
return rows.map((row) => {
if (row.order !== prevOrder) order += 1;
prevOrder = row.order;
return { ...row, order };
});
}
/**
* Маршрут с принимающим руководителем ПЕРВЫМ шагом (сразу после инициатора, как
* в маршруте А). head не задан — шаг убирается (сменили вид или очистили поле).
*
* - Тот же человек уже стоит этим шагом — строку не трогаем: у возвращённой на
* доработку заявки в ней может быть статус и комментарий.
* - Человек уже есть в маршруте другой ролью (напр. он же непосредственный
* руководитель) — второй строкой не добавляем.
*/
export function withReceivingHead(rows: IMatrixRow[], head: IReceivingHead | undefined): IMatrixRow[] {
const existing = rows.find(isReceivingHeadRow);
if (head && existing && sameEmail(existing.email, head.email)) return rows;
const others = rows.filter((row) => !isReceivingHeadRow(row));
if (!head || others.some((row) => sameEmail(row.email, head.email))) return renumber(others);
const headRow: IMatrixRow = {
order: 0, // перенумерует renumber
who: head.name,
email: head.email,
position: RECEIVING_HEAD_POSITION,
status: others[0]?.status || '—',
date: '',
comment: '',
allowReassign: false
};
return renumber([headRow, ...others]);
}
@@ -103,8 +103,16 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
'PositionTransferNewPosition',
'PositionTransferDate',
'PositionTransferReason',
'PositionTransferJustification'
)()) as Record<string, unknown>;
'PositionTransferJustification',
'DepartmentTransferNewDepartment',
'DepartmentTransferNewDepartmentId',
'DepartmentTransferDate',
'DepartmentTransferReason',
'DepartmentTransferJustification',
'DepartmentTransferReceivingHead/EMail'
)
.expand('DepartmentTransferReceivingHead')()) as Record<string, unknown>;
const receivingHead = item.DepartmentTransferReceivingHead as { EMail?: string } | undefined;
const data: IFormData = {
...EMPTY_DATA,
@@ -126,7 +134,13 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
PositionTransferNewPosition: text(item.PositionTransferNewPosition),
PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)),
PositionTransferReason: text(item.PositionTransferReason),
PositionTransferJustification: text(item.PositionTransferJustification)
PositionTransferJustification: text(item.PositionTransferJustification),
DepartmentTransferNewDepartment: text(item.DepartmentTransferNewDepartment),
DepartmentTransferNewDepartmentId: text(item.DepartmentTransferNewDepartmentId),
DepartmentTransferDate: spDateOnlyToIso(text(item.DepartmentTransferDate)),
DepartmentTransferReason: text(item.DepartmentTransferReason),
DepartmentTransferJustification: text(item.DepartmentTransferJustification),
DepartmentTransferReceivingHead: receivingHead?.EMail || ''
};
return {
@@ -74,6 +74,24 @@ export interface IFormData {
PositionTransferReason: string;
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
PositionTransferJustification: string;
// ── Вид «Перевод в другое подразделение» (byType/DepartmentTransfer).
// Поля необязательные, кроме принимающего руководителя (при отправке).
/** Новое подразделение (справочник Dictionary/Departments, DepartmentPicker). */
DepartmentTransferNewDepartment: string;
/** Id подразделения из справочника; '' — введено вручную. */
DepartmentTransferNewDepartmentId: string;
/** С какой даты, ISO YYYY-MM-DD. */
DepartmentTransferDate: string;
/** Причина — одно из DEPARTMENT_TRANSFER_REASONS (Choice-колонка); '' — не выбрана. */
DepartmentTransferReason: string;
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
DepartmentTransferJustification: string;
/**
* E-mail принимающего руководителя (People). Форма ставит его первым шагом
* маршрута согласования (byType/DepartmentTransfer/receivingHead.ts). '' — не выбран.
*/
DepartmentTransferReceivingHead: string;
}
/** Обновление одного поля формы с сохранением типа значения. */
@@ -106,7 +124,13 @@ export const EMPTY_DATA: IFormData = {
PositionTransferNewPosition: '',
PositionTransferDate: '',
PositionTransferReason: '',
PositionTransferJustification: ''
PositionTransferJustification: '',
DepartmentTransferNewDepartment: '',
DepartmentTransferNewDepartmentId: '',
DepartmentTransferDate: '',
DepartmentTransferReason: '',
DepartmentTransferJustification: '',
DepartmentTransferReceivingHead: ''
};
/** Плейсхолдер для поля выбора сотрудника. */
@@ -122,6 +146,8 @@ export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид измене
export const ROUTE_BY_APPLICANT_NOTE =
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
export const RECEIVING_HEAD_REQUIRED_MESSAGE =
'Укажите принимающего руководителя — его согласие входит в маршрут перевода в другое подразделение.';
/**
* E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной
@@ -58,6 +58,18 @@
"Открытие нового объекта / проекта, перераспределение функций",
"Иное"
] },
{ "internalName": "PositionTransferJustification", "title": "Перевод на должность: обоснование", "type": "Note" }
{ "internalName": "PositionTransferJustification", "title": "Перевод на должность: обоснование", "type": "Note" },
{ "internalName": "DepartmentTransferNewDepartment", "title": "Перевод в подразделение: новое подразделение", "type": "Text" },
{ "internalName": "DepartmentTransferNewDepartmentId", "title": "Перевод в подразделение: id подразделения", "type": "Text" },
{ "internalName": "DepartmentTransferDate", "title": "Перевод в подразделение: дата перевода", "type": "DateTime", "dateOnly": true },
{ "internalName": "DepartmentTransferReason", "title": "Перевод в подразделение: причина", "type": "Choice", "choices": [
"Усиление подразделения (рост объёма работ / запуск нового объекта)",
"Перераспределение функций между подразделениями",
"Вакансия, соответствующая квалификации работника",
"Иное"
] },
{ "internalName": "DepartmentTransferJustification", "title": "Перевод в подразделение: обоснование", "type": "Note" },
{ "internalName": "DepartmentTransferReceivingHead", "title": "Перевод в подразделение: принимающий руководитель", "type": "User", "userSelectionMode": "PeopleOnly" }
]
}
@@ -1,6 +1,7 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ensureEmployee } from '../../shared/employee';
import { CHANGE_TYPE_LABELS } from './changeTypes';
import { FORM_ID, IFormData } from './model';
@@ -113,8 +114,33 @@ function positionTransferColumns(data: IFormData): Record<string, unknown> {
};
}
/**
* Колонки вида «Перевод в другое подразделение»; вид не выбран — очищаются.
* receivingHeadId — Id пользователя сайта для People-колонки (см. saveContractChangeRequest).
*/
function departmentTransferColumns(data: IFormData, receivingHeadId: number | undefined): Record<string, unknown> {
if (!data.ChangeTypes.includes('department-transfer')) {
return {
DepartmentTransferNewDepartment: null,
DepartmentTransferNewDepartmentId: null,
DepartmentTransferDate: null,
DepartmentTransferReason: null,
DepartmentTransferJustification: null,
DepartmentTransferReceivingHeadId: null
};
}
return {
DepartmentTransferNewDepartment: data.DepartmentTransferNewDepartment.trim() || null,
DepartmentTransferNewDepartmentId: data.DepartmentTransferNewDepartmentId || null,
DepartmentTransferDate: data.DepartmentTransferDate || null,
DepartmentTransferReason: data.DepartmentTransferReason || null,
DepartmentTransferJustification: data.DepartmentTransferJustification || null,
DepartmentTransferReceivingHeadId: receivingHeadId ?? null
};
}
/** Поля элемента списка данных из модели формы. */
function buildItem(data: IFormData, status: string): Record<string, unknown> {
function buildItem(data: IFormData, status: string, receivingHeadId: number | undefined): Record<string, unknown> {
return {
Title: buildTitle(data.Employee?.fio || ''),
RequestStatus: status,
@@ -123,10 +149,18 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
...salaryIncreaseColumns(data),
...allowanceColumns(data),
...positionTransferColumns(data)
...positionTransferColumns(data),
...departmentTransferColumns(data, receivingHeadId)
};
}
/** People-колонка пишется Id пользователя сайта: e-mail → ensureUser. Не выбран — undefined. */
async function receivingHeadUserId(data: IFormData): Promise<number | undefined> {
if (!data.ChangeTypes.includes('department-transfer') || !data.DepartmentTransferReceivingHead) return undefined;
const user = await ensureEmployee(data.DepartmentTransferReceivingHead);
return user.Id;
}
export type ISaveResult = ISaveItemResult;
/**
@@ -146,7 +180,7 @@ export async function saveContractChangeRequest(
status: string,
listId?: string
): Promise<ISaveResult> {
const item = buildItem(data, status);
const item = buildItem(data, status, await receivingHeadUserId(data));
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
await writeRoute(contractChangeApprovalService(listId), result.id, approvers, executors, status);
return result;
@@ -0,0 +1,91 @@
import { useCallback } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
ApprovalListService,
IApproverToWrite,
IMatrixRow,
IUseMatrixEditor,
getPlannedMatrix,
isOpenedRouteReady,
loadRoutePhase,
useFormExecutors,
useMatrixEditor
} from '../../shared/approval';
import { withReceivingHead } from './byType/DepartmentTransfer/receivingHead';
export interface IContractRouteSource {
context: WebPartContext;
approval: ApprovalListService;
requestTypeId: number | undefined;
/** По кому строится маршрут: сотрудник, без учётки — заявитель (см. routeEmail). */
routeUserEmail: string;
editId: number | undefined;
requestStatus: string | undefined;
readOnly: boolean;
/**
* Принимающий руководитель (перевод в другое подразделение) — ставится первым
* шагом согласования, см. receivingHead.ts. '' — шага нет (другой вид / не выбран).
*/
receivingHeadEmail: string;
}
export interface IContractRoute {
matrix: IUseMatrixEditor;
executors: IUseMatrixEditor;
}
/**
* Маршрут формы: согласование + исполнение. Новая заявка — планируемый маршрут
* из админки, открытая — уже записанные строки (loadRoutePhase). Пока маршрут
* правится, в него вставляется принимающий руководитель — approval-route строит
* маршрут по сотруднику и найти руководителя нового подразделения не может.
*/
export function useContractRoute(source: IContractRouteSource): IContractRoute {
const { context, approval, requestTypeId, routeUserEmail, editId, requestStatus, readOnly, receivingHeadEmail } =
source;
const loadMatrix = useCallback(async (): Promise<IMatrixRow[]> => {
const rows = await loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, routeUserEmail, approval, 'Approval')
);
if (readOnly) return rows;
const head = receivingHeadEmail
? { email: receivingHeadEmail, name: await approval.resolveUserName(receivingHeadEmail) }
: undefined;
return withReceivingHead(rows, head);
}, [context, approval, editId, requestStatus, routeUserEmail, requestTypeId, readOnly, receivingHeadEmail]);
const matrix = useMatrixEditor(
loadMatrix,
// Смена принимающего руководителя пересобирает маршрут, как и смена сотрудника.
`${routeUserEmail}-${editId ?? 'new'}-${receivingHeadEmail}`,
!readOnly,
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!routeUserEmail && !!requestTypeId)),
(login) => approval.getJobTitle(login)
);
const executors = useFormExecutors({
readOnly,
context,
approval,
requestTypeId,
userEmail: routeUserEmail,
editId,
requestStatus
});
return { matrix, executors };
}
/** Строки матрицы → согласующие для записи в список (SLA и прочее — транзитом из админки). */
export function matrixToApprovers(rows: IMatrixRow[]): IApproverToWrite[] {
return rows.map((r) => ({
email: r.email,
position: r.position,
allowReassign: r.allowReassign,
order: r.order,
slaHours: r.slaHours,
askOverdueReason: r.askOverdueReason,
autoApprove: r.autoApprove
}));
}