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 * as React from 'react';
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import styles from './ContractChangeForm.module.scss';
|
import styles from './ContractChangeForm.module.scss';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -18,20 +18,14 @@ import {
|
|||||||
ExecutorsMatrix,
|
ExecutorsMatrix,
|
||||||
ApprovalHistory,
|
ApprovalHistory,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
useMatrixEditor,
|
|
||||||
getPlannedMatrix,
|
|
||||||
loadRoutePhase,
|
|
||||||
isOpenedRouteReady,
|
|
||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId,
|
useRequestTypeId,
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
isEditableStatus,
|
isEditableStatus,
|
||||||
IApproverToWrite,
|
|
||||||
unassignedStepMessage,
|
unassignedStepMessage,
|
||||||
resolveWriteListId,
|
resolveWriteListId,
|
||||||
executorsToWrite,
|
executorsToWrite,
|
||||||
initialSubmitStatus,
|
initialSubmitStatus
|
||||||
useFormExecutors
|
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
@@ -42,13 +36,22 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} 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 { saveContractChangeRequest, contractChangeApprovalService } from './submit';
|
||||||
import { loadContractChangeRequest } from './load';
|
import { loadContractChangeRequest } from './load';
|
||||||
import { useContractEmployee } from './useContractEmployee';
|
import { useContractEmployee } from './useContractEmployee';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
import { ChangeTypeSections } from './byType/ChangeTypeSections';
|
import { ChangeTypeSections } from './byType/ChangeTypeSections';
|
||||||
import { missingRequiredAttachments, prepareFilesFor } from './byType/attachmentSlots';
|
import { missingRequiredAttachments, prepareFilesFor } from './byType/attachmentSlots';
|
||||||
|
import { useContractRoute, matrixToApprovers } from './useContractRoute';
|
||||||
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||||
import { getAttachments } from '../../shared/attachments/attachments.service';
|
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
@@ -114,32 +117,18 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
|
|||||||
set('Employee', record)
|
set('Employee', record)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Маршрут — по сотруднику, а без учётной записи — по заявителю (см. routeEmail).
|
// Маршрут — по сотруднику, без учётной записи — по заявителю (см. routeEmail); при
|
||||||
// У заявки на редактировании маршрут читается из уже записанных строк.
|
// переводе в другое подразделение принимающий руководитель — первым шагом.
|
||||||
const routeUserEmail = routeEmail(employeeControl.person, applicantEmail);
|
const isDepartmentTransfer = data.ChangeTypes.includes('department-transfer');
|
||||||
const loadMatrix = useCallback(
|
const { matrix, executors } = useContractRoute({
|
||||||
() =>
|
|
||||||
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,
|
|
||||||
context,
|
context,
|
||||||
approval,
|
approval,
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
userEmail: routeUserEmail,
|
routeUserEmail: routeEmail(employeeControl.person, applicantEmail),
|
||||||
editId,
|
editId,
|
||||||
requestStatus
|
requestStatus,
|
||||||
|
readOnly,
|
||||||
|
receivingHeadEmail: !readOnly && isDepartmentTransfer ? data.DepartmentTransferReceivingHead : ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
|
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
|
||||||
@@ -150,17 +139,6 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
|
|||||||
return undefined;
|
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> => {
|
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||||
// validate() уже гарантировал requestTypeId, но TS этого через замыкание не видит.
|
// validate() уже гарантировал requestTypeId, но TS этого через замыкание не видит.
|
||||||
const rtId = requestTypeId;
|
const rtId = requestTypeId;
|
||||||
@@ -173,7 +151,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
|
|||||||
requestId,
|
requestId,
|
||||||
rtId,
|
rtId,
|
||||||
data,
|
data,
|
||||||
toApprovers(matrix.rows),
|
matrixToApprovers(matrix.rows),
|
||||||
executorsToWrite(executors.rows),
|
executorsToWrite(executors.rows),
|
||||||
status,
|
status,
|
||||||
writeListId
|
writeListId
|
||||||
@@ -210,6 +188,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
|
|||||||
// Обязательные документы — только на отправку: черновик можно сохранить и без файла.
|
// Обязательные документы — только на отправку: черновик можно сохранить и без файла.
|
||||||
const missingFiles = missingRequiredAttachments(data, attachments);
|
const missingFiles = missingRequiredAttachments(data, attachments);
|
||||||
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
|
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
|
||||||
|
if (isDepartmentTransfer && !data.DepartmentTransferReceivingHead) return RECEIVING_HEAD_REQUIRED_MESSAGE;
|
||||||
if (matrix.error) {
|
if (matrix.error) {
|
||||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,23 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
|
|||||||
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
|
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
|
||||||
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
|
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
|
||||||
| Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно |
|
| Перевод на другую должность (постоянный) | `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): фиксированные
|
**Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные
|
||||||
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
|
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ISectionProps } from '../model';
|
|||||||
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
|
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
|
||||||
import { AllowanceSection } from './Allowance/AllowanceSection';
|
import { AllowanceSection } from './Allowance/AllowanceSection';
|
||||||
import { PositionTransferSection } from './PositionTransfer/PositionTransferSection';
|
import { PositionTransferSection } from './PositionTransfer/PositionTransferSection';
|
||||||
|
import { DepartmentTransferSection } from './DepartmentTransfer/DepartmentTransferSection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
|
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
|
||||||
@@ -13,7 +14,8 @@ import { PositionTransferSection } from './PositionTransfer/PositionTransferSect
|
|||||||
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
|
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
|
||||||
'salary-increase': SalaryIncreaseSection,
|
'salary-increase': SalaryIncreaseSection,
|
||||||
allowance: AllowanceSection,
|
allowance: AllowanceSection,
|
||||||
'position-transfer': PositionTransferSection
|
'position-transfer': PositionTransferSection,
|
||||||
|
'department-transfer': DepartmentTransferSection
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES). */
|
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. 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',
|
'PositionTransferNewPosition',
|
||||||
'PositionTransferDate',
|
'PositionTransferDate',
|
||||||
'PositionTransferReason',
|
'PositionTransferReason',
|
||||||
'PositionTransferJustification'
|
'PositionTransferJustification',
|
||||||
)()) as Record<string, unknown>;
|
'DepartmentTransferNewDepartment',
|
||||||
|
'DepartmentTransferNewDepartmentId',
|
||||||
|
'DepartmentTransferDate',
|
||||||
|
'DepartmentTransferReason',
|
||||||
|
'DepartmentTransferJustification',
|
||||||
|
'DepartmentTransferReceivingHead/EMail'
|
||||||
|
)
|
||||||
|
.expand('DepartmentTransferReceivingHead')()) as Record<string, unknown>;
|
||||||
|
const receivingHead = item.DepartmentTransferReceivingHead as { EMail?: string } | undefined;
|
||||||
|
|
||||||
const data: IFormData = {
|
const data: IFormData = {
|
||||||
...EMPTY_DATA,
|
...EMPTY_DATA,
|
||||||
@@ -126,7 +134,13 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
|
|||||||
PositionTransferNewPosition: text(item.PositionTransferNewPosition),
|
PositionTransferNewPosition: text(item.PositionTransferNewPosition),
|
||||||
PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)),
|
PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)),
|
||||||
PositionTransferReason: text(item.PositionTransferReason),
|
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 {
|
return {
|
||||||
|
|||||||
@@ -74,6 +74,24 @@ export interface IFormData {
|
|||||||
PositionTransferReason: string;
|
PositionTransferReason: string;
|
||||||
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
|
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
|
||||||
PositionTransferJustification: 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: '',
|
PositionTransferNewPosition: '',
|
||||||
PositionTransferDate: '',
|
PositionTransferDate: '',
|
||||||
PositionTransferReason: '',
|
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 ROUTE_BY_APPLICANT_NOTE =
|
||||||
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
|
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
|
||||||
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
|
export const REQUIRED_FIELDS_MESSAGE = 'Заполните обязательные поля: сотрудник, организация, вид изменения.';
|
||||||
|
export const RECEIVING_HEAD_REQUIRED_MESSAGE =
|
||||||
|
'Укажите принимающего руководителя — его согласие входит в маршрут перевода в другое подразделение.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной
|
* 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 { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||||
|
import { ensureEmployee } from '../../shared/employee';
|
||||||
import { CHANGE_TYPE_LABELS } from './changeTypes';
|
import { CHANGE_TYPE_LABELS } from './changeTypes';
|
||||||
import { FORM_ID, IFormData } from './model';
|
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 {
|
return {
|
||||||
Title: buildTitle(data.Employee?.fio || ''),
|
Title: buildTitle(data.Employee?.fio || ''),
|
||||||
RequestStatus: status,
|
RequestStatus: status,
|
||||||
@@ -123,10 +149,18 @@ function buildItem(data: IFormData, status: string): Record<string, unknown> {
|
|||||||
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
|
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
|
||||||
...salaryIncreaseColumns(data),
|
...salaryIncreaseColumns(data),
|
||||||
...allowanceColumns(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;
|
export type ISaveResult = ISaveItemResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,7 +180,7 @@ export async function saveContractChangeRequest(
|
|||||||
status: string,
|
status: string,
|
||||||
listId?: string
|
listId?: string
|
||||||
): Promise<ISaveResult> {
|
): 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);
|
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||||
await writeRoute(contractChangeApprovalService(listId), result.id, approvers, executors, status);
|
await writeRoute(contractChangeApprovalService(listId), result.id, approvers, executors, status);
|
||||||
return result;
|
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