feat: поля вида «Перевод в другое подразделение» в изменении условий ТД
Четвёртый вид: новое подразделение из справочника, дата, причина а–г и обоснование. По ТЗ согласие принимающего руководителя — шаг маршрута, а approval-route руководителя нового подразделения найти не умеет, поэтому пока форма сама ставит выбранного принимающего руководителя первым шагом согласования (временно, до шага в админке маршрутов). Логика маршрута вынесена в useContractRoute — форма перерастала 300 строк. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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). */
|
||||
|
||||
+64
@@ -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 }));
|
||||
+55
@@ -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('Руководитель');
|
||||
});
|
||||
});
|
||||
+68
@@ -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
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user