Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3495e681e7 | |||
| 583f400e26 | |||
| 36432c75e6 | |||
| 45fc06a6ac | |||
| 14ac640c50 | |||
| 2434ead754 | |||
| bdd8da9b89 | |||
| f61418dbe2 | |||
| 55659f94cf | |||
| 040ab475b5 | |||
| c4b2cb3c3d | |||
| 87c9f92d48 | |||
| b77261079b | |||
| 0005bf0cbd | |||
| 614a9d7e37 | |||
| efa3fdf65e | |||
| 16699ef2e2 | |||
| 9fd94e0adf | |||
| d3940ce584 | |||
| 49568e00e8 | |||
| f0951cdda2 | |||
| 26ddfcb5ba |
@@ -1 +1 @@
|
|||||||
@tnsu:registry=http://git.tnsu.kz/api/packages/Tansu/npm/
|
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ Info "Подключаем @tnsu/ui-kit..."
|
|||||||
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
|
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
|
||||||
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
|
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
|
||||||
$npmrc = @'
|
$npmrc = @'
|
||||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||||
'@
|
'@
|
||||||
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
|
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
|
||||||
Success ".npmrc создан (адрес registry @tnsu)."
|
Success ".npmrc создан (адрес registry @tnsu)."
|
||||||
@@ -282,7 +282,7 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
|
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
|
||||||
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
|
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
|
||||||
Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН"
|
Warn " //git.tnsu.kz/api/packages/tansu/npm/:_authToken=ТВОЙ_ТОКЕН"
|
||||||
Warn "Потом выполни: npm install @tnsu/ui-kit"
|
Warn "Потом выполни: npm install @tnsu/ui-kit"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@
|
|||||||
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
|
| [`hr-skud-explanation`](../src/webparts/requestPortal/forms/SkudExplanation/README.md) | Объяснительная об отсутствии отметок в СКУД | Отдел кадров | да | нет | да | только «на себя»; дни выбираются кликом в календаре «Мой СКУД» (проходы Hikvision текущего пользователя), сохраняются снимком для согласующего; текст записки собирается автоматически, печать — бланк объяснительной | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||||
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; командирующая организация — выбор из организаций сотрудника; маршрут лентой (точки по Казахстану — город из справочника КАТО на русском и казахском + объект из справочника, за рубежом — страна/город/объект вручную; даты не задним числом, поездка ≤ 40 дней; участки с видом транспорта и временем выезда); суточные (≤ 6 МРП КЗ / ≤ 8 за рубежом) и проживание (ночи сами, ≤ 15 000 ₸ за ночь КЗ) — по точкам; комментарий после итога; задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
|
| [`hr-business-trip`](../src/webparts/requestPortal/forms/BusinessTrip/README.md) | Заявка на командировку | Отдел кадров | да | да | да | можно за другого сотрудника; командирующая организация — выбор из организаций сотрудника; маршрут лентой (точки по Казахстану — город из справочника КАТО на русском и казахском + объект из справочника, за рубежом — страна/город/объект вручную; даты не задним числом, поездка ≤ 40 дней; участки с видом транспорта и временем выезда); суточные (≤ 6 МРП КЗ / ≤ 8 за рубежом) и проживание (ночи сами, ≤ 15 000 ₸ за ночь КЗ) — по точкам; комментарий после итога; задание — список задач; смета: статьи с позициями, привязанными к точкам/участкам (суммой или кол-во × цена — всё вручную), итог с долями и по объектам; обязательство по авансовому отчёту — флажок; печать без шапки адресата | ⏳ код готов, списки/тип/маршрут не заведены |
|
||||||
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
| `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||||
| [`hr-contract-change`](../src/webparts/requestPortal/forms/ContractChange/README.md) | Изменение условий трудового договора | Отдел кадров | да | — | да | сотрудник из `Employee/FullBase` (и без учётки M365), снимок в текстовых колонках; маршрут по сотруднику, без учётки — по заявителю; 10 видов изменения (один, данные под несколько); поля по видам — в работе | ⏳ каркас, списки/тип/маршрут не заведены |
|
| [`hr-contract-change`](../src/webparts/requestPortal/forms/ContractChange/README.md) | Изменение условий трудового договора | Отдел кадров | да | — | да | сотрудник из `Employee/FullBase` (и без учётки M365), снимок в текстовых колонках; маршрут по сотруднику, без учётки — по заявителю; 10 видов изменения (один, данные под несколько), поля по каждому виду по ТЗ кадров (кроме «Продления срока ТД»); инициатор работник/работодатель; подписанные вложения по «Что приложить» | ⏳ код готов (кроме вида 6), списки/тип/маршрут не заведены |
|
||||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||||
|
|
||||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
||||||
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
|
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
|
||||||
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
|
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
|
||||||
| Заполнять «Необходимое ПО» и «Доступы» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
|
| Заполнять «Необходимое ПО», «Доступы» и «Мебель» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
|
||||||
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
||||||
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
|
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
|
||||||
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
|
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
|
||||||
|
|||||||
Generated
+4
-4
@@ -17,7 +17,7 @@
|
|||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.14.0",
|
"@tnsu/ui-kit": "^0.15.0",
|
||||||
"jszip": "~3.8.0",
|
"jszip": "~3.8.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
@@ -9886,9 +9886,9 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/@tnsu/ui-kit": {
|
"node_modules/@tnsu/ui-kit": {
|
||||||
"version": "0.14.0",
|
"version": "0.15.0",
|
||||||
"resolved": "http://git.tnsu.kz/api/packages/Tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
|
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.15.0/ui-kit-0.15.0.tgz",
|
||||||
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
|
"integrity": "sha512-vs+7NPem7PDaYj00nwaFztkw2K/nOkw6m2bvqgMABZG5rWjLVqfyFyiopKFkPYgqPWcMPwmQuxoFnc9xi7rwjQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@fluentui/react": "^8.106.4",
|
"@fluentui/react": "^8.106.4",
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.14.0",
|
"@tnsu/ui-kit": "^0.15.0",
|
||||||
"jszip": "~3.8.0",
|
"jszip": "~3.8.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './BusinessTripView.module.scss';
|
import styles from './BusinessTripView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
|
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, buildPrintTitle } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
@@ -55,6 +55,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -128,7 +130,9 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintButton />
|
<PrintButton
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
@@ -156,6 +160,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
|||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
|
printable={false}
|
||||||
search={searchUsers}
|
search={searchUsers}
|
||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface ILoadedRequest {
|
|||||||
status: string;
|
status: string;
|
||||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
code: string;
|
code: string;
|
||||||
|
/** ФИО командируемого (Employee/Title) — для имени печатного документа. */
|
||||||
|
employeeName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,11 +39,12 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
|||||||
'TripObligationAccepted',
|
'TripObligationAccepted',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode',
|
'RequestUniqueCode',
|
||||||
'Employee/EMail'
|
'Employee/EMail',
|
||||||
|
'Employee/Title'
|
||||||
)
|
)
|
||||||
.expand('Employee')()) as Record<string, unknown>;
|
.expand('Employee')()) as Record<string, unknown>;
|
||||||
|
|
||||||
const employee = item.Employee as { EMail?: string } | undefined;
|
const employee = item.Employee as { EMail?: string; Title?: string } | undefined;
|
||||||
|
|
||||||
const data: IFormData = {
|
const data: IFormData = {
|
||||||
...EMPTY_DATA,
|
...EMPTY_DATA,
|
||||||
@@ -61,6 +64,7 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
|||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
status: (item.RequestStatus as string) || '',
|
status: (item.RequestStatus as string) || '',
|
||||||
code: (item.RequestUniqueCode as string) || ''
|
code: (item.RequestUniqueCode as string) || '',
|
||||||
|
employeeName: employee?.Title || ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './ClearanceView.module.scss';
|
import styles from './ClearanceView.module.scss';
|
||||||
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
buildPrintTitle
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -48,6 +57,8 @@ interface IClearanceViewProps {
|
|||||||
|
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Обходной лист';
|
||||||
|
|
||||||
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
|
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
|
||||||
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
||||||
// отдел подбора — парковочная карта (см. approverRights.ts).
|
// отдел подбора — парковочная карта (см. approverRights.ts).
|
||||||
@@ -60,6 +71,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
// ФИО увольняемого — для имени печатного документа.
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
|
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
|
||||||
@@ -123,6 +136,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -274,7 +288,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditAnything && (
|
{canEditAnything && (
|
||||||
@@ -295,7 +314,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Обходной лист"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
@@ -315,6 +334,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
|
printable={false}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './ConcreteRequestView.module.scss';
|
import styles from './ConcreteRequestView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
buildPrintTitle
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -42,6 +50,8 @@ interface IConcreteRequestViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявка на бетон';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
const NOOP_SET: SetField = () => undefined;
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
@@ -137,13 +147,18 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.SiteChiefName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на бетон"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -46,9 +40,12 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, REQUIRED_FIELDS_MESSAGE, rout
|
|||||||
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 { useInitiatorAutofill } from './useInitiatorAutofill';
|
||||||
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 { prepareFilesFor } from './byType/attachmentSlots';
|
||||||
|
import { changeTypeSubmitError } from './byType/submitChecks';
|
||||||
|
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';
|
||||||
@@ -113,33 +110,21 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
|
|||||||
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
|
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
|
||||||
set('Employee', record)
|
set('Employee', record)
|
||||||
);
|
);
|
||||||
|
// Пропсы шапки: сотрудник + инициатор, который следует за сотрудником, пока его не выбрали руками.
|
||||||
|
const headerProps = useInitiatorAutofill(!!editId, data, set, applicantEmail, employeeControl);
|
||||||
|
|
||||||
// Маршрут — по сотруднику, а без учётной записи — по заявителю (см. routeEmail).
|
// Маршрут — по сотруднику, без учётной записи — по заявителю (см. routeEmail); при
|
||||||
// У заявки на редактировании маршрут читается из уже записанных строк.
|
// переводе в другое подразделение принимающий руководитель — первым шагом.
|
||||||
const routeUserEmail = routeEmail(employeeControl.person, applicantEmail);
|
const { matrix, executors } = useContractRoute({
|
||||||
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,
|
|
||||||
context,
|
context,
|
||||||
approval,
|
approval,
|
||||||
requestTypeId,
|
requestTypeId,
|
||||||
userEmail: routeUserEmail,
|
routeUserEmail: routeEmail(employeeControl.person, applicantEmail),
|
||||||
editId,
|
editId,
|
||||||
requestStatus
|
requestStatus,
|
||||||
|
readOnly,
|
||||||
|
receivingHeadEmail:
|
||||||
|
!readOnly && data.ChangeTypes.includes('department-transfer') ? data.DepartmentTransferReceivingHead : ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
|
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
|
||||||
@@ -150,17 +135,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 +147,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
|
||||||
@@ -207,9 +181,9 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
|
|||||||
const validateSubmit = (): string | undefined => {
|
const validateSubmit = (): string | undefined => {
|
||||||
const formError = validate();
|
const formError = validate();
|
||||||
if (formError) return formError;
|
if (formError) return formError;
|
||||||
// Обязательные документы — только на отправку: черновик можно сохранить и без файла.
|
// Обязательное по виду изменения (документы, принимающий руководитель и т.п.) — только на отправку.
|
||||||
const missingFiles = missingRequiredAttachments(data, attachments);
|
const typeError = changeTypeSubmitError(data, attachments);
|
||||||
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
|
if (typeError) return typeError;
|
||||||
if (matrix.error) {
|
if (matrix.error) {
|
||||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||||
}
|
}
|
||||||
@@ -279,7 +253,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
|
|||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<RequestSection data={data} set={set} context={context} employeeControl={employeeControl} />
|
<RequestSection data={data} set={set} context={context} {...headerProps} />
|
||||||
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
|
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
`pageUrl` («hr-contract-change») через `useRequestTypeId` (см. «Формы больше не хардкодят
|
`pageUrl` («hr-contract-change») через `useRequestTypeId` (см. «Формы больше не хардкодят
|
||||||
RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке approval-route ещё не заведены.
|
RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке approval-route ещё не заведены.
|
||||||
|
|
||||||
> **Статус: каркас.** Есть сотрудник и вид изменения; поля под конкретные виды
|
> **Статус (2026-10-29):** поля разобраны по всем видам, кроме 6 «Продление срока ТД»
|
||||||
> добавляются по мере разбора ТЗ кадров. Таблица ТЗ (пример, кто инициирует, что
|
> (это автоматический запрос, не обычная заявка — отдельно). ТЗ кадров — файл
|
||||||
> приложить, сроки, маршрут А/Б/В/Г…) — общая, в форму из неё попадает только то,
|
> «ТЗ_ст33_маршруты_v2» (листы «Виды изменений ст.33» и «Маршрут»); оно общее, в форму
|
||||||
> что явно согласовано. Печатного бланка пока нет.
|
> из него попадает только то, что явно согласовано. Ещё не сделано: передача вида и
|
||||||
|
> инициатора в маршрут (`fieldValues`: фин. блок — виды 1, 2, 3, 5, 9; маршрут А/Б —
|
||||||
|
> по инициатору), печатный бланк, заведение типа и маршрутов в админке.
|
||||||
|
|
||||||
## Маршрут
|
## Маршрут
|
||||||
- Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает).
|
- Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает).
|
||||||
@@ -60,7 +62,43 @@ 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, обязателен при отправке) — см. ниже |
|
||||||
|
| Изменение места работы (другая местность) | `WorkplaceChange` | место работы «было → стало» парой **организация + проект**: текущая организация — из «Организации» сотрудника (только чтение, отдельно не хранится — это `EmployeeCompany`), текущий проект (`ProjectPicker`); новая организация (`Dictionary/Companies`, **обязательна при отправке**, не подставляется) и новый проект; адрес нового места, дата, причина (новый объект / переезд подразделения или работодателя / иное), условия переезда и компенсации; переключатель **«Вахта»** → «График работы» (тот же набор, что в подборе и приёме, «Другое» → ручной ввод; без вахты график очищается); обоснование |
|
||||||
|
| Продление срока ТД | — | **пропущен** (2026-10-29): по ТЗ это автоматический запрос ОК за месяц до окончания срочного ТД (маршрут В), а не обычная заявка — решать отдельно |
|
||||||
|
| Изменение режима / графика работы | `ScheduleChange` | по ролям строго по ТЗ. Общее: текущий график + текущее время работы (списки как в подборе/приёме, «Другое» → текст; `OptionWithOtherField`, наборы — `byType/workSchedule.ts`), дата начала, вложение «Новый график» (необязательно). **Работник:** неполный рабочий день (часов) / неполная рабочая неделя (дней) — одно или оба, текст заявления. **Работодатель:** новый режим (вахтовый / сменный / иной + описание), новые график и время работы, причина (специфика объекта / смещение сроков проекта — только из ТЗ), обоснование. Поля другой роли при сохранении очищаются |
|
||||||
|
| Изменение трудовой функции (без смены должности) | `FunctionChange` | ровно по шаблону ТЗ: дата изменения, «В связи с» (свободный текст — списка причин в ТЗ нет), вложение **«Новая должностная инструкция»** — обязательно. Инициатор — только руководитель |
|
||||||
|
| Изменение условий труда по инициативе работодателя | `EmployerInitiative` | причина (из названия вида в ТЗ: экономические / технологические / изменения в организации / сокращение объёма работ + «Другое» со своим текстом); «Что меняется» — флажки а) график работы, б) должностной оклад (можно оба, при отправке — хотя бы один); а) текущий → новый график + дата; б) **один новый оклад + доля постоянной части в %** (по умолчанию 75 из ТЗ, 0–100, можно дробную), переменная доля = остаток до 100 %, суммы обеих частей в ₸ считаются сами (`salaryParts.ts`, превысить 100 % нельзя; в SP пишутся при сохранении) + дата; даты **не раньше +15 дней** (уведомление по ТЗ: блок в календаре + проверка при отправке); обоснование — **текстом ИЛИ документом**, одно из двух обязательно при отправке |
|
||||||
|
| Временный перевод при производственной необходимости / простое | `TemporaryTransfer` | откуда и куда — проект из справочника + работа текстом; дата начала / окончания — **не дольше 3 месяцев** (по ТЗ «до 3 мес. в календарном году»; включительно: с 01.01 по 31.03, `period.ts`; сумму за год проверяет HR-директор); основание (производственная необходимость / простой), обоснование; пометка из шаблона ТЗ «Оплата — не ниже средней заработной платы по прежней работе». Инициатор — только руководитель |
|
||||||
|
|
||||||
|
Обязательное по виду при отправке (инициатор, документы, принимающий руководитель,
|
||||||
|
новая организация) — одна функция `byType/submitChecks.ts`; черновик сохраняется без них.
|
||||||
|
|
||||||
|
**Инициатор (работник / работодатель)** — решение 2026-10-29: разделяем строго по
|
||||||
|
ТЗ, чтобы у кадров не было вопросов. Поле `ChangeInitiator` в шапке — только у
|
||||||
|
видов, которые по ТЗ может начать и работник (3, 4, 5, 7; `hasInitiatorChoice`).
|
||||||
|
По умолчанию при выборе вида: подаёт на себя — «Работник», за другого —
|
||||||
|
«Работодатель» (`defaultInitiatorRole`), под полем подсказка об этом; можно
|
||||||
|
поменять. Пока инициатора не выбирали руками, он следует за сотрудником: сменили
|
||||||
|
сотрудника — пересчитался (`useInitiatorAutofill`); после ручного выбора и у
|
||||||
|
открытого черновика — не трогается. От роли (`byType/InitiatorReasonFields`): работник — только «Текст
|
||||||
|
заявления» (по ТЗ «От работника: заявление (текст)»), работодатель — причина из
|
||||||
|
списка + «Обоснование». Текст пишется в ту же колонку `*Justification`; причина у
|
||||||
|
работника при сохранении очищается. Остальные поля вида — у обеих ролей.
|
||||||
|
|
||||||
|
**Принимающий руководитель — временное решение (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
-5
@@ -1,8 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui';
|
import { FormGrid, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui';
|
||||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||||
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
|
|
||||||
import { ISectionProps } from '../../model';
|
import { ISectionProps } from '../../model';
|
||||||
import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options';
|
import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options';
|
||||||
import { UnitSwitch } from './UnitSwitch';
|
import { UnitSwitch } from './UnitSwitch';
|
||||||
@@ -15,13 +14,13 @@ import styles from './AllowanceSection.module.scss';
|
|||||||
* (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение
|
* (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение
|
||||||
* «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ).
|
* «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ).
|
||||||
*
|
*
|
||||||
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
|
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||||
*/
|
*/
|
||||||
export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
||||||
const readOnly = useFormReadOnly();
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title={CHANGE_TYPE_LABELS.allowance} icon="calculator">
|
<FormGrid>
|
||||||
<FormField label="Основание" htmlFor="AllowanceReason" full>
|
<FormField label="Основание" htmlFor="AllowanceReason" full>
|
||||||
<Select
|
<Select
|
||||||
id="AllowanceReason"
|
id="AllowanceReason"
|
||||||
@@ -99,6 +98,6 @@ export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachmen
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CollapseSection>
|
</FormGrid>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Поля вида идут сразу под шапкой (сотрудник · вид · инициатор) отдельной сеткой
|
||||||
|
// FormGrid. У сетки ui-kit нет внешнего отступа, а расстояние между строками полей —
|
||||||
|
// 18px (row-gap FormGrid): тот же отступ, чтобы стык двух сеток был не виден.
|
||||||
|
.typeFields {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
@@ -4,6 +4,13 @@ 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';
|
||||||
|
import { WorkplaceChangeSection } from './WorkplaceChange/WorkplaceChangeSection';
|
||||||
|
import { ScheduleChangeSection } from './ScheduleChange/ScheduleChangeSection';
|
||||||
|
import { FunctionChangeSection } from './FunctionChange/FunctionChangeSection';
|
||||||
|
import { EmployerInitiativeSection } from './EmployerInitiative/EmployerInitiativeSection';
|
||||||
|
import { TemporaryTransferSection } from './TemporaryTransfer/TemporaryTransferSection';
|
||||||
|
import styles from './ChangeTypeSections.module.scss';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
|
* Вид изменения → секция с его полями. Partial: поля видов добавляются по мере
|
||||||
@@ -13,15 +20,28 @@ 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,
|
||||||
|
'workplace-change': WorkplaceChangeSection,
|
||||||
|
'schedule-change': ScheduleChangeSection,
|
||||||
|
'function-change': FunctionChangeSection,
|
||||||
|
'employer-initiative': EmployerInitiativeSection,
|
||||||
|
'temporary-transfer': TemporaryTransferSection
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES). */
|
/**
|
||||||
|
* Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES).
|
||||||
|
* Заголовка у секции нет — вид выбран прямо над ней; обёртка даёт только отступ от шапки.
|
||||||
|
*/
|
||||||
export const ChangeTypeSections: React.FC<ISectionProps> = (props) => (
|
export const ChangeTypeSections: React.FC<ISectionProps> = (props) => (
|
||||||
<>
|
<>
|
||||||
{props.data.ChangeTypes.map((type) => {
|
{props.data.ChangeTypes.map((type) => {
|
||||||
const Section = SECTION_BY_TYPE[type];
|
const Section = SECTION_BY_TYPE[type];
|
||||||
return Section ? <Section key={type} {...props} /> : undefined;
|
return Section ? (
|
||||||
|
<div key={type} className={styles.typeFields}>
|
||||||
|
<Section {...props} />
|
||||||
|
</div>
|
||||||
|
) : undefined;
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormGrid, FormField, TextInput, PeopleField } from '../../../../shared/ui';
|
||||||
|
import { InitiatorReasonFields } from '../InitiatorReasonFields';
|
||||||
|
import { DepartmentPicker } from '../../../../shared/dictionary';
|
||||||
|
import { ISectionProps } from '../../model';
|
||||||
|
import { DEPARTMENT_TRANSFER_REASONS } from './options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля вида «Перевод в другое подразделение». Состав согласован с Тимуром по ТЗ
|
||||||
|
* кадров: новое подразделение (справочник), дата, причина (а–г), обоснование и
|
||||||
|
* принимающий руководитель. Его согласие по ТЗ — шаг маршрута: форма сама ставит
|
||||||
|
* его первым шагом согласования (см. receivingHead.ts). Обязателен при отправке.
|
||||||
|
*
|
||||||
|
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||||
|
*/
|
||||||
|
export const DepartmentTransferSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||||
|
<FormGrid>
|
||||||
|
<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>
|
||||||
|
<InitiatorReasonFields
|
||||||
|
initiator={data.ChangeInitiator}
|
||||||
|
reason={{
|
||||||
|
id: 'DepartmentTransferReason',
|
||||||
|
value: data.DepartmentTransferReason,
|
||||||
|
options: DEPARTMENT_TRANSFER_REASONS,
|
||||||
|
onChange: (v) => set('DepartmentTransferReason', v)
|
||||||
|
}}
|
||||||
|
justification={{
|
||||||
|
id: 'DepartmentTransferJustification',
|
||||||
|
value: data.DepartmentTransferJustification,
|
||||||
|
onChange: (v) => set('DepartmentTransferJustification', v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
@import '../../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Флажки «что меняется» — в строку, на узком экране переносятся.
|
||||||
|
.flags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $sp-2 $sp-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пояснение под датой (срок уведомления работника по ТЗ).
|
||||||
|
.hint {
|
||||||
|
margin-top: $sp-1;
|
||||||
|
font-size: $fs-label;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormGrid, FormField, TextInput, TextArea, Select, Checkbox, MoneyInput } from '../../../../shared/ui';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||||
|
import { ISectionProps } from '../../model';
|
||||||
|
import { OptionWithOtherField } from '../OptionWithOtherField';
|
||||||
|
import { WORK_SCHEDULE_OPTIONS } from '../workSchedule';
|
||||||
|
import {
|
||||||
|
EMPLOYER_CHANGE_REASONS,
|
||||||
|
EMPLOYER_REASON_OTHER,
|
||||||
|
EMPLOYER_CHANGE_NOTICE_HINT,
|
||||||
|
EMPLOYER_JUSTIFICATION_ATTACHMENT,
|
||||||
|
minEmployerChangeDate
|
||||||
|
} from './options';
|
||||||
|
import { splitSalary, sanitizeFixedPercent } from './salaryParts';
|
||||||
|
import styles from './EmployerInitiativeSection.module.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля вида «Изменение условий труда по инициативе работодателя» — по шаблону ТЗ:
|
||||||
|
* «в связи с ___ (причина) прошу: а) изменить график работы с ___ на ___ с …;
|
||||||
|
* б) изменить должностной оклад: постоянная часть 75 % — ___, переменная 25 % — ___, с …».
|
||||||
|
* Что меняется — флажки а/б (можно оба); оклад вводится один, доля постоянной части
|
||||||
|
* задаётся (по умолчанию 75 %), переменная — остаток до 100 %, суммы считаются сами. Даты — не раньше чем через 15 дней (уведомление работника по ТЗ).
|
||||||
|
* Обоснование — текстом или документом (одно из двух обязательно при отправке).
|
||||||
|
*
|
||||||
|
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||||
|
*/
|
||||||
|
export const EmployerInitiativeSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
const minDate = minEmployerChangeDate();
|
||||||
|
const parts = splitSalary(data.EmployerChangeNewSalary, data.EmployerChangeFixedPercent);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Причина" htmlFor="EmployerChangeReason" full>
|
||||||
|
<Select
|
||||||
|
id="EmployerChangeReason"
|
||||||
|
value={data.EmployerChangeReason}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('EmployerChangeReason', v);
|
||||||
|
if (v !== EMPLOYER_REASON_OTHER) set('EmployerChangeReasonOther', '');
|
||||||
|
}}
|
||||||
|
options={EMPLOYER_CHANGE_REASONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{data.EmployerChangeReason === EMPLOYER_REASON_OTHER && (
|
||||||
|
<FormField label="Причина (другое)" htmlFor="EmployerChangeReasonOther" full>
|
||||||
|
<TextInput
|
||||||
|
id="EmployerChangeReasonOther"
|
||||||
|
value={data.EmployerChangeReasonOther}
|
||||||
|
onChange={(v) => set('EmployerChangeReasonOther', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField label="Что меняется" htmlFor="EmployerChangeSchedule" full>
|
||||||
|
<div className={styles.flags}>
|
||||||
|
<Checkbox
|
||||||
|
checked={data.EmployerChangeSchedule}
|
||||||
|
onChange={(checked) => set('EmployerChangeSchedule', checked)}
|
||||||
|
label="График работы"
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
checked={data.EmployerChangeSalary}
|
||||||
|
onChange={(checked) => set('EmployerChangeSalary', checked)}
|
||||||
|
label="Должностной оклад"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{data.EmployerChangeSchedule && (
|
||||||
|
<>
|
||||||
|
<OptionWithOtherField
|
||||||
|
label="Текущий график работы"
|
||||||
|
id="EmployerChangeCurrentSchedule"
|
||||||
|
value={data.EmployerChangeCurrentSchedule}
|
||||||
|
otherValue={data.EmployerChangeCurrentScheduleOther}
|
||||||
|
options={WORK_SCHEDULE_OPTIONS}
|
||||||
|
onChange={(v) => set('EmployerChangeCurrentSchedule', v)}
|
||||||
|
onOtherChange={(v) => set('EmployerChangeCurrentScheduleOther', v)}
|
||||||
|
/>
|
||||||
|
<OptionWithOtherField
|
||||||
|
label="Новый график работы"
|
||||||
|
id="EmployerChangeNewSchedule"
|
||||||
|
value={data.EmployerChangeNewSchedule}
|
||||||
|
otherValue={data.EmployerChangeNewScheduleOther}
|
||||||
|
options={WORK_SCHEDULE_OPTIONS}
|
||||||
|
onChange={(v) => set('EmployerChangeNewSchedule', v)}
|
||||||
|
onOtherChange={(v) => set('EmployerChangeNewScheduleOther', v)}
|
||||||
|
/>
|
||||||
|
<FormField label="Дата изменения графика" htmlFor="EmployerChangeScheduleDate" full>
|
||||||
|
<TextInput
|
||||||
|
id="EmployerChangeScheduleDate"
|
||||||
|
type="date"
|
||||||
|
value={data.EmployerChangeScheduleDate}
|
||||||
|
onChange={(v) => set('EmployerChangeScheduleDate', v)}
|
||||||
|
min={minDate}
|
||||||
|
/>
|
||||||
|
{!readOnly && <div className={styles.hint}>{EMPLOYER_CHANGE_NOTICE_HINT}</div>}
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.EmployerChangeSalary && (
|
||||||
|
<>
|
||||||
|
<FormField label="Новый должностной оклад, ₸" htmlFor="EmployerChangeNewSalary" full>
|
||||||
|
<MoneyInput
|
||||||
|
id="EmployerChangeNewSalary"
|
||||||
|
value={data.EmployerChangeNewSalary}
|
||||||
|
onChange={(v) => set('EmployerChangeNewSalary', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{/* Задаётся только доля постоянной части; переменная — остаток до 100 %,
|
||||||
|
суммы считаются из оклада (см. salaryParts.ts) — превысить 100 % нельзя. */}
|
||||||
|
<FormField label="Постоянная (повременная) часть, %" htmlFor="EmployerChangeFixedPercent">
|
||||||
|
<TextInput
|
||||||
|
id="EmployerChangeFixedPercent"
|
||||||
|
value={data.EmployerChangeFixedPercent}
|
||||||
|
onChange={(v) => set('EmployerChangeFixedPercent', sanitizeFixedPercent(v))}
|
||||||
|
placeholder="например, 75"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Переменная часть, %" htmlFor="EmployerChangeVariablePercent">
|
||||||
|
<TextInput
|
||||||
|
id="EmployerChangeVariablePercent"
|
||||||
|
value={parts ? String(parts.variablePercent) : ''}
|
||||||
|
onChange={() => undefined}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Постоянная часть, ₸" htmlFor="EmployerChangeFixedPart">
|
||||||
|
<MoneyInput
|
||||||
|
id="EmployerChangeFixedPart"
|
||||||
|
value={parts ? String(parts.fixed) : ''}
|
||||||
|
onChange={() => undefined}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Переменная часть, ₸" htmlFor="EmployerChangeVariablePart">
|
||||||
|
<MoneyInput
|
||||||
|
id="EmployerChangeVariablePart"
|
||||||
|
value={parts ? String(parts.variable) : ''}
|
||||||
|
onChange={() => undefined}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Дата изменения оклада" htmlFor="EmployerChangeSalaryDate" full>
|
||||||
|
<TextInput
|
||||||
|
id="EmployerChangeSalaryDate"
|
||||||
|
type="date"
|
||||||
|
value={data.EmployerChangeSalaryDate}
|
||||||
|
onChange={(v) => set('EmployerChangeSalaryDate', v)}
|
||||||
|
min={minDate}
|
||||||
|
/>
|
||||||
|
{!readOnly && <div className={styles.hint}>{EMPLOYER_CHANGE_NOTICE_HINT}</div>}
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField label="Обоснование" htmlFor="EmployerChangeJustification" full>
|
||||||
|
<TextArea
|
||||||
|
id="EmployerChangeJustification"
|
||||||
|
value={data.EmployerChangeJustification}
|
||||||
|
onChange={(v) => set('EmployerChangeJustification', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{attachments && (
|
||||||
|
<AttachmentSlotField
|
||||||
|
label={`${EMPLOYER_JUSTIFICATION_ATTACHMENT} (приказ о реорганизации, сокращении объёма и т. п.)`}
|
||||||
|
editor={attachments}
|
||||||
|
editable={!readOnly}
|
||||||
|
groupKey={EMPLOYER_JUSTIFICATION_ATTACHMENT}
|
||||||
|
category={EMPLOYER_JUSTIFICATION_ATTACHMENT}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ISelectOption } from '../../../../shared/ui';
|
||||||
|
import { isoDatePlusDays } from '../../../../shared/dateUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вид «Изменение условий труда по инициативе работодателя». Причины — из названия
|
||||||
|
* вида в ТЗ (экономические, технологические, изменения в организации, сокращение
|
||||||
|
* объёма работ) + «Другое» со своим текстом (решение Тимура 2026-10-29).
|
||||||
|
* Значения = choices колонки EmployerChangeReason.
|
||||||
|
*/
|
||||||
|
export const EMPLOYER_REASON_OTHER = 'Другое';
|
||||||
|
export const EMPLOYER_CHANGE_REASONS: ISelectOption[] = [
|
||||||
|
'Экономические причины',
|
||||||
|
'Технологические причины',
|
||||||
|
'Изменения в организации',
|
||||||
|
'Сокращение объёма работ',
|
||||||
|
EMPLOYER_REASON_OTHER
|
||||||
|
].map((reason) => ({ value: reason, label: reason }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* По ТЗ работника уведомляют не позднее чем за столько календарных дней до даты
|
||||||
|
* изменений — поэтому раньше дату не даём выбрать (как срок подачи в отпуске).
|
||||||
|
*/
|
||||||
|
export const EMPLOYER_CHANGE_NOTICE_DAYS = 15;
|
||||||
|
export const EMPLOYER_CHANGE_NOTICE_HINT = `Не раньше чем через ${EMPLOYER_CHANGE_NOTICE_DAYS} календарных дней: работника уведомляют за ${EMPLOYER_CHANGE_NOTICE_DAYS} дней до изменений.`;
|
||||||
|
|
||||||
|
/** Самая ранняя допустимая дата изменений, YYYY-MM-DD. */
|
||||||
|
export function minEmployerChangeDate(): string {
|
||||||
|
return isoDatePlusDays(EMPLOYER_CHANGE_NOTICE_DAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вложение-обоснование (приказ о реорганизации, сокращении объёма и т.п.). По ТЗ
|
||||||
|
* «ТЕКСТ ИЛИ ПРИЛОЖИТЬ ДОКУМЕНТ» — сам по себе необязателен, но при отправке
|
||||||
|
* нужен текст обоснования или этот файл (см. submitChecks).
|
||||||
|
*/
|
||||||
|
export const EMPLOYER_JUSTIFICATION_ATTACHMENT = 'Документ-обоснование';
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
import { splitSalary, sanitizeFixedPercent } from './salaryParts';
|
||||||
|
|
||||||
|
describe('splitSalary', () => {
|
||||||
|
it('по умолчанию из ТЗ — 75 / 25', () => {
|
||||||
|
expect(splitSalary('400000', '75')).toEqual({ fixedPercent: 75, variablePercent: 25, fixed: 300000, variable: 100000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('своя доля: переменная часть — остаток до 100 %', () => {
|
||||||
|
expect(splitSalary('400000', '70')).toEqual({ fixedPercent: 70, variablePercent: 30, fixed: 280000, variable: 120000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('при округлении сумма частей всё равно равна окладу', () => {
|
||||||
|
const parts = splitSalary('333333', '75');
|
||||||
|
expect(parts?.fixed).toBe(250000);
|
||||||
|
expect((parts?.fixed ?? 0) + (parts?.variable ?? 0)).toBe(333333);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('дробная доля', () => {
|
||||||
|
expect(splitSalary('200000', '62.5')).toEqual({
|
||||||
|
fixedPercent: 62.5,
|
||||||
|
variablePercent: 37.5,
|
||||||
|
fixed: 125000,
|
||||||
|
variable: 75000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет оклада или доли — частей нет', () => {
|
||||||
|
expect(splitSalary('', '75')).toBeUndefined();
|
||||||
|
expect(splitSalary('0', '75')).toBeUndefined();
|
||||||
|
expect(splitSalary('400000', '')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeFixedPercent', () => {
|
||||||
|
it('больше 100 не даёт', () => {
|
||||||
|
expect(sanitizeFixedPercent('150')).toBe('100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пропускает обычные и дробные доли', () => {
|
||||||
|
expect(sanitizeFixedPercent('75')).toBe('75');
|
||||||
|
expect(sanitizeFixedPercent('62,5')).toBe('62.5');
|
||||||
|
});
|
||||||
|
});
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { sanitizePercentInput } from '../Allowance/amount';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Новый оклад делится на постоянную (повременную) и переменную части. В шаблоне ТЗ
|
||||||
|
* — 75 % / 25 %; по решению Тимура (2026-10-29) долю постоянной части можно задать
|
||||||
|
* самому, а переменная — всегда остаток до 100 %. Так сумма частей ровно 100 % и
|
||||||
|
* превысить её нельзя в принципе.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_FIXED_PERCENT = '75';
|
||||||
|
const MAX_PERCENT = 100;
|
||||||
|
|
||||||
|
export interface ISalaryParts {
|
||||||
|
/** Доли в процентах; variablePercent = 100 − fixedPercent. */
|
||||||
|
fixedPercent: number;
|
||||||
|
variablePercent: number;
|
||||||
|
/** Суммы в тенге; variable — остаток, чтобы fixed + variable = оклад. */
|
||||||
|
fixed: number;
|
||||||
|
variable: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ввод доли постоянной части → «сырое» число строкой: только цифры и один
|
||||||
|
* разделитель (как ввод процента надбавки), не больше 100.
|
||||||
|
*/
|
||||||
|
export function sanitizeFixedPercent(text: string): string {
|
||||||
|
const percent = sanitizePercentInput(text);
|
||||||
|
return Number(percent) > MAX_PERCENT ? String(MAX_PERCENT) : percent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Доля постоянной части числом; пусто/некорректно/вне 0–100 — undefined. */
|
||||||
|
function parsePercent(raw: string): number | undefined {
|
||||||
|
const percent = Number(raw);
|
||||||
|
if (raw.trim() === '' || !Number.isFinite(percent) || percent < 0 || percent > MAX_PERCENT) return undefined;
|
||||||
|
return percent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оклад и доля постоянной части → проценты и суммы обеих частей. Постоянная сумма
|
||||||
|
* округляется до тенге, переменная — остаток (без расхождений от округления).
|
||||||
|
* Нет оклада или доли — undefined.
|
||||||
|
*/
|
||||||
|
export function splitSalary(salaryRaw: string, fixedPercentRaw: string): ISalaryParts | undefined {
|
||||||
|
const salary = Number(salaryRaw);
|
||||||
|
const fixedPercent = parsePercent(fixedPercentRaw);
|
||||||
|
if (salaryRaw.trim() === '' || !Number.isFinite(salary) || salary <= 0 || fixedPercent === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const fixed = Math.round((salary * fixedPercent) / MAX_PERCENT);
|
||||||
|
return { fixedPercent, variablePercent: MAX_PERCENT - fixedPercent, fixed, variable: salary - fixed };
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormGrid, FormField, TextInput, TextArea } from '../../../../shared/ui';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||||
|
import { ISectionProps } from '../../model';
|
||||||
|
import { NEW_JOB_DESCRIPTION_ATTACHMENT } from './options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля вида «Изменение трудовой функции (без смены должности)». Ровно по шаблону
|
||||||
|
* ТЗ: «прошу внести изменения в трудовую функцию работника согласно ДИ во
|
||||||
|
* вложении с __.__.20__ в связи с ___ (текст)» — дата, «в связи с» (свободный
|
||||||
|
* текст, списка причин в ТЗ нет) и обязательная новая ДИ. Инициатор — только
|
||||||
|
* руководитель.
|
||||||
|
*
|
||||||
|
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||||
|
*/
|
||||||
|
export const FunctionChangeSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Дата изменения" htmlFor="FunctionChangeDate">
|
||||||
|
<TextInput
|
||||||
|
id="FunctionChangeDate"
|
||||||
|
type="date"
|
||||||
|
value={data.FunctionChangeDate}
|
||||||
|
onChange={(v) => set('FunctionChangeDate', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="В связи с" htmlFor="FunctionChangeReason" full>
|
||||||
|
<TextArea
|
||||||
|
id="FunctionChangeReason"
|
||||||
|
value={data.FunctionChangeReason}
|
||||||
|
onChange={(v) => set('FunctionChangeReason', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{attachments && (
|
||||||
|
<AttachmentSlotField
|
||||||
|
label={NEW_JOB_DESCRIPTION_ATTACHMENT}
|
||||||
|
editor={attachments}
|
||||||
|
editable={!readOnly}
|
||||||
|
groupKey={NEW_JOB_DESCRIPTION_ATTACHMENT}
|
||||||
|
category={NEW_JOB_DESCRIPTION_ATTACHMENT}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Вложение «Новая должностная инструкция» — по ТЗ «приложить документ», обязательно.
|
||||||
|
* Отдельная подпись от «Должностной инструкции» перевода на должность: это разные слоты.
|
||||||
|
*/
|
||||||
|
export const NEW_JOB_DESCRIPTION_ATTACHMENT = 'Новая должностная инструкция';
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormField, Select, TextArea, ISelectOption } from '../../../shared/ui';
|
||||||
|
import { InitiatorRole, INITIATOR_EMPLOYEE } from '../changeTypes';
|
||||||
|
import { EMPLOYEE_STATEMENT_LABEL } from '../model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initiator: InitiatorRole | '';
|
||||||
|
/** Причина из списка — только у работодателя. */
|
||||||
|
reason: { id: string; value: string; options: ISelectOption[]; onChange: (value: string) => void };
|
||||||
|
/** Обоснование работодателя / текст заявления работника — одна и та же колонка вида. */
|
||||||
|
justification: { id: string; value: string; onChange: (value: string) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Окончание секции вида, где по ТЗ шаблон зависит от инициатора:
|
||||||
|
* - работник — «заявление (текст)»: только «Текст заявления», без списка причин;
|
||||||
|
* - работодатель (и пока инициатор не выбран) — «в связи с: а) б) в)»: причина + обоснование.
|
||||||
|
* Поля кладутся прямо в сетку FormGrid секции (без обёртки) — как и остальные поля вида.
|
||||||
|
*/
|
||||||
|
export const InitiatorReasonFields: React.FC<Props> = ({ initiator, reason, justification }) => {
|
||||||
|
const isEmployee = initiator === INITIATOR_EMPLOYEE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isEmployee && (
|
||||||
|
<FormField label="Причина" htmlFor={reason.id} full>
|
||||||
|
<Select
|
||||||
|
id={reason.id}
|
||||||
|
value={reason.value}
|
||||||
|
onChange={reason.onChange}
|
||||||
|
options={reason.options}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<FormField label={isEmployee ? EMPLOYEE_STATEMENT_LABEL : 'Обоснование'} htmlFor={justification.id} full>
|
||||||
|
<TextArea id={justification.id} value={justification.value} onChange={justification.onChange} />
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormField, Select, TextInput, ISelectOption } from '../../../shared/ui';
|
||||||
|
import { OPTION_OTHER } from './workSchedule';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
/** Текст при «Другое». */
|
||||||
|
otherValue: string;
|
||||||
|
options: ISelectOption[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onOtherChange: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбор из списка + ручной ввод при «Другое» (как график/время работы в подборе
|
||||||
|
* и приёме). Выбрали не «Другое» — ручной текст очищается, чтобы не сохранить
|
||||||
|
* скрытое значение. Поля кладутся прямо в сетку FormGrid секции.
|
||||||
|
*/
|
||||||
|
export const OptionWithOtherField: React.FC<Props> = ({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
otherValue,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
onOtherChange
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<FormField label={label} htmlFor={id}>
|
||||||
|
<Select
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
onChange(v);
|
||||||
|
if (v !== OPTION_OTHER) onOtherChange('');
|
||||||
|
}}
|
||||||
|
options={options}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{value === OPTION_OTHER && (
|
||||||
|
<FormField label={`${label} (другое)`} htmlFor={`${id}Other`}>
|
||||||
|
<TextInput id={`${id}Other`} value={otherValue} onChange={onOtherChange} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
+18
-20
@@ -1,9 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea, Select } from '../../../../shared/ui';
|
import { FormGrid, FormField, TextInput } from '../../../../shared/ui';
|
||||||
|
import { InitiatorReasonFields } from '../InitiatorReasonFields';
|
||||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
import { PositionPicker } from '../../../../shared/dictionary';
|
import { PositionPicker } from '../../../../shared/dictionary';
|
||||||
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||||
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
|
|
||||||
import { ISectionProps } from '../../model';
|
import { ISectionProps } from '../../model';
|
||||||
import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options';
|
import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options';
|
||||||
|
|
||||||
@@ -13,13 +13,13 @@ import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options
|
|||||||
* причина (а–г), обоснование и обязательная должностная инструкция. Текущая
|
* причина (а–г), обоснование и обязательная должностная инструкция. Текущая
|
||||||
* должность уже видна в «Организации» — отдельного «было» нет.
|
* должность уже видна в «Организации» — отдельного «было» нет.
|
||||||
*
|
*
|
||||||
* Поля — прямо в CollapseSection, без FormGrid (тело секции само сетка).
|
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||||
*/
|
*/
|
||||||
export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, context, attachments }) => {
|
export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, context, attachments }) => {
|
||||||
const readOnly = useFormReadOnly();
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title={CHANGE_TYPE_LABELS['position-transfer']} icon="people">
|
<FormGrid>
|
||||||
<FormField label="Новая должность" htmlFor="PositionTransferNewPosition">
|
<FormField label="Новая должность" htmlFor="PositionTransferNewPosition">
|
||||||
<PositionPicker
|
<PositionPicker
|
||||||
context={context}
|
context={context}
|
||||||
@@ -37,22 +37,20 @@ export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, co
|
|||||||
onChange={(v) => set('PositionTransferDate', v)}
|
onChange={(v) => set('PositionTransferDate', v)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Причина" htmlFor="PositionTransferReason" full>
|
<InitiatorReasonFields
|
||||||
<Select
|
initiator={data.ChangeInitiator}
|
||||||
id="PositionTransferReason"
|
reason={{
|
||||||
value={data.PositionTransferReason}
|
id: 'PositionTransferReason',
|
||||||
onChange={(v) => set('PositionTransferReason', v)}
|
value: data.PositionTransferReason,
|
||||||
options={POSITION_TRANSFER_REASONS}
|
options: POSITION_TRANSFER_REASONS,
|
||||||
placeholder="— выберите —"
|
onChange: (v) => set('PositionTransferReason', v)
|
||||||
|
}}
|
||||||
|
justification={{
|
||||||
|
id: 'PositionTransferJustification',
|
||||||
|
value: data.PositionTransferJustification,
|
||||||
|
onChange: (v) => set('PositionTransferJustification', v)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
|
||||||
<FormField label="Обоснование" htmlFor="PositionTransferJustification" full>
|
|
||||||
<TextArea
|
|
||||||
id="PositionTransferJustification"
|
|
||||||
value={data.PositionTransferJustification}
|
|
||||||
onChange={(v) => set('PositionTransferJustification', v)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
{attachments && (
|
{attachments && (
|
||||||
<AttachmentSlotField
|
<AttachmentSlotField
|
||||||
label={JOB_DESCRIPTION_ATTACHMENT}
|
label={JOB_DESCRIPTION_ATTACHMENT}
|
||||||
@@ -63,6 +61,6 @@ export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, co
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CollapseSection>
|
</FormGrid>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-6
@@ -1,6 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui';
|
import { FormGrid, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui';
|
||||||
import { CHANGE_TYPE_LABELS } from '../../changeTypes';
|
|
||||||
import { ISectionProps } from '../../model';
|
import { ISectionProps } from '../../model';
|
||||||
import { SALARY_INCREASE_REASONS } from './reasons';
|
import { SALARY_INCREASE_REASONS } from './reasons';
|
||||||
|
|
||||||
@@ -9,11 +8,10 @@ import { SALARY_INCREASE_REASONS } from './reasons';
|
|||||||
* кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные,
|
* кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные,
|
||||||
* без ограничений — по решению от 2026-10-28.
|
* без ограничений — по решению от 2026-10-28.
|
||||||
*
|
*
|
||||||
* Поля — прямо в CollapseSection, без FormGrid: тело секции само является
|
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||||
* двухколоночной сеткой, вложенная FormGrid сжала бы всё в одну её ячейку.
|
|
||||||
*/
|
*/
|
||||||
export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => (
|
export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||||
<CollapseSection title={CHANGE_TYPE_LABELS['salary-increase']} icon="calculator">
|
<FormGrid>
|
||||||
<FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent">
|
<FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent">
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
id="SalaryIncreaseCurrent"
|
id="SalaryIncreaseCurrent"
|
||||||
@@ -52,5 +50,5 @@ export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) =>
|
|||||||
onChange={(v) => set('SalaryIncreaseJustification', v)}
|
onChange={(v) => set('SalaryIncreaseJustification', v)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</CollapseSection>
|
</FormGrid>
|
||||||
);
|
);
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormGrid, FormField, TextInput, Select } from '../../../../shared/ui';
|
||||||
|
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||||
|
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||||
|
import { INITIATOR_EMPLOYEE } from '../../changeTypes';
|
||||||
|
import { ISectionProps } from '../../model';
|
||||||
|
import { InitiatorReasonFields } from '../InitiatorReasonFields';
|
||||||
|
import { OptionWithOtherField } from '../OptionWithOtherField';
|
||||||
|
import { WORK_SCHEDULE_OPTIONS, WORK_HOURS_OPTIONS } from '../workSchedule';
|
||||||
|
import {
|
||||||
|
MODE_OTHER,
|
||||||
|
NEW_SCHEDULE_ATTACHMENT,
|
||||||
|
SCHEDULE_CHANGE_NEW_MODES,
|
||||||
|
SCHEDULE_CHANGE_REASONS
|
||||||
|
} from './options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля вида «Изменение режима / графика работы» — по ролям, строго по шаблонам ТЗ
|
||||||
|
* (см. options.ts). Общее: текущий режим (график + время работы), дата начала,
|
||||||
|
* необязательный документ «Новый график». Работник — неполное рабочее время
|
||||||
|
* (часов в день / дней в неделю) и текст заявления; работодатель — новый режим,
|
||||||
|
* новые график и время работы, причина и обоснование. Все поля необязательные.
|
||||||
|
*
|
||||||
|
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||||
|
*/
|
||||||
|
export const ScheduleChangeSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
||||||
|
const readOnly = useFormReadOnly();
|
||||||
|
const isEmployee = data.ChangeInitiator === INITIATOR_EMPLOYEE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormGrid>
|
||||||
|
<OptionWithOtherField
|
||||||
|
label="Текущий график работы"
|
||||||
|
id="ScheduleChangeCurrentSchedule"
|
||||||
|
value={data.ScheduleChangeCurrentSchedule}
|
||||||
|
otherValue={data.ScheduleChangeCurrentScheduleOther}
|
||||||
|
options={WORK_SCHEDULE_OPTIONS}
|
||||||
|
onChange={(v) => set('ScheduleChangeCurrentSchedule', v)}
|
||||||
|
onOtherChange={(v) => set('ScheduleChangeCurrentScheduleOther', v)}
|
||||||
|
/>
|
||||||
|
<OptionWithOtherField
|
||||||
|
label="Текущее время работы"
|
||||||
|
id="ScheduleChangeCurrentHours"
|
||||||
|
value={data.ScheduleChangeCurrentHours}
|
||||||
|
otherValue={data.ScheduleChangeCurrentHoursOther}
|
||||||
|
options={WORK_HOURS_OPTIONS}
|
||||||
|
onChange={(v) => set('ScheduleChangeCurrentHours', v)}
|
||||||
|
onOtherChange={(v) => set('ScheduleChangeCurrentHoursOther', v)}
|
||||||
|
/>
|
||||||
|
<FormField label="Дата начала" htmlFor="ScheduleChangeDate" full>
|
||||||
|
<TextInput
|
||||||
|
id="ScheduleChangeDate"
|
||||||
|
type="date"
|
||||||
|
value={data.ScheduleChangeDate}
|
||||||
|
onChange={(v) => set('ScheduleChangeDate', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{isEmployee && (
|
||||||
|
<>
|
||||||
|
{/* Термины ТК: неполный рабочий день / неполная рабочая неделя — без них
|
||||||
|
«часов в день» читалось непонятно к чему. Заполнить можно одно или оба. */}
|
||||||
|
<FormField label="Неполный рабочий день, часов" htmlFor="ScheduleChangeHoursPerDay">
|
||||||
|
<TextInput
|
||||||
|
id="ScheduleChangeHoursPerDay"
|
||||||
|
type="number"
|
||||||
|
value={data.ScheduleChangeHoursPerDay}
|
||||||
|
onChange={(v) => set('ScheduleChangeHoursPerDay', v)}
|
||||||
|
placeholder="например, 6"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Неполная рабочая неделя, дней" htmlFor="ScheduleChangeDaysPerWeek">
|
||||||
|
<TextInput
|
||||||
|
id="ScheduleChangeDaysPerWeek"
|
||||||
|
type="number"
|
||||||
|
value={data.ScheduleChangeDaysPerWeek}
|
||||||
|
onChange={(v) => set('ScheduleChangeDaysPerWeek', v)}
|
||||||
|
placeholder="например, 4"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEmployee && (
|
||||||
|
<>
|
||||||
|
<FormField label="Новый режим" htmlFor="ScheduleChangeNewMode" full>
|
||||||
|
<Select
|
||||||
|
id="ScheduleChangeNewMode"
|
||||||
|
value={data.ScheduleChangeNewMode}
|
||||||
|
onChange={(v) => {
|
||||||
|
set('ScheduleChangeNewMode', v);
|
||||||
|
if (v !== MODE_OTHER) set('ScheduleChangeNewModeOther', '');
|
||||||
|
}}
|
||||||
|
options={SCHEDULE_CHANGE_NEW_MODES}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{data.ScheduleChangeNewMode === MODE_OTHER && (
|
||||||
|
<FormField label="Иной режим (описание)" htmlFor="ScheduleChangeNewModeOther" full>
|
||||||
|
<TextInput
|
||||||
|
id="ScheduleChangeNewModeOther"
|
||||||
|
value={data.ScheduleChangeNewModeOther}
|
||||||
|
onChange={(v) => set('ScheduleChangeNewModeOther', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<OptionWithOtherField
|
||||||
|
label="Новый график работы"
|
||||||
|
id="ScheduleChangeNewSchedule"
|
||||||
|
value={data.ScheduleChangeNewSchedule}
|
||||||
|
otherValue={data.ScheduleChangeNewScheduleOther}
|
||||||
|
options={WORK_SCHEDULE_OPTIONS}
|
||||||
|
onChange={(v) => set('ScheduleChangeNewSchedule', v)}
|
||||||
|
onOtherChange={(v) => set('ScheduleChangeNewScheduleOther', v)}
|
||||||
|
/>
|
||||||
|
<OptionWithOtherField
|
||||||
|
label="Новое время работы"
|
||||||
|
id="ScheduleChangeNewHours"
|
||||||
|
value={data.ScheduleChangeNewHours}
|
||||||
|
otherValue={data.ScheduleChangeNewHoursOther}
|
||||||
|
options={WORK_HOURS_OPTIONS}
|
||||||
|
onChange={(v) => set('ScheduleChangeNewHours', v)}
|
||||||
|
onOtherChange={(v) => set('ScheduleChangeNewHoursOther', v)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InitiatorReasonFields
|
||||||
|
initiator={data.ChangeInitiator}
|
||||||
|
reason={{
|
||||||
|
id: 'ScheduleChangeReason',
|
||||||
|
value: data.ScheduleChangeReason,
|
||||||
|
options: SCHEDULE_CHANGE_REASONS,
|
||||||
|
onChange: (v) => set('ScheduleChangeReason', v)
|
||||||
|
}}
|
||||||
|
justification={{
|
||||||
|
id: 'ScheduleChangeJustification',
|
||||||
|
value: data.ScheduleChangeJustification,
|
||||||
|
onChange: (v) => set('ScheduleChangeJustification', v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{attachments && (
|
||||||
|
<AttachmentSlotField
|
||||||
|
label={NEW_SCHEDULE_ATTACHMENT}
|
||||||
|
editor={attachments}
|
||||||
|
editable={!readOnly}
|
||||||
|
groupKey={NEW_SCHEDULE_ATTACHMENT}
|
||||||
|
category={NEW_SCHEDULE_ATTACHMENT}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ISelectOption } from '../../../../shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вид «Изменение режима / графика работы». По ТЗ шаблон зависит от инициатора:
|
||||||
|
* - работник: «прошу установить неполное рабочее время (___ ч в день / ___ дней
|
||||||
|
* в неделю) с __.__.20__ (текст)»;
|
||||||
|
* - работодатель: «прошу перевести работника на а) вахтовый метод; б) сменный
|
||||||
|
* график; в) иной режим (текст) с __.__.20__ в связи со спецификой объекта /
|
||||||
|
* смещением сроков проекта».
|
||||||
|
* Значения = choices колонок ScheduleChangeNewMode / ScheduleChangeReason.
|
||||||
|
*/
|
||||||
|
export const MODE_OTHER = 'Иной режим';
|
||||||
|
export const SCHEDULE_CHANGE_NEW_MODES: ISelectOption[] = ['Вахтовый метод', 'Сменный график', MODE_OTHER].map(
|
||||||
|
(mode) => ({ value: mode, label: mode })
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Причины работодателя — ровно те, что в шаблоне ТЗ («Иное» в ТЗ нет — не добавляем). */
|
||||||
|
export const SCHEDULE_CHANGE_REASONS: ISelectOption[] = ['Специфика объекта', 'Смещение сроков проекта'].map(
|
||||||
|
(reason) => ({ value: reason, label: reason })
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Вложение «Новый график» — по ТЗ «можно приложить документ», необязательно. */
|
||||||
|
export const NEW_SCHEDULE_ATTACHMENT = 'Новый график';
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
@import '../../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Пояснение под полем (ограничение периода).
|
||||||
|
.hint {
|
||||||
|
margin-top: $sp-1;
|
||||||
|
font-size: $fs-label;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Гарантия по оплате из шаблона ТЗ — на всю ширину сетки, приглушённо, как пометка.
|
||||||
|
.payNote {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormGrid, FormField, TextInput, TextArea, Select } from '../../../../shared/ui';
|
||||||
|
import { ProjectPicker } from '../../../../shared/dictionary';
|
||||||
|
import { ISectionProps } from '../../model';
|
||||||
|
import { TEMPORARY_TRANSFER_REASONS, TEMPORARY_TRANSFER_PAY_NOTE, TEMPORARY_TRANSFER_PERIOD_HINT } from './options';
|
||||||
|
import { maxTransferEndDate } from './period';
|
||||||
|
import styles from './TemporaryTransferSection.module.scss';
|
||||||
|
|
||||||
|
const PROJECT_PLACEHOLDER = 'Начните вводить название или код проекта…';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля вида «Временный перевод при производственной необходимости / простое» — по
|
||||||
|
* шаблону ТЗ: «прошу временно перевести работника с ___ (объект / работа) на ___
|
||||||
|
* с … по … в связи с ___ (производственная необходимость / простой). Оплата — не
|
||||||
|
* ниже средней заработной платы по прежней работе». Откуда/куда — проект из
|
||||||
|
* справочника + работа текстом; период не длиннее 3 месяцев (period.ts).
|
||||||
|
* Инициатор — только руководитель.
|
||||||
|
*
|
||||||
|
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||||
|
*/
|
||||||
|
export const TemporaryTransferSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Откуда: проект / объект" htmlFor="TemporaryTransferFromProject">
|
||||||
|
<ProjectPicker
|
||||||
|
context={context}
|
||||||
|
id="TemporaryTransferFromProject"
|
||||||
|
value={data.TemporaryTransferFromProject}
|
||||||
|
onChange={(name, id) => {
|
||||||
|
set('TemporaryTransferFromProject', name);
|
||||||
|
set('TemporaryTransferFromProjectId', id);
|
||||||
|
}}
|
||||||
|
placeholder={PROJECT_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Откуда: работа" htmlFor="TemporaryTransferFromWork">
|
||||||
|
<TextInput
|
||||||
|
id="TemporaryTransferFromWork"
|
||||||
|
value={data.TemporaryTransferFromWork}
|
||||||
|
onChange={(v) => set('TemporaryTransferFromWork', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Куда: проект / объект" htmlFor="TemporaryTransferToProject">
|
||||||
|
<ProjectPicker
|
||||||
|
context={context}
|
||||||
|
id="TemporaryTransferToProject"
|
||||||
|
value={data.TemporaryTransferToProject}
|
||||||
|
onChange={(name, id) => {
|
||||||
|
set('TemporaryTransferToProject', name);
|
||||||
|
set('TemporaryTransferToProjectId', id);
|
||||||
|
}}
|
||||||
|
placeholder={PROJECT_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Куда: работа" htmlFor="TemporaryTransferToWork">
|
||||||
|
<TextInput
|
||||||
|
id="TemporaryTransferToWork"
|
||||||
|
value={data.TemporaryTransferToWork}
|
||||||
|
onChange={(v) => set('TemporaryTransferToWork', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Дата начала" htmlFor="TemporaryTransferStartDate">
|
||||||
|
<TextInput
|
||||||
|
id="TemporaryTransferStartDate"
|
||||||
|
type="date"
|
||||||
|
value={data.TemporaryTransferStartDate}
|
||||||
|
onChange={(v) => set('TemporaryTransferStartDate', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Дата окончания" htmlFor="TemporaryTransferEndDate">
|
||||||
|
{/* Не раньше начала и не дольше 3 месяцев — календарь не даст выбрать лишнее. */}
|
||||||
|
<TextInput
|
||||||
|
id="TemporaryTransferEndDate"
|
||||||
|
type="date"
|
||||||
|
value={data.TemporaryTransferEndDate}
|
||||||
|
onChange={(v) => set('TemporaryTransferEndDate', v)}
|
||||||
|
min={data.TemporaryTransferStartDate || undefined}
|
||||||
|
max={maxTransferEndDate(data.TemporaryTransferStartDate)}
|
||||||
|
/>
|
||||||
|
<div className={styles.hint}>{TEMPORARY_TRANSFER_PERIOD_HINT}</div>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Основание" htmlFor="TemporaryTransferReason" full>
|
||||||
|
<Select
|
||||||
|
id="TemporaryTransferReason"
|
||||||
|
value={data.TemporaryTransferReason}
|
||||||
|
onChange={(v) => set('TemporaryTransferReason', v)}
|
||||||
|
options={TEMPORARY_TRANSFER_REASONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Обоснование" htmlFor="TemporaryTransferJustification" full>
|
||||||
|
<TextArea
|
||||||
|
id="TemporaryTransferJustification"
|
||||||
|
value={data.TemporaryTransferJustification}
|
||||||
|
onChange={(v) => set('TemporaryTransferJustification', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className={styles.payNote}>{TEMPORARY_TRANSFER_PAY_NOTE}</div>
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ISelectOption } from '../../../../shared/ui';
|
||||||
|
import { MAX_TRANSFER_MONTHS } from './period';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основания временного перевода — из шаблона ТЗ («в связи с ___ (производственная
|
||||||
|
* необходимость / простой)»). Значения = choices колонки TemporaryTransferReason.
|
||||||
|
*/
|
||||||
|
export const TEMPORARY_TRANSFER_REASONS: ISelectOption[] = ['Производственная необходимость', 'Простой'].map(
|
||||||
|
(reason) => ({ value: reason, label: reason })
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Гарантия работнику из шаблона ТЗ — показывается на форме пометкой. */
|
||||||
|
export const TEMPORARY_TRANSFER_PAY_NOTE = 'Оплата — не ниже средней заработной платы по прежней работе.';
|
||||||
|
|
||||||
|
/** Пояснение под датой окончания. */
|
||||||
|
export const TEMPORARY_TRANSFER_PERIOD_HINT = `Не дольше ${MAX_TRANSFER_MONTHS} месяцев.`;
|
||||||
|
export const TEMPORARY_TRANSFER_PERIOD_ERROR = `Временный перевод — не дольше ${MAX_TRANSFER_MONTHS} месяцев, и дата окончания не раньше даты начала.`;
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { maxTransferEndDate } from './period';
|
||||||
|
|
||||||
|
describe('maxTransferEndDate', () => {
|
||||||
|
it('3 месяца включительно: с 01.01 — по 31.03', () => {
|
||||||
|
expect(maxTransferEndDate('2026-01-01')).toBe('2026-03-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('с середины месяца', () => {
|
||||||
|
expect(maxTransferEndDate('2026-02-15')).toBe('2026-05-14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('через новый год', () => {
|
||||||
|
expect(maxTransferEndDate('2026-11-01')).toBe('2027-01-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('конец месяца не уезжает в следующий (30.11 + 3 мес. = 28.02)', () => {
|
||||||
|
expect(maxTransferEndDate('2026-11-30')).toBe('2027-02-27');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустая дата — нет ограничения', () => {
|
||||||
|
expect(maxTransferEndDate('')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Временный перевод — не дольше 3 месяцев (ТЗ: «До 3 месяцев в течение
|
||||||
|
* календарного года»; сумму за год форма не видит — её проверяет HR-директор).
|
||||||
|
*/
|
||||||
|
export const MAX_TRANSFER_MONTHS = 3;
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return n < 10 ? `0${n}` : `${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Последний допустимый день периода, начатого start (YYYY-MM-DD): start + 3 месяца
|
||||||
|
* − 1 день, оба дня включительно (с 01.01 — по 31.03). К концу месяца прижимаем:
|
||||||
|
* 30.11 + 3 мес. — это 28.02, а не 02.03. Некорректная дата — undefined.
|
||||||
|
*/
|
||||||
|
export function maxTransferEndDate(startIso: string): string | undefined {
|
||||||
|
const [year, month, day] = startIso.split('-').map(Number);
|
||||||
|
if (!year || !month || !day) return undefined;
|
||||||
|
|
||||||
|
const targetMonthIndex = month - 1 + MAX_TRANSFER_MONTHS;
|
||||||
|
const lastDayOfTargetMonth = new Date(year, targetMonthIndex + 1, 0).getDate();
|
||||||
|
const end = new Date(year, targetMonthIndex, Math.min(day, lastDayOfTargetMonth));
|
||||||
|
end.setDate(end.getDate() - 1);
|
||||||
|
return `${end.getFullYear()}-${pad2(end.getMonth() + 1)}-${pad2(end.getDate())}`;
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
@import '../../../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
// Ошибка загрузки справочника под полем (не прячем за пустым списком).
|
||||||
|
.fieldError {
|
||||||
|
margin-top: $sp-1;
|
||||||
|
font-size: $fs-label;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormGrid, FormField, TextInput, TextArea, Select, Toggle } from '../../../../shared/ui';
|
||||||
|
import { ProjectPicker } from '../../../../shared/dictionary';
|
||||||
|
import { ISectionProps } from '../../model';
|
||||||
|
import { WORKPLACE_CHANGE_REASONS } from './options';
|
||||||
|
import { WORK_SCHEDULE_OPTIONS } from '../workSchedule';
|
||||||
|
import { OptionWithOtherField } from '../OptionWithOtherField';
|
||||||
|
import { useCompanyOptions } from './useCompanyOptions';
|
||||||
|
import { InitiatorReasonFields } from '../InitiatorReasonFields';
|
||||||
|
import styles from './WorkplaceChangeSection.module.scss';
|
||||||
|
|
||||||
|
const PROJECT_PLACEHOLDER = 'Начните вводить название или код проекта…';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поля вида «Изменение места работы (другая местность)». Состав согласован с
|
||||||
|
* Тимуром по ТЗ кадров: место работы «было → стало» парой «организация + проект»
|
||||||
|
* (текущая организация — из выбранной в шапке, новая обязательна), адрес, дата,
|
||||||
|
* причина (а–в), условия переезда, вахта с графиком работы, обоснование.
|
||||||
|
*
|
||||||
|
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||||
|
*/
|
||||||
|
export const WorkplaceChangeSection: React.FC<ISectionProps> = ({ data, set, context }) => {
|
||||||
|
const companies = useCompanyOptions(context);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="Текущая организация" htmlFor="WorkplaceChangeCurrentCompany">
|
||||||
|
{/* Та же, что выбрана в «Организации» сотрудника, — второй раз не вводим. */}
|
||||||
|
<TextInput id="WorkplaceChangeCurrentCompany" value={data.Employee?.company || ''} onChange={() => undefined} readOnly />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Текущий проект" htmlFor="WorkplaceChangeCurrentProject">
|
||||||
|
<ProjectPicker
|
||||||
|
context={context}
|
||||||
|
id="WorkplaceChangeCurrentProject"
|
||||||
|
value={data.WorkplaceChangeCurrentProject}
|
||||||
|
onChange={(name, id) => {
|
||||||
|
set('WorkplaceChangeCurrentProject', name);
|
||||||
|
set('WorkplaceChangeCurrentProjectId', id);
|
||||||
|
}}
|
||||||
|
placeholder={PROJECT_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Новая организация" htmlFor="WorkplaceChangeNewCompanyId" required>
|
||||||
|
<Select
|
||||||
|
id="WorkplaceChangeNewCompanyId"
|
||||||
|
value={data.WorkplaceChangeNewCompanyId}
|
||||||
|
onChange={(id) => {
|
||||||
|
set('WorkplaceChangeNewCompanyId', id);
|
||||||
|
set('WorkplaceChangeNewCompany', companies.byId.get(id)?.Name || '');
|
||||||
|
}}
|
||||||
|
options={companies.options}
|
||||||
|
placeholder={companies.loading ? 'Загрузка…' : '— выберите —'}
|
||||||
|
/>
|
||||||
|
{companies.error && <div className={styles.fieldError}>{companies.error}</div>}
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Новый проект" htmlFor="WorkplaceChangeNewProject">
|
||||||
|
<ProjectPicker
|
||||||
|
context={context}
|
||||||
|
id="WorkplaceChangeNewProject"
|
||||||
|
value={data.WorkplaceChangeNewProject}
|
||||||
|
onChange={(name, id) => {
|
||||||
|
set('WorkplaceChangeNewProject', name);
|
||||||
|
set('WorkplaceChangeNewProjectId', id);
|
||||||
|
}}
|
||||||
|
placeholder={PROJECT_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Адрес нового места работы" htmlFor="WorkplaceChangeAddress" full>
|
||||||
|
<TextInput
|
||||||
|
id="WorkplaceChangeAddress"
|
||||||
|
value={data.WorkplaceChangeAddress}
|
||||||
|
onChange={(v) => set('WorkplaceChangeAddress', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Дата изменения" htmlFor="WorkplaceChangeDate">
|
||||||
|
<TextInput
|
||||||
|
id="WorkplaceChangeDate"
|
||||||
|
type="date"
|
||||||
|
value={data.WorkplaceChangeDate}
|
||||||
|
onChange={(v) => set('WorkplaceChangeDate', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Условия переезда и компенсации" htmlFor="WorkplaceChangeRelocation" full>
|
||||||
|
<TextArea
|
||||||
|
id="WorkplaceChangeRelocation"
|
||||||
|
value={data.WorkplaceChangeRelocation}
|
||||||
|
onChange={(v) => set('WorkplaceChangeRelocation', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Вахта" htmlFor="WorkplaceChangeRotation" full>
|
||||||
|
<Toggle
|
||||||
|
id="WorkplaceChangeRotation"
|
||||||
|
checked={data.WorkplaceChangeRotation}
|
||||||
|
onChange={(checked) => {
|
||||||
|
set('WorkplaceChangeRotation', checked);
|
||||||
|
// Без вахты график не нужен — очищаем, чтобы не сохранить скрытое значение.
|
||||||
|
if (!checked) {
|
||||||
|
set('WorkplaceChangeSchedule', '');
|
||||||
|
set('WorkplaceChangeScheduleOther', '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onLabel="Да"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{data.WorkplaceChangeRotation && (
|
||||||
|
<OptionWithOtherField
|
||||||
|
label="График работы"
|
||||||
|
id="WorkplaceChangeSchedule"
|
||||||
|
value={data.WorkplaceChangeSchedule}
|
||||||
|
otherValue={data.WorkplaceChangeScheduleOther}
|
||||||
|
options={WORK_SCHEDULE_OPTIONS}
|
||||||
|
onChange={(v) => set('WorkplaceChangeSchedule', v)}
|
||||||
|
onOtherChange={(v) => set('WorkplaceChangeScheduleOther', v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<InitiatorReasonFields
|
||||||
|
initiator={data.ChangeInitiator}
|
||||||
|
reason={{
|
||||||
|
id: 'WorkplaceChangeReason',
|
||||||
|
value: data.WorkplaceChangeReason,
|
||||||
|
options: WORKPLACE_CHANGE_REASONS,
|
||||||
|
onChange: (v) => set('WorkplaceChangeReason', v)
|
||||||
|
}}
|
||||||
|
justification={{
|
||||||
|
id: 'WorkplaceChangeJustification',
|
||||||
|
value: data.WorkplaceChangeJustification,
|
||||||
|
onChange: (v) => set('WorkplaceChangeJustification', v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGrid>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ISelectOption } from '../../../../shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Причины изменения места работы — по шаблону текста ТЗ кадров (а–в).
|
||||||
|
* Работника и работодателя не различаем (как в видах 3–4).
|
||||||
|
* Значения совпадают с choices колонки WorkplaceChangeReason (provisioning.json).
|
||||||
|
*/
|
||||||
|
export const WORKPLACE_CHANGE_REASONS: ISelectOption[] = [
|
||||||
|
'Открытие нового объекта',
|
||||||
|
'Переезд подразделения / работодателя в другую местность',
|
||||||
|
'Иное'
|
||||||
|
].map((reason) => ({ value: reason, label: reason }));
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { ISelectOption } from '../../../../shared/ui';
|
||||||
|
import { ICompany, getCompanies } from '../../../../shared/dictionary';
|
||||||
|
|
||||||
|
export interface ICompanyOptions {
|
||||||
|
options: ISelectOption[];
|
||||||
|
/** Компании по Id — чтобы по выбранному значению Select получить название. */
|
||||||
|
byId: Map<string, ICompany>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Справочник компаний (GET /Dictionary/Companies) как варианты Select: value — Id,
|
||||||
|
* label — название. Список кешируется в самом сервисе (localStorage, 4 часа).
|
||||||
|
* Не загрузился — ошибка видна под полем, не прячем её за пустым списком.
|
||||||
|
*/
|
||||||
|
export function useCompanyOptions(context: WebPartContext): ICompanyOptions {
|
||||||
|
const [companies, setCompanies] = useState<ICompany[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getCompanies(context)
|
||||||
|
.then((list) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setCompanies(list);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!alive) return;
|
||||||
|
setError('Не удалось загрузить список организаций. Обновите страницу.');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: companies.map((c) => ({ value: c.Id, label: c.Name })),
|
||||||
|
byId: new Map(companies.map((c) => [c.Id, c])),
|
||||||
|
loading,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import { attachmentsOfCategory, prefixFiles } from '../../../shared/attachments/
|
|||||||
import { IFormData } from '../model';
|
import { IFormData } from '../model';
|
||||||
import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/options';
|
import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/options';
|
||||||
import { JOB_DESCRIPTION_ATTACHMENT } from './PositionTransfer/options';
|
import { JOB_DESCRIPTION_ATTACHMENT } from './PositionTransfer/options';
|
||||||
|
import { NEW_SCHEDULE_ATTACHMENT } from './ScheduleChange/options';
|
||||||
|
import { NEW_JOB_DESCRIPTION_ATTACHMENT } from './FunctionChange/options';
|
||||||
|
import { EMPLOYER_JUSTIFICATION_ATTACHMENT } from './EmployerInitiative/options';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подписанный слот вложений по колонке ТЗ «Что приложить». Подпись (category)
|
* Подписанный слот вложений по колонке ТЗ «Что приложить». Подпись (category)
|
||||||
@@ -23,6 +26,17 @@ export function activeAttachmentSlots(data: IFormData): IAttachmentSlot[] {
|
|||||||
if (data.ChangeTypes.includes('position-transfer')) {
|
if (data.ChangeTypes.includes('position-transfer')) {
|
||||||
slots.push({ category: JOB_DESCRIPTION_ATTACHMENT, required: true });
|
slots.push({ category: JOB_DESCRIPTION_ATTACHMENT, required: true });
|
||||||
}
|
}
|
||||||
|
if (data.ChangeTypes.includes('function-change')) {
|
||||||
|
slots.push({ category: NEW_JOB_DESCRIPTION_ATTACHMENT, required: true });
|
||||||
|
}
|
||||||
|
if (data.ChangeTypes.includes('employer-initiative')) {
|
||||||
|
// «Текст ИЛИ документ» — сам слот необязателен, правило «одно из двух» — в submitChecks.
|
||||||
|
slots.push({ category: EMPLOYER_JUSTIFICATION_ATTACHMENT, required: false });
|
||||||
|
}
|
||||||
|
if (data.ChangeTypes.includes('schedule-change')) {
|
||||||
|
// «Можно приложить документ» — необязательно.
|
||||||
|
slots.push({ category: NEW_SCHEDULE_ATTACHMENT, required: false });
|
||||||
|
}
|
||||||
return slots;
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,14 +49,16 @@ export function prepareFilesFor(data: IFormData): PrepareFiles {
|
|||||||
return (groupKey, files) => (visible.indexOf(groupKey) >= 0 ? prefixFiles(files, groupKey) : []);
|
return (groupKey, files) => (visible.indexOf(groupKey) >= 0 ? prefixFiles(files, groupKey) : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Есть ли в слоте файл — уже прикреплённый или выбранный к загрузке. */
|
||||||
|
export function hasAttachment(editor: IAttachmentEditor, category: string): boolean {
|
||||||
|
const uploaded = attachmentsOfCategory(editor.items, category).length;
|
||||||
|
const selected = (editor.pending[category] || []).length;
|
||||||
|
return uploaded + selected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Подписи обязательных слотов, где нет ни прикреплённого, ни выбранного файла. */
|
/** Подписи обязательных слотов, где нет ни прикреплённого, ни выбранного файла. */
|
||||||
export function missingRequiredAttachments(data: IFormData, editor: IAttachmentEditor): string[] {
|
export function missingRequiredAttachments(data: IFormData, editor: IAttachmentEditor): string[] {
|
||||||
return activeAttachmentSlots(data)
|
return activeAttachmentSlots(data)
|
||||||
.filter((slot) => slot.required)
|
.filter((slot) => slot.required && !hasAttachment(editor, slot.category))
|
||||||
.filter((slot) => {
|
|
||||||
const uploaded = attachmentsOfCategory(editor.items, slot.category).length;
|
|
||||||
const selected = (editor.pending[slot.category] || []).length;
|
|
||||||
return uploaded + selected === 0;
|
|
||||||
})
|
|
||||||
.map((slot) => slot.category);
|
.map((slot) => slot.category);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { IAttachmentEditor } from '../../../shared/attachments/useAttachmentEditor';
|
||||||
|
import { formatIsoDate } from '../../../shared/dateUtils';
|
||||||
|
import {
|
||||||
|
IFormData,
|
||||||
|
INITIATOR_REQUIRED_MESSAGE,
|
||||||
|
NEW_COMPANY_REQUIRED_MESSAGE,
|
||||||
|
RECEIVING_HEAD_REQUIRED_MESSAGE,
|
||||||
|
EMPLOYER_CHANGE_NOTHING_SELECTED_MESSAGE,
|
||||||
|
EMPLOYER_JUSTIFICATION_REQUIRED_MESSAGE
|
||||||
|
} from '../model';
|
||||||
|
import { hasInitiatorChoice } from '../changeTypes';
|
||||||
|
import { hasAttachment, missingRequiredAttachments } from './attachmentSlots';
|
||||||
|
import {
|
||||||
|
EMPLOYER_CHANGE_NOTICE_DAYS,
|
||||||
|
EMPLOYER_JUSTIFICATION_ATTACHMENT,
|
||||||
|
minEmployerChangeDate
|
||||||
|
} from './EmployerInitiative/options';
|
||||||
|
import { TEMPORARY_TRANSFER_PERIOD_ERROR } from './TemporaryTransfer/options';
|
||||||
|
import { maxTransferEndDate } from './TemporaryTransfer/period';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Что обязательно по выбранному виду изменения — проверяется только при ОТПРАВКЕ:
|
||||||
|
* черновик сохраняется и без этого. Первая найденная проблема — текстом; ничего — undefined.
|
||||||
|
* Новое обязательное поле вида = строка здесь.
|
||||||
|
*/
|
||||||
|
export function changeTypeSubmitError(data: IFormData, attachments: IAttachmentEditor): string | undefined {
|
||||||
|
if (hasInitiatorChoice(data.ChangeTypes) && !data.ChangeInitiator) return INITIATOR_REQUIRED_MESSAGE;
|
||||||
|
|
||||||
|
const missingFiles = missingRequiredAttachments(data, attachments);
|
||||||
|
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
|
||||||
|
|
||||||
|
if (data.ChangeTypes.includes('department-transfer') && !data.DepartmentTransferReceivingHead) {
|
||||||
|
return RECEIVING_HEAD_REQUIRED_MESSAGE;
|
||||||
|
}
|
||||||
|
if (data.ChangeTypes.includes('workplace-change') && !data.WorkplaceChangeNewCompanyId) {
|
||||||
|
return NEW_COMPANY_REQUIRED_MESSAGE;
|
||||||
|
}
|
||||||
|
if (data.ChangeTypes.includes('employer-initiative')) {
|
||||||
|
return employerInitiativeError(data, attachments);
|
||||||
|
}
|
||||||
|
if (data.ChangeTypes.includes('temporary-transfer')) {
|
||||||
|
return temporaryTransferError(data);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Временный перевод: если обе даты заданы — окончание не раньше начала и не дольше
|
||||||
|
* 3 месяцев. Календарь это уже ограничивает; здесь — на случай, когда дату начала
|
||||||
|
* сдвинули после выбора окончания.
|
||||||
|
*/
|
||||||
|
function temporaryTransferError(data: IFormData): string | undefined {
|
||||||
|
const start = data.TemporaryTransferStartDate;
|
||||||
|
const end = data.TemporaryTransferEndDate;
|
||||||
|
if (!start || !end) return undefined;
|
||||||
|
const maxEnd = maxTransferEndDate(start);
|
||||||
|
if (end < start || (maxEnd && end > maxEnd)) return TEMPORARY_TRANSFER_PERIOD_ERROR;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициатива работодателя: отмечено, что меняется; обоснование текстом ИЛИ
|
||||||
|
* документом (ТЗ: «ТЕКСТ ИЛИ ПРИЛОЖИТЬ ДОКУМЕНТ»); даты — не раньше срока
|
||||||
|
* уведомления. Даты проверяем и здесь, а не только min в календаре: черновик мог
|
||||||
|
* пролежать, и прежняя дата стала слишком близкой.
|
||||||
|
*/
|
||||||
|
function employerInitiativeError(data: IFormData, attachments: IAttachmentEditor): string | undefined {
|
||||||
|
if (!data.EmployerChangeSchedule && !data.EmployerChangeSalary) return EMPLOYER_CHANGE_NOTHING_SELECTED_MESSAGE;
|
||||||
|
|
||||||
|
const hasJustification =
|
||||||
|
!!data.EmployerChangeJustification.trim() || hasAttachment(attachments, EMPLOYER_JUSTIFICATION_ATTACHMENT);
|
||||||
|
if (!hasJustification) return EMPLOYER_JUSTIFICATION_REQUIRED_MESSAGE;
|
||||||
|
|
||||||
|
const minDate = minEmployerChangeDate();
|
||||||
|
const dates = [
|
||||||
|
data.EmployerChangeSchedule ? data.EmployerChangeScheduleDate : '',
|
||||||
|
data.EmployerChangeSalary ? data.EmployerChangeSalaryDate : ''
|
||||||
|
];
|
||||||
|
if (dates.some((date) => date && date < minDate)) {
|
||||||
|
return `Дата изменений — не раньше ${formatIsoDate(minDate)}: работника уведомляют за ${EMPLOYER_CHANGE_NOTICE_DAYS} календарных дней.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ISelectOption } from '../../../shared/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режим работы: «График работы» и «Время работы» — те же наборы, что в «Заявке
|
||||||
|
* на подбор» и «Заявке на приём» (WORK_SCHEDULE_OPTIONS / WORK_HOURS_OPTIONS там):
|
||||||
|
* формы независимы, значения продублированы намеренно, как и между ними.
|
||||||
|
* Общие для видов этой формы (место работы — вахта, режим/график работы).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** «Другое» в списке — открывает ручной ввод (см. OptionWithOtherField). */
|
||||||
|
export const OPTION_OTHER = 'Другое';
|
||||||
|
|
||||||
|
function toOptions(values: string[]): ISelectOption[] {
|
||||||
|
return values.map((value) => ({ value, label: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = toOptions([
|
||||||
|
'5/2',
|
||||||
|
'Вахтовый 30/30',
|
||||||
|
'Вахтовый 20/10',
|
||||||
|
'Вахтовый 30/15',
|
||||||
|
'Вахтовый 22/8',
|
||||||
|
'Вахтовый 15/15',
|
||||||
|
'6/1',
|
||||||
|
'1/2',
|
||||||
|
'2/2',
|
||||||
|
OPTION_OTHER
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 24-часовой формат, как в подборе и приёме. */
|
||||||
|
export const WORK_HOURS_OPTIONS: ISelectOption[] = toOptions([
|
||||||
|
'08:00-17:00',
|
||||||
|
'09:00-18:00',
|
||||||
|
'07:00-19:00',
|
||||||
|
'08:00-20:00',
|
||||||
|
'09:00-21:00',
|
||||||
|
OPTION_OTHER
|
||||||
|
]);
|
||||||
@@ -28,13 +28,12 @@ export const CHANGE_TYPE_LABELS: Record<ChangeType, string> = {
|
|||||||
'department-transfer': 'Перевод в другое подразделение',
|
'department-transfer': 'Перевод в другое подразделение',
|
||||||
'workplace-change': 'Изменение места работы (другая местность)',
|
'workplace-change': 'Изменение места работы (другая местность)',
|
||||||
'contract-extension': 'Продление срока трудового договора',
|
'contract-extension': 'Продление срока трудового договора',
|
||||||
'schedule-change':
|
// В ТЗ ещё «; неполное рабочее время по просьбе работника» — сокращено по просьбе Тимура
|
||||||
'Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника',
|
// (неполное время — поля работника внутри вида).
|
||||||
|
'schedule-change': 'Изменение режима / графика работы по соглашению сторон',
|
||||||
'function-change': 'Изменение трудовой функции (без смены должности)',
|
'function-change': 'Изменение трудовой функции (без смены должности)',
|
||||||
// Самая длинная подпись (~225 символов) — близко к лимиту choice в SP (255), не удлинять.
|
// В ТЗ — длинная формулировка с перечнем причин; сокращено по просьбе Тимура (причины — в поле «Причина» вида).
|
||||||
'employer-initiative':
|
'employer-initiative': 'Изменение условий труда по инициативе работодателя',
|
||||||
'Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, ' +
|
|
||||||
'изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)',
|
|
||||||
'temporary-transfer': 'Временный перевод при производственной необходимости / простое'
|
'temporary-transfer': 'Временный перевод при производственной необходимости / простое'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +48,39 @@ export const CHANGE_TYPE_OPTIONS: ISelectOption[] = (Object.keys(CHANGE_TYPE_LAB
|
|||||||
(type) => ({ value: type, label: CHANGE_TYPE_LABELS[type] })
|
(type) => ({ value: type, label: CHANGE_TYPE_LABELS[type] })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кто инициирует изменение. Значения = choices колонки ChangeInitiator. По ТЗ у
|
||||||
|
* части видов шаблон разный: «От работника: заявление (текст). От работодателя:
|
||||||
|
* прошу… в связи с: а) б) в)» — форма показывает поля той роли, что выбрана.
|
||||||
|
*/
|
||||||
|
export type InitiatorRole = 'Работник' | 'Работодатель';
|
||||||
|
export const INITIATOR_EMPLOYEE: InitiatorRole = 'Работник';
|
||||||
|
export const INITIATOR_EMPLOYER: InitiatorRole = 'Работодатель';
|
||||||
|
export const INITIATOR_OPTIONS: ISelectOption[] = [INITIATOR_EMPLOYEE, INITIATOR_EMPLOYER].map((role) => ({
|
||||||
|
value: role,
|
||||||
|
label: role
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Виды, которые по ТЗ («Кто инициирует») может начать и руководитель, и сам
|
||||||
|
* работник. У остальных инициатор — только руководитель, выбора нет.
|
||||||
|
*/
|
||||||
|
const TYPES_WITH_INITIATOR_CHOICE: ChangeType[] = [
|
||||||
|
'position-transfer',
|
||||||
|
'department-transfer',
|
||||||
|
'workplace-change',
|
||||||
|
'schedule-change'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function hasInitiatorChoice(types: ChangeType[]): boolean {
|
||||||
|
return types.some((type) => TYPES_WITH_INITIATOR_CHOICE.indexOf(type) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка из SP → роль; пусто/неизвестное — '' (не выбрана). */
|
||||||
|
export function initiatorRoleFromText(text: string): InitiatorRole | '' {
|
||||||
|
return text === INITIATOR_EMPLOYEE || text === INITIATOR_EMPLOYER ? text : '';
|
||||||
|
}
|
||||||
|
|
||||||
/** Подпись из SP → ключ вида. Неизвестная подпись (переименовали choice) — undefined. */
|
/** Подпись из SP → ключ вида. Неизвестная подпись (переименовали choice) — undefined. */
|
||||||
export function changeTypeFromLabel(label: string): ChangeType | undefined {
|
export function changeTypeFromLabel(label: string): ChangeType | undefined {
|
||||||
const normalized = label.trim();
|
const normalized = label.trim();
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { getSP } from '../../shared/pnp/pnpjs';
|
|||||||
import { dataListTitle } from '../../shared/listTitles';
|
import { dataListTitle } from '../../shared/listTitles';
|
||||||
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||||
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||||
import { ChangeType, changeTypeFromLabel } from './changeTypes';
|
import { ChangeType, changeTypeFromLabel, initiatorRoleFromText } from './changeTypes';
|
||||||
import { allowanceUnitFromText } from './byType/Allowance/options';
|
import { allowanceUnitFromText } from './byType/Allowance/options';
|
||||||
|
import { DEFAULT_FIXED_PERCENT } from './byType/EmployerInitiative/salaryParts';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
||||||
|
|
||||||
export interface ILoadedRequest {
|
export interface ILoadedRequest {
|
||||||
@@ -88,6 +89,7 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
|
|||||||
'EmployeePosition',
|
'EmployeePosition',
|
||||||
'EmployeeDepartment',
|
'EmployeeDepartment',
|
||||||
'ChangeTypes',
|
'ChangeTypes',
|
||||||
|
'ChangeInitiator',
|
||||||
'SalaryIncreaseCurrent',
|
'SalaryIncreaseCurrent',
|
||||||
'SalaryIncreaseNew',
|
'SalaryIncreaseNew',
|
||||||
'SalaryIncreaseDate',
|
'SalaryIncreaseDate',
|
||||||
@@ -103,13 +105,76 @@ 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',
|
||||||
|
'WorkplaceChangeCurrentProject',
|
||||||
|
'WorkplaceChangeCurrentProjectId',
|
||||||
|
'WorkplaceChangeNewCompany',
|
||||||
|
'WorkplaceChangeNewCompanyId',
|
||||||
|
'WorkplaceChangeNewProject',
|
||||||
|
'WorkplaceChangeNewProjectId',
|
||||||
|
'WorkplaceChangeAddress',
|
||||||
|
'WorkplaceChangeDate',
|
||||||
|
'WorkplaceChangeReason',
|
||||||
|
'WorkplaceChangeRelocation',
|
||||||
|
'WorkplaceChangeRotation',
|
||||||
|
'WorkplaceChangeSchedule',
|
||||||
|
'WorkplaceChangeScheduleOther',
|
||||||
|
'WorkplaceChangeJustification',
|
||||||
|
'ScheduleChangeCurrentSchedule',
|
||||||
|
'ScheduleChangeCurrentScheduleOther',
|
||||||
|
'ScheduleChangeCurrentHours',
|
||||||
|
'ScheduleChangeCurrentHoursOther',
|
||||||
|
'ScheduleChangeDate',
|
||||||
|
'ScheduleChangeHoursPerDay',
|
||||||
|
'ScheduleChangeDaysPerWeek',
|
||||||
|
'ScheduleChangeNewMode',
|
||||||
|
'ScheduleChangeNewModeOther',
|
||||||
|
'ScheduleChangeNewSchedule',
|
||||||
|
'ScheduleChangeNewScheduleOther',
|
||||||
|
'ScheduleChangeNewHours',
|
||||||
|
'ScheduleChangeNewHoursOther',
|
||||||
|
'ScheduleChangeReason',
|
||||||
|
'ScheduleChangeJustification',
|
||||||
|
'FunctionChangeDate',
|
||||||
|
'FunctionChangeReason',
|
||||||
|
'EmployerChangeReason',
|
||||||
|
'EmployerChangeReasonOther',
|
||||||
|
'EmployerChangeSchedule',
|
||||||
|
'EmployerChangeCurrentSchedule',
|
||||||
|
'EmployerChangeCurrentScheduleOther',
|
||||||
|
'EmployerChangeNewSchedule',
|
||||||
|
'EmployerChangeNewScheduleOther',
|
||||||
|
'EmployerChangeScheduleDate',
|
||||||
|
'EmployerChangeSalary',
|
||||||
|
'EmployerChangeNewSalary',
|
||||||
|
'EmployerChangeFixedPercent',
|
||||||
|
'EmployerChangeSalaryDate',
|
||||||
|
'EmployerChangeJustification',
|
||||||
|
'TemporaryTransferFromProject',
|
||||||
|
'TemporaryTransferFromProjectId',
|
||||||
|
'TemporaryTransferFromWork',
|
||||||
|
'TemporaryTransferToProject',
|
||||||
|
'TemporaryTransferToProjectId',
|
||||||
|
'TemporaryTransferToWork',
|
||||||
|
'TemporaryTransferStartDate',
|
||||||
|
'TemporaryTransferEndDate',
|
||||||
|
'TemporaryTransferReason',
|
||||||
|
'TemporaryTransferJustification'
|
||||||
|
)
|
||||||
|
.expand('DepartmentTransferReceivingHead')()) as Record<string, unknown>;
|
||||||
|
const receivingHead = item.DepartmentTransferReceivingHead as { EMail?: string } | undefined;
|
||||||
|
|
||||||
const data: IFormData = {
|
const data: IFormData = {
|
||||||
...EMPTY_DATA,
|
...EMPTY_DATA,
|
||||||
Employee: employeeFromItem(item),
|
Employee: employeeFromItem(item),
|
||||||
ChangeTypes: changeTypesFromItem(item.ChangeTypes),
|
ChangeTypes: changeTypesFromItem(item.ChangeTypes),
|
||||||
|
ChangeInitiator: initiatorRoleFromText(text(item.ChangeInitiator)),
|
||||||
SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent),
|
SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent),
|
||||||
SalaryIncreaseNew: numberText(item.SalaryIncreaseNew),
|
SalaryIncreaseNew: numberText(item.SalaryIncreaseNew),
|
||||||
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
|
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
|
||||||
@@ -126,7 +191,69 @@ 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 || '',
|
||||||
|
WorkplaceChangeCurrentProject: text(item.WorkplaceChangeCurrentProject),
|
||||||
|
WorkplaceChangeCurrentProjectId: text(item.WorkplaceChangeCurrentProjectId),
|
||||||
|
WorkplaceChangeNewCompany: text(item.WorkplaceChangeNewCompany),
|
||||||
|
WorkplaceChangeNewCompanyId: text(item.WorkplaceChangeNewCompanyId),
|
||||||
|
WorkplaceChangeNewProject: text(item.WorkplaceChangeNewProject),
|
||||||
|
WorkplaceChangeNewProjectId: text(item.WorkplaceChangeNewProjectId),
|
||||||
|
WorkplaceChangeAddress: text(item.WorkplaceChangeAddress),
|
||||||
|
WorkplaceChangeDate: spDateOnlyToIso(text(item.WorkplaceChangeDate)),
|
||||||
|
WorkplaceChangeReason: text(item.WorkplaceChangeReason),
|
||||||
|
WorkplaceChangeRelocation: text(item.WorkplaceChangeRelocation),
|
||||||
|
WorkplaceChangeRotation: item.WorkplaceChangeRotation === true,
|
||||||
|
WorkplaceChangeSchedule: text(item.WorkplaceChangeSchedule),
|
||||||
|
WorkplaceChangeScheduleOther: text(item.WorkplaceChangeScheduleOther),
|
||||||
|
WorkplaceChangeJustification: text(item.WorkplaceChangeJustification),
|
||||||
|
ScheduleChangeCurrentSchedule: text(item.ScheduleChangeCurrentSchedule),
|
||||||
|
ScheduleChangeCurrentScheduleOther: text(item.ScheduleChangeCurrentScheduleOther),
|
||||||
|
ScheduleChangeCurrentHours: text(item.ScheduleChangeCurrentHours),
|
||||||
|
ScheduleChangeCurrentHoursOther: text(item.ScheduleChangeCurrentHoursOther),
|
||||||
|
ScheduleChangeDate: spDateOnlyToIso(text(item.ScheduleChangeDate)),
|
||||||
|
ScheduleChangeHoursPerDay: numberText(item.ScheduleChangeHoursPerDay),
|
||||||
|
ScheduleChangeDaysPerWeek: numberText(item.ScheduleChangeDaysPerWeek),
|
||||||
|
ScheduleChangeNewMode: text(item.ScheduleChangeNewMode),
|
||||||
|
ScheduleChangeNewModeOther: text(item.ScheduleChangeNewModeOther),
|
||||||
|
ScheduleChangeNewSchedule: text(item.ScheduleChangeNewSchedule),
|
||||||
|
ScheduleChangeNewScheduleOther: text(item.ScheduleChangeNewScheduleOther),
|
||||||
|
ScheduleChangeNewHours: text(item.ScheduleChangeNewHours),
|
||||||
|
ScheduleChangeNewHoursOther: text(item.ScheduleChangeNewHoursOther),
|
||||||
|
ScheduleChangeReason: text(item.ScheduleChangeReason),
|
||||||
|
ScheduleChangeJustification: text(item.ScheduleChangeJustification),
|
||||||
|
FunctionChangeDate: spDateOnlyToIso(text(item.FunctionChangeDate)),
|
||||||
|
FunctionChangeReason: text(item.FunctionChangeReason),
|
||||||
|
EmployerChangeReason: text(item.EmployerChangeReason),
|
||||||
|
EmployerChangeReasonOther: text(item.EmployerChangeReasonOther),
|
||||||
|
EmployerChangeSchedule: item.EmployerChangeSchedule === true,
|
||||||
|
EmployerChangeCurrentSchedule: text(item.EmployerChangeCurrentSchedule),
|
||||||
|
EmployerChangeCurrentScheduleOther: text(item.EmployerChangeCurrentScheduleOther),
|
||||||
|
EmployerChangeNewSchedule: text(item.EmployerChangeNewSchedule),
|
||||||
|
EmployerChangeNewScheduleOther: text(item.EmployerChangeNewScheduleOther),
|
||||||
|
EmployerChangeScheduleDate: spDateOnlyToIso(text(item.EmployerChangeScheduleDate)),
|
||||||
|
EmployerChangeSalary: item.EmployerChangeSalary === true,
|
||||||
|
// Суммы частей и переменную долю не читаем — они считаются (splitSalary).
|
||||||
|
EmployerChangeNewSalary: numberText(item.EmployerChangeNewSalary),
|
||||||
|
// Оклад не меняли (доли не сохранялись) — доля по умолчанию из ТЗ, 75 %.
|
||||||
|
EmployerChangeFixedPercent: numberText(item.EmployerChangeFixedPercent) || DEFAULT_FIXED_PERCENT,
|
||||||
|
EmployerChangeSalaryDate: spDateOnlyToIso(text(item.EmployerChangeSalaryDate)),
|
||||||
|
EmployerChangeJustification: text(item.EmployerChangeJustification),
|
||||||
|
TemporaryTransferFromProject: text(item.TemporaryTransferFromProject),
|
||||||
|
TemporaryTransferFromProjectId: text(item.TemporaryTransferFromProjectId),
|
||||||
|
TemporaryTransferFromWork: text(item.TemporaryTransferFromWork),
|
||||||
|
TemporaryTransferToProject: text(item.TemporaryTransferToProject),
|
||||||
|
TemporaryTransferToProjectId: text(item.TemporaryTransferToProjectId),
|
||||||
|
TemporaryTransferToWork: text(item.TemporaryTransferToWork),
|
||||||
|
TemporaryTransferStartDate: spDateOnlyToIso(text(item.TemporaryTransferStartDate)),
|
||||||
|
TemporaryTransferEndDate: spDateOnlyToIso(text(item.TemporaryTransferEndDate)),
|
||||||
|
TemporaryTransferReason: text(item.TemporaryTransferReason),
|
||||||
|
TemporaryTransferJustification: text(item.TemporaryTransferJustification)
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { RequestTypeId } from '../../models/types';
|
import { RequestTypeId } from '../../models/types';
|
||||||
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||||
import { ChangeType } from './changeTypes';
|
import { ChangeType, InitiatorRole, INITIATOR_EMPLOYEE, INITIATOR_EMPLOYER } from './changeTypes';
|
||||||
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
|
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
|
||||||
|
import { DEFAULT_FIXED_PERCENT } from './byType/EmployerInitiative/salaryParts';
|
||||||
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +34,12 @@ export interface IFormData {
|
|||||||
* задел под несколько, см. changeTypes.ts. [] — не выбран.
|
* задел под несколько, см. changeTypes.ts. [] — не выбран.
|
||||||
*/
|
*/
|
||||||
ChangeTypes: ChangeType[];
|
ChangeTypes: ChangeType[];
|
||||||
|
/**
|
||||||
|
* Инициатор — только у видов, где по ТЗ им может быть и работник
|
||||||
|
* (hasInitiatorChoice). От роли зависят поля: у работника — текст заявления,
|
||||||
|
* у работодателя — причины + обоснование. '' — не выбран / у вида выбора нет.
|
||||||
|
*/
|
||||||
|
ChangeInitiator: InitiatorRole | '';
|
||||||
|
|
||||||
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
|
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
|
||||||
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
|
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
|
||||||
@@ -74,6 +81,126 @@ 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;
|
||||||
|
|
||||||
|
// ── Вид «Изменение места работы (другая местность)» (byType/WorkplaceChange).
|
||||||
|
// Место работы = организация + проект. Текущая организация не хранится отдельно —
|
||||||
|
// это EmployeeCompany (выбранная в шапке). Обязательна при отправке только новая организация.
|
||||||
|
/** Текущий проект (справочник /Project, ProjectPicker) и его oid; '' — не выбран / введён вручную. */
|
||||||
|
WorkplaceChangeCurrentProject: string;
|
||||||
|
WorkplaceChangeCurrentProjectId: string;
|
||||||
|
/** Новая организация (справочник Dictionary/Companies): название и Id. */
|
||||||
|
WorkplaceChangeNewCompany: string;
|
||||||
|
WorkplaceChangeNewCompanyId: string;
|
||||||
|
/** Новый проект и его oid. */
|
||||||
|
WorkplaceChangeNewProject: string;
|
||||||
|
WorkplaceChangeNewProjectId: string;
|
||||||
|
/** Адрес нового места работы. */
|
||||||
|
WorkplaceChangeAddress: string;
|
||||||
|
/** С какой даты, ISO YYYY-MM-DD. */
|
||||||
|
WorkplaceChangeDate: string;
|
||||||
|
/** Причина — одно из WORKPLACE_CHANGE_REASONS (Choice-колонка); '' — не выбрана. */
|
||||||
|
WorkplaceChangeReason: string;
|
||||||
|
/** Условия переезда и компенсации (если переезд). */
|
||||||
|
WorkplaceChangeRelocation: string;
|
||||||
|
/** Вахта — тогда показывается график работы. */
|
||||||
|
WorkplaceChangeRotation: boolean;
|
||||||
|
/** График работы — одно из WORK_SCHEDULE_OPTIONS; при «Другое» текст — в WorkplaceChangeScheduleOther. */
|
||||||
|
WorkplaceChangeSchedule: string;
|
||||||
|
WorkplaceChangeScheduleOther: string;
|
||||||
|
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
|
||||||
|
WorkplaceChangeJustification: string;
|
||||||
|
|
||||||
|
// ── Вид «Изменение режима / графика работы» (byType/ScheduleChange). Поля по ролям
|
||||||
|
// строго по ТЗ (см. ScheduleChange/options.ts). Все необязательные; «Другое» → *Other.
|
||||||
|
/** Общее: текущий график и время работы (WORK_SCHEDULE_OPTIONS / WORK_HOURS_OPTIONS). */
|
||||||
|
ScheduleChangeCurrentSchedule: string;
|
||||||
|
ScheduleChangeCurrentScheduleOther: string;
|
||||||
|
ScheduleChangeCurrentHours: string;
|
||||||
|
ScheduleChangeCurrentHoursOther: string;
|
||||||
|
/** Общее: с какой даты, ISO YYYY-MM-DD. */
|
||||||
|
ScheduleChangeDate: string;
|
||||||
|
/** Работник: неполное рабочее время — часов в день и дней в неделю (строка из input type=number). */
|
||||||
|
ScheduleChangeHoursPerDay: string;
|
||||||
|
ScheduleChangeDaysPerWeek: string;
|
||||||
|
/** Работодатель: новый режим (SCHEDULE_CHANGE_NEW_MODES); при «Иной режим» — описание. */
|
||||||
|
ScheduleChangeNewMode: string;
|
||||||
|
ScheduleChangeNewModeOther: string;
|
||||||
|
/** Работодатель: новые график и время работы. */
|
||||||
|
ScheduleChangeNewSchedule: string;
|
||||||
|
ScheduleChangeNewScheduleOther: string;
|
||||||
|
ScheduleChangeNewHours: string;
|
||||||
|
ScheduleChangeNewHoursOther: string;
|
||||||
|
/** Работодатель: причина (SCHEDULE_CHANGE_REASONS). */
|
||||||
|
ScheduleChangeReason: string;
|
||||||
|
/** Работодатель — обоснование, работник — текст заявления. */
|
||||||
|
ScheduleChangeJustification: string;
|
||||||
|
|
||||||
|
// ── Вид «Изменение трудовой функции» (byType/FunctionChange). Только работодатель.
|
||||||
|
// Поля необязательные; вложение «Новая должностная инструкция» — обязательно (attachmentSlots).
|
||||||
|
/** С какой даты, ISO YYYY-MM-DD. */
|
||||||
|
FunctionChangeDate: string;
|
||||||
|
/** «В связи с ___ (текст)» из шаблона ТЗ — списка причин нет. */
|
||||||
|
FunctionChangeReason: string;
|
||||||
|
|
||||||
|
// ── Вид «Изменение условий труда по инициативе работодателя» (byType/EmployerInitiative).
|
||||||
|
// Только работодатель. Что меняется — флажки а) график / б) оклад (можно оба).
|
||||||
|
/** Причина — EMPLOYER_CHANGE_REASONS; при «Другое» — свой текст. */
|
||||||
|
EmployerChangeReason: string;
|
||||||
|
EmployerChangeReasonOther: string;
|
||||||
|
/** а) меняется график работы. */
|
||||||
|
EmployerChangeSchedule: boolean;
|
||||||
|
EmployerChangeCurrentSchedule: string;
|
||||||
|
EmployerChangeCurrentScheduleOther: string;
|
||||||
|
EmployerChangeNewSchedule: string;
|
||||||
|
EmployerChangeNewScheduleOther: string;
|
||||||
|
/** Не раньше чем через 15 дней (minEmployerChangeDate). ISO YYYY-MM-DD. */
|
||||||
|
EmployerChangeScheduleDate: string;
|
||||||
|
/** б) меняется должностной оклад. */
|
||||||
|
EmployerChangeSalary: boolean;
|
||||||
|
/**
|
||||||
|
* Новый оклад — «сырые» цифры (MoneyInput). Суммы частей и доля переменной в
|
||||||
|
* модели не храним — считаются (splitSalary); в SP пишутся при сохранении.
|
||||||
|
*/
|
||||||
|
EmployerChangeNewSalary: string;
|
||||||
|
/** Доля постоянной части, % (0–100, по умолчанию 75 из ТЗ); переменная — остаток до 100. */
|
||||||
|
EmployerChangeFixedPercent: string;
|
||||||
|
EmployerChangeSalaryDate: string;
|
||||||
|
/** Обоснование текстом — или документом (одно из двух обязательно при отправке). */
|
||||||
|
EmployerChangeJustification: string;
|
||||||
|
|
||||||
|
// ── Вид «Временный перевод» (byType/TemporaryTransfer). Только работодатель, поля необязательные.
|
||||||
|
/** Откуда: проект (справочник /Project) + его oid, работа — текстом. */
|
||||||
|
TemporaryTransferFromProject: string;
|
||||||
|
TemporaryTransferFromProjectId: string;
|
||||||
|
TemporaryTransferFromWork: string;
|
||||||
|
/** Куда: так же. */
|
||||||
|
TemporaryTransferToProject: string;
|
||||||
|
TemporaryTransferToProjectId: string;
|
||||||
|
TemporaryTransferToWork: string;
|
||||||
|
/** Период, ISO YYYY-MM-DD; не дольше 3 месяцев (maxTransferEndDate). */
|
||||||
|
TemporaryTransferStartDate: string;
|
||||||
|
TemporaryTransferEndDate: string;
|
||||||
|
/** Основание — TEMPORARY_TRANSFER_REASONS. */
|
||||||
|
TemporaryTransferReason: string;
|
||||||
|
TemporaryTransferJustification: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновление одного поля формы с сохранением типа значения. */
|
/** Обновление одного поля формы с сохранением типа значения. */
|
||||||
@@ -91,6 +218,7 @@ export interface ISectionProps {
|
|||||||
export const EMPTY_DATA: IFormData = {
|
export const EMPTY_DATA: IFormData = {
|
||||||
Employee: undefined,
|
Employee: undefined,
|
||||||
ChangeTypes: [],
|
ChangeTypes: [],
|
||||||
|
ChangeInitiator: '',
|
||||||
SalaryIncreaseCurrent: '',
|
SalaryIncreaseCurrent: '',
|
||||||
SalaryIncreaseNew: '',
|
SalaryIncreaseNew: '',
|
||||||
SalaryIncreaseDate: '',
|
SalaryIncreaseDate: '',
|
||||||
@@ -106,7 +234,67 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
PositionTransferNewPosition: '',
|
PositionTransferNewPosition: '',
|
||||||
PositionTransferDate: '',
|
PositionTransferDate: '',
|
||||||
PositionTransferReason: '',
|
PositionTransferReason: '',
|
||||||
PositionTransferJustification: ''
|
PositionTransferJustification: '',
|
||||||
|
DepartmentTransferNewDepartment: '',
|
||||||
|
DepartmentTransferNewDepartmentId: '',
|
||||||
|
DepartmentTransferDate: '',
|
||||||
|
DepartmentTransferReason: '',
|
||||||
|
DepartmentTransferJustification: '',
|
||||||
|
DepartmentTransferReceivingHead: '',
|
||||||
|
WorkplaceChangeCurrentProject: '',
|
||||||
|
WorkplaceChangeCurrentProjectId: '',
|
||||||
|
WorkplaceChangeNewCompany: '',
|
||||||
|
WorkplaceChangeNewCompanyId: '',
|
||||||
|
WorkplaceChangeNewProject: '',
|
||||||
|
WorkplaceChangeNewProjectId: '',
|
||||||
|
WorkplaceChangeAddress: '',
|
||||||
|
WorkplaceChangeDate: '',
|
||||||
|
WorkplaceChangeReason: '',
|
||||||
|
WorkplaceChangeRelocation: '',
|
||||||
|
WorkplaceChangeRotation: false,
|
||||||
|
WorkplaceChangeSchedule: '',
|
||||||
|
WorkplaceChangeScheduleOther: '',
|
||||||
|
WorkplaceChangeJustification: '',
|
||||||
|
ScheduleChangeCurrentSchedule: '',
|
||||||
|
ScheduleChangeCurrentScheduleOther: '',
|
||||||
|
ScheduleChangeCurrentHours: '',
|
||||||
|
ScheduleChangeCurrentHoursOther: '',
|
||||||
|
ScheduleChangeDate: '',
|
||||||
|
ScheduleChangeHoursPerDay: '',
|
||||||
|
ScheduleChangeDaysPerWeek: '',
|
||||||
|
ScheduleChangeNewMode: '',
|
||||||
|
ScheduleChangeNewModeOther: '',
|
||||||
|
ScheduleChangeNewSchedule: '',
|
||||||
|
ScheduleChangeNewScheduleOther: '',
|
||||||
|
ScheduleChangeNewHours: '',
|
||||||
|
ScheduleChangeNewHoursOther: '',
|
||||||
|
ScheduleChangeReason: '',
|
||||||
|
ScheduleChangeJustification: '',
|
||||||
|
FunctionChangeDate: '',
|
||||||
|
FunctionChangeReason: '',
|
||||||
|
EmployerChangeReason: '',
|
||||||
|
EmployerChangeReasonOther: '',
|
||||||
|
EmployerChangeSchedule: false,
|
||||||
|
EmployerChangeCurrentSchedule: '',
|
||||||
|
EmployerChangeCurrentScheduleOther: '',
|
||||||
|
EmployerChangeNewSchedule: '',
|
||||||
|
EmployerChangeNewScheduleOther: '',
|
||||||
|
EmployerChangeScheduleDate: '',
|
||||||
|
EmployerChangeSalary: false,
|
||||||
|
EmployerChangeNewSalary: '',
|
||||||
|
EmployerChangeFixedPercent: DEFAULT_FIXED_PERCENT,
|
||||||
|
EmployerChangeSalaryDate: '',
|
||||||
|
EmployerChangeJustification: '',
|
||||||
|
TemporaryTransferFromProject: '',
|
||||||
|
TemporaryTransferFromProjectId: '',
|
||||||
|
TemporaryTransferFromWork: '',
|
||||||
|
TemporaryTransferToProject: '',
|
||||||
|
TemporaryTransferToProjectId: '',
|
||||||
|
TemporaryTransferToWork: '',
|
||||||
|
TemporaryTransferStartDate: '',
|
||||||
|
TemporaryTransferEndDate: '',
|
||||||
|
TemporaryTransferReason: '',
|
||||||
|
TemporaryTransferJustification: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Плейсхолдер для поля выбора сотрудника. */
|
/** Плейсхолдер для поля выбора сотрудника. */
|
||||||
@@ -122,12 +310,40 @@ 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 INITIATOR_HINT =
|
||||||
|
'Подставляется автоматически: подаёте на себя — «Работник», за другого сотрудника — «Работодатель». Можно изменить.';
|
||||||
|
export const INITIATOR_REQUIRED_MESSAGE = 'Укажите инициатора изменения: работник или работодатель.';
|
||||||
|
/** Подпись поля текста у работника — вместо причин и обоснования (шаблон ТЗ «От работника: заявление»). */
|
||||||
|
export const EMPLOYEE_STATEMENT_LABEL = 'Текст заявления';
|
||||||
|
export const EMPLOYER_CHANGE_NOTHING_SELECTED_MESSAGE =
|
||||||
|
'Отметьте, что меняется: график работы и (или) должностной оклад.';
|
||||||
|
export const EMPLOYER_JUSTIFICATION_REQUIRED_MESSAGE =
|
||||||
|
'Укажите обоснование текстом или приложите документ-обоснование (приказ о реорганизации и т. п.).';
|
||||||
|
export const NEW_COMPANY_REQUIRED_MESSAGE = 'Выберите новую организацию — в какой организации будет новое место работы.';
|
||||||
|
export const RECEIVING_HEAD_REQUIRED_MESSAGE =
|
||||||
|
'Укажите принимающего руководителя — его согласие входит в маршрут перевода в другое подразделение.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной
|
* E-mail, по которому строится маршрут: сотрудника, а если у него нет учётной
|
||||||
* записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
|
* записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
|
||||||
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
|
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Инициатор по умолчанию (см. INITIATOR_HINT): подаёт на себя — работник, за
|
||||||
|
* другого — работодатель. Пользователь может поменять.
|
||||||
|
*/
|
||||||
|
export function defaultInitiatorRole(
|
||||||
|
person: IEmployeeFullBasePerson | undefined,
|
||||||
|
applicantEmail: string
|
||||||
|
): InitiatorRole {
|
||||||
|
// Сотрудник ещё не выбран/не подгрузился — новая заявка по умолчанию на себя.
|
||||||
|
if (!person) return INITIATOR_EMPLOYEE;
|
||||||
|
// У сотрудника нет учётной записи — это точно не сам заявитель.
|
||||||
|
if (!person.email) return INITIATOR_EMPLOYER;
|
||||||
|
return person.email.toLowerCase() === applicantEmail.toLowerCase() ? INITIATOR_EMPLOYEE : INITIATOR_EMPLOYER;
|
||||||
|
}
|
||||||
|
|
||||||
export function routeEmail(person: IEmployeeFullBasePerson | undefined, applicantEmail: string): string {
|
export function routeEmail(person: IEmployeeFullBasePerson | undefined, applicantEmail: string): string {
|
||||||
if (!person) return '';
|
if (!person) return '';
|
||||||
return person.email || applicantEmail;
|
return person.email || applicantEmail;
|
||||||
|
|||||||
@@ -20,11 +20,12 @@
|
|||||||
"Перевод в другое подразделение",
|
"Перевод в другое подразделение",
|
||||||
"Изменение места работы (другая местность)",
|
"Изменение места работы (другая местность)",
|
||||||
"Продление срока трудового договора",
|
"Продление срока трудового договора",
|
||||||
"Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника",
|
"Изменение режима / графика работы по соглашению сторон",
|
||||||
"Изменение трудовой функции (без смены должности)",
|
"Изменение трудовой функции (без смены должности)",
|
||||||
"Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)",
|
"Изменение условий труда по инициативе работодателя",
|
||||||
"Временный перевод при производственной необходимости / простое"
|
"Временный перевод при производственной необходимости / простое"
|
||||||
] },
|
] },
|
||||||
|
{ "internalName": "ChangeInitiator", "title": "Инициатор изменения", "type": "Choice", "choices": ["Работник", "Работодатель"] },
|
||||||
|
|
||||||
{ "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 },
|
{ "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 },
|
||||||
{ "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 },
|
{ "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 },
|
||||||
@@ -58,6 +59,90 @@
|
|||||||
"Открытие нового объекта / проекта, перераспределение функций",
|
"Открытие нового объекта / проекта, перераспределение функций",
|
||||||
"Иное"
|
"Иное"
|
||||||
] },
|
] },
|
||||||
{ "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" },
|
||||||
|
|
||||||
|
{ "internalName": "WorkplaceChangeCurrentProject", "title": "Место работы: текущий проект", "type": "Text" },
|
||||||
|
{ "internalName": "WorkplaceChangeCurrentProjectId", "title": "Место работы: id текущего проекта", "type": "Text" },
|
||||||
|
{ "internalName": "WorkplaceChangeNewCompany", "title": "Место работы: новая организация", "type": "Text" },
|
||||||
|
{ "internalName": "WorkplaceChangeNewCompanyId", "title": "Место работы: id новой организации", "type": "Text" },
|
||||||
|
{ "internalName": "WorkplaceChangeNewProject", "title": "Место работы: новый проект", "type": "Text" },
|
||||||
|
{ "internalName": "WorkplaceChangeNewProjectId", "title": "Место работы: id нового проекта", "type": "Text" },
|
||||||
|
{ "internalName": "WorkplaceChangeAddress", "title": "Место работы: адрес нового места", "type": "Note" },
|
||||||
|
{ "internalName": "WorkplaceChangeDate", "title": "Место работы: дата изменения", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "WorkplaceChangeReason", "title": "Место работы: причина", "type": "Choice", "choices": [
|
||||||
|
"Открытие нового объекта",
|
||||||
|
"Переезд подразделения / работодателя в другую местность",
|
||||||
|
"Иное"
|
||||||
|
] },
|
||||||
|
{ "internalName": "WorkplaceChangeRelocation", "title": "Место работы: условия переезда и компенсации", "type": "Note" },
|
||||||
|
{ "internalName": "WorkplaceChangeRotation", "title": "Место работы: вахта", "type": "Boolean" },
|
||||||
|
{ "internalName": "WorkplaceChangeSchedule", "title": "Место работы: график работы", "type": "Text" },
|
||||||
|
{ "internalName": "WorkplaceChangeScheduleOther", "title": "Место работы: график работы (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "WorkplaceChangeJustification", "title": "Место работы: обоснование", "type": "Note" },
|
||||||
|
|
||||||
|
{ "internalName": "ScheduleChangeCurrentSchedule", "title": "Режим работы: текущий график", "type": "Text" },
|
||||||
|
{ "internalName": "ScheduleChangeCurrentScheduleOther", "title": "Режим работы: текущий график (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "ScheduleChangeCurrentHours", "title": "Режим работы: текущее время работы", "type": "Text" },
|
||||||
|
{ "internalName": "ScheduleChangeCurrentHoursOther", "title": "Режим работы: текущее время работы (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "ScheduleChangeDate", "title": "Режим работы: дата начала", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "ScheduleChangeHoursPerDay", "title": "Режим работы: неполный рабочий день, часов", "type": "Number", "decimals": 1 },
|
||||||
|
{ "internalName": "ScheduleChangeDaysPerWeek", "title": "Режим работы: неполная рабочая неделя, дней", "type": "Number", "decimals": 0 },
|
||||||
|
{ "internalName": "ScheduleChangeNewMode", "title": "Режим работы: новый режим", "type": "Choice", "choices": ["Вахтовый метод", "Сменный график", "Иной режим"] },
|
||||||
|
{ "internalName": "ScheduleChangeNewModeOther", "title": "Режим работы: иной режим (описание)", "type": "Text" },
|
||||||
|
{ "internalName": "ScheduleChangeNewSchedule", "title": "Режим работы: новый график", "type": "Text" },
|
||||||
|
{ "internalName": "ScheduleChangeNewScheduleOther", "title": "Режим работы: новый график (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "ScheduleChangeNewHours", "title": "Режим работы: новое время работы", "type": "Text" },
|
||||||
|
{ "internalName": "ScheduleChangeNewHoursOther", "title": "Режим работы: новое время работы (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "ScheduleChangeReason", "title": "Режим работы: причина", "type": "Choice", "choices": ["Специфика объекта", "Смещение сроков проекта"] },
|
||||||
|
{ "internalName": "ScheduleChangeJustification", "title": "Режим работы: обоснование / текст заявления", "type": "Note" },
|
||||||
|
|
||||||
|
{ "internalName": "FunctionChangeDate", "title": "Трудовая функция: дата изменения", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "FunctionChangeReason", "title": "Трудовая функция: в связи с", "type": "Note" },
|
||||||
|
|
||||||
|
{ "internalName": "EmployerChangeReason", "title": "Инициатива работодателя: причина", "type": "Choice", "choices": [
|
||||||
|
"Экономические причины",
|
||||||
|
"Технологические причины",
|
||||||
|
"Изменения в организации",
|
||||||
|
"Сокращение объёма работ",
|
||||||
|
"Другое"
|
||||||
|
] },
|
||||||
|
{ "internalName": "EmployerChangeReasonOther", "title": "Инициатива работодателя: причина (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "EmployerChangeSchedule", "title": "Инициатива работодателя: меняется график", "type": "Boolean" },
|
||||||
|
{ "internalName": "EmployerChangeCurrentSchedule", "title": "Инициатива работодателя: текущий график", "type": "Text" },
|
||||||
|
{ "internalName": "EmployerChangeCurrentScheduleOther", "title": "Инициатива работодателя: текущий график (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "EmployerChangeNewSchedule", "title": "Инициатива работодателя: новый график", "type": "Text" },
|
||||||
|
{ "internalName": "EmployerChangeNewScheduleOther", "title": "Инициатива работодателя: новый график (другое)", "type": "Text" },
|
||||||
|
{ "internalName": "EmployerChangeScheduleDate", "title": "Инициатива работодателя: дата изменения графика", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "EmployerChangeSalary", "title": "Инициатива работодателя: меняется оклад", "type": "Boolean" },
|
||||||
|
{ "internalName": "EmployerChangeNewSalary", "title": "Инициатива работодателя: новый оклад", "type": "Number", "decimals": 0 },
|
||||||
|
{ "internalName": "EmployerChangeFixedPercent", "title": "Инициатива работодателя: постоянная часть, %", "type": "Number", "decimals": 2 },
|
||||||
|
{ "internalName": "EmployerChangeVariablePercent", "title": "Инициатива работодателя: переменная часть, %", "type": "Number", "decimals": 2 },
|
||||||
|
{ "internalName": "EmployerChangeFixedPart", "title": "Инициатива работодателя: постоянная часть, ₸", "type": "Number", "decimals": 0 },
|
||||||
|
{ "internalName": "EmployerChangeVariablePart", "title": "Инициатива работодателя: переменная часть, ₸", "type": "Number", "decimals": 0 },
|
||||||
|
{ "internalName": "EmployerChangeSalaryDate", "title": "Инициатива работодателя: дата изменения оклада", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "EmployerChangeJustification", "title": "Инициатива работодателя: обоснование", "type": "Note" },
|
||||||
|
|
||||||
|
{ "internalName": "TemporaryTransferFromProject", "title": "Временный перевод: откуда — проект", "type": "Text" },
|
||||||
|
{ "internalName": "TemporaryTransferFromProjectId", "title": "Временный перевод: откуда — id проекта", "type": "Text" },
|
||||||
|
{ "internalName": "TemporaryTransferFromWork", "title": "Временный перевод: откуда — работа", "type": "Text" },
|
||||||
|
{ "internalName": "TemporaryTransferToProject", "title": "Временный перевод: куда — проект", "type": "Text" },
|
||||||
|
{ "internalName": "TemporaryTransferToProjectId", "title": "Временный перевод: куда — id проекта", "type": "Text" },
|
||||||
|
{ "internalName": "TemporaryTransferToWork", "title": "Временный перевод: куда — работа", "type": "Text" },
|
||||||
|
{ "internalName": "TemporaryTransferStartDate", "title": "Временный перевод: дата начала", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "TemporaryTransferEndDate", "title": "Временный перевод: дата окончания", "type": "DateTime", "dateOnly": true },
|
||||||
|
{ "internalName": "TemporaryTransferReason", "title": "Временный перевод: основание", "type": "Choice", "choices": ["Производственная необходимость", "Простой"] },
|
||||||
|
{ "internalName": "TemporaryTransferJustification", "title": "Временный перевод: обоснование", "type": "Note" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,21 @@ import {
|
|||||||
personFromWorkplace,
|
personFromWorkplace,
|
||||||
workplaceKey
|
workplaceKey
|
||||||
} from '../../../shared/dictionary';
|
} from '../../../shared/dictionary';
|
||||||
import { ChangeType, CHANGE_TYPE_OPTIONS } from '../changeTypes';
|
import {
|
||||||
|
ChangeType,
|
||||||
|
CHANGE_TYPE_OPTIONS,
|
||||||
|
INITIATOR_OPTIONS,
|
||||||
|
InitiatorRole,
|
||||||
|
hasInitiatorChoice,
|
||||||
|
initiatorRoleFromText
|
||||||
|
} from '../changeTypes';
|
||||||
import {
|
import {
|
||||||
ISectionProps,
|
ISectionProps,
|
||||||
EMPLOYEE_PLACEHOLDER,
|
EMPLOYEE_PLACEHOLDER,
|
||||||
WORKPLACE_PLACEHOLDER,
|
WORKPLACE_PLACEHOLDER,
|
||||||
CHANGE_TYPE_PLACEHOLDER,
|
CHANGE_TYPE_PLACEHOLDER,
|
||||||
ROUTE_BY_APPLICANT_NOTE
|
ROUTE_BY_APPLICANT_NOTE,
|
||||||
|
INITIATOR_HINT
|
||||||
} from '../model';
|
} from '../model';
|
||||||
import { IContractEmployee } from '../useContractEmployee';
|
import { IContractEmployee } from '../useContractEmployee';
|
||||||
import styles from './RequestSection.module.scss';
|
import styles from './RequestSection.module.scss';
|
||||||
@@ -21,6 +29,13 @@ import styles from './RequestSection.module.scss';
|
|||||||
interface IRequestSectionProps extends ISectionProps {
|
interface IRequestSectionProps extends ISectionProps {
|
||||||
/** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */
|
/** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */
|
||||||
employeeControl?: IContractEmployee;
|
employeeControl?: IContractEmployee;
|
||||||
|
/**
|
||||||
|
* Инициатор по умолчанию при выборе вида с выбором инициатора: подаёт на себя —
|
||||||
|
* работник, за другого — работодатель (см. ContractChangeForm). В просмотре не нужен.
|
||||||
|
*/
|
||||||
|
defaultInitiator?: InitiatorRole;
|
||||||
|
/** Ручной выбор инициатора (см. useInitiatorAutofill); не задан — просто set. */
|
||||||
|
onInitiatorChange?: (role: InitiatorRole | '') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOOP = (): void => undefined;
|
const NOOP = (): void => undefined;
|
||||||
@@ -30,7 +45,14 @@ const NOOP = (): void => undefined;
|
|||||||
* в какой организации (у совместителя их несколько) и что именно меняется.
|
* в какой организации (у совместителя их несколько) и что именно меняется.
|
||||||
* Поля конкретных видов — отдельными секциями (byType/).
|
* Поля конкретных видов — отдельными секциями (byType/).
|
||||||
*/
|
*/
|
||||||
export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, context, employeeControl }) => {
|
export const RequestSection: React.FC<IRequestSectionProps> = ({
|
||||||
|
data,
|
||||||
|
set,
|
||||||
|
context,
|
||||||
|
employeeControl,
|
||||||
|
defaultInitiator,
|
||||||
|
onInitiatorChange
|
||||||
|
}) => {
|
||||||
const readOnly = useFormReadOnly();
|
const readOnly = useFormReadOnly();
|
||||||
// В просмотре человека нет в состоянии — собираем из сохранённого места работы.
|
// В просмотре человека нет в состоянии — собираем из сохранённого места работы.
|
||||||
const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined);
|
const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined);
|
||||||
@@ -45,7 +67,12 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, cont
|
|||||||
// встанет MultiChoiceField, модель и SP уже готовы.
|
// встанет MultiChoiceField, модель и SP уже готовы.
|
||||||
const selectedType = data.ChangeTypes[0] || '';
|
const selectedType = data.ChangeTypes[0] || '';
|
||||||
const handleTypeChange = (value: string): void => {
|
const handleTypeChange = (value: string): void => {
|
||||||
set('ChangeTypes', value ? [value as ChangeType] : []);
|
const types: ChangeType[] = value ? [value as ChangeType] : [];
|
||||||
|
set('ChangeTypes', types);
|
||||||
|
// Инициатор есть только у видов с выбором: у остальных очищаем, у этих —
|
||||||
|
// подставляем по умолчанию, если ещё не выбран.
|
||||||
|
if (!hasInitiatorChoice(types)) set('ChangeInitiator', '');
|
||||||
|
else if (!data.ChangeInitiator && defaultInitiator) set('ChangeInitiator', defaultInitiator);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,6 +109,22 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, cont
|
|||||||
placeholder={CHANGE_TYPE_PLACEHOLDER}
|
placeholder={CHANGE_TYPE_PLACEHOLDER}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{hasInitiatorChoice(data.ChangeTypes) && (
|
||||||
|
<FormField label="Инициатор" htmlFor="ChangeInitiator" required>
|
||||||
|
<Select
|
||||||
|
id="ChangeInitiator"
|
||||||
|
value={data.ChangeInitiator}
|
||||||
|
onChange={(v) => {
|
||||||
|
const role = initiatorRoleFromText(v);
|
||||||
|
if (onInitiatorChange) onInitiatorChange(role);
|
||||||
|
else set('ChangeInitiator', role);
|
||||||
|
}}
|
||||||
|
options={INITIATOR_OPTIONS}
|
||||||
|
placeholder="— выберите —"
|
||||||
|
/>
|
||||||
|
{!readOnly && <div className={styles.note}>{INITIATOR_HINT}</div>}
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
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 { CHANGE_TYPE_LABELS } from './changeTypes';
|
import { ensureEmployee } from '../../shared/employee';
|
||||||
|
import { CHANGE_TYPE_LABELS, INITIATOR_EMPLOYEE, hasInitiatorChoice } from './changeTypes';
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
import { splitSalary } from './byType/EmployerInitiative/salaryParts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||||
@@ -41,6 +43,16 @@ function employeeColumns(employee: IEmployeeFullBaseRecord | undefined): Record<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Причина из списка — только у работодателя: у работника по ТЗ вместо причин
|
||||||
|
* текст заявления, и списка причин на форме нет. Иначе в заявке осталась бы
|
||||||
|
* причина, выбранная до смены инициатора, которую никто не видит.
|
||||||
|
*/
|
||||||
|
function employerReason(data: IFormData, reason: string): string | undefined {
|
||||||
|
if (data.ChangeInitiator === INITIATOR_EMPLOYEE) return undefined;
|
||||||
|
return reason || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */
|
/** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */
|
||||||
function numberColumn(raw: string): number | undefined {
|
function numberColumn(raw: string): number | undefined {
|
||||||
const amount = Number(raw);
|
const amount = Number(raw);
|
||||||
@@ -108,25 +120,185 @@ function positionTransferColumns(data: IFormData): Record<string, unknown> {
|
|||||||
return {
|
return {
|
||||||
PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null,
|
PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null,
|
||||||
PositionTransferDate: data.PositionTransferDate || null,
|
PositionTransferDate: data.PositionTransferDate || null,
|
||||||
PositionTransferReason: data.PositionTransferReason || null,
|
PositionTransferReason: employerReason(data, data.PositionTransferReason) ?? null,
|
||||||
PositionTransferJustification: data.PositionTransferJustification || null
|
PositionTransferJustification: data.PositionTransferJustification || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Колонки вида «Перевод в другое подразделение»; вид не выбран — очищаются.
|
||||||
|
* 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: employerReason(data, data.DepartmentTransferReason) ?? null,
|
||||||
|
DepartmentTransferJustification: data.DepartmentTransferJustification || null,
|
||||||
|
DepartmentTransferReceivingHeadId: receivingHeadId ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Колонки вида «Изменение места работы»; вид не выбран — очищаются, график — только при вахте. */
|
||||||
|
function workplaceChangeColumns(data: IFormData): Record<string, unknown> {
|
||||||
|
const isSelected = data.ChangeTypes.includes('workplace-change');
|
||||||
|
const fields = keepOrClear(isSelected, {
|
||||||
|
WorkplaceChangeCurrentProject: data.WorkplaceChangeCurrentProject.trim(),
|
||||||
|
WorkplaceChangeCurrentProjectId: data.WorkplaceChangeCurrentProjectId,
|
||||||
|
WorkplaceChangeNewCompany: data.WorkplaceChangeNewCompany,
|
||||||
|
WorkplaceChangeNewCompanyId: data.WorkplaceChangeNewCompanyId,
|
||||||
|
WorkplaceChangeNewProject: data.WorkplaceChangeNewProject.trim(),
|
||||||
|
WorkplaceChangeNewProjectId: data.WorkplaceChangeNewProjectId,
|
||||||
|
WorkplaceChangeAddress: data.WorkplaceChangeAddress.trim(),
|
||||||
|
WorkplaceChangeDate: data.WorkplaceChangeDate,
|
||||||
|
WorkplaceChangeReason: employerReason(data, data.WorkplaceChangeReason),
|
||||||
|
WorkplaceChangeRelocation: data.WorkplaceChangeRelocation,
|
||||||
|
WorkplaceChangeRotation: data.WorkplaceChangeRotation,
|
||||||
|
WorkplaceChangeJustification: data.WorkplaceChangeJustification
|
||||||
|
});
|
||||||
|
// Без вахты графика нет, даже если он остался в состоянии формы.
|
||||||
|
const rotationFields = keepOrClear(isSelected && data.WorkplaceChangeRotation, {
|
||||||
|
WorkplaceChangeSchedule: data.WorkplaceChangeSchedule,
|
||||||
|
WorkplaceChangeScheduleOther: data.WorkplaceChangeScheduleOther.trim()
|
||||||
|
});
|
||||||
|
return { ...fields, ...rotationFields };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Колонки вида «Изменение режима / графика работы». Поля другой роли очищаются:
|
||||||
|
* у работника нет нового режима и причин, у работодателя — часов/дней неполного времени.
|
||||||
|
*/
|
||||||
|
function scheduleChangeColumns(data: IFormData): Record<string, unknown> {
|
||||||
|
const isSelected = data.ChangeTypes.includes('schedule-change');
|
||||||
|
const isEmployee = data.ChangeInitiator === INITIATOR_EMPLOYEE;
|
||||||
|
|
||||||
|
const common = keepOrClear(isSelected, {
|
||||||
|
ScheduleChangeCurrentSchedule: data.ScheduleChangeCurrentSchedule,
|
||||||
|
ScheduleChangeCurrentScheduleOther: data.ScheduleChangeCurrentScheduleOther.trim(),
|
||||||
|
ScheduleChangeCurrentHours: data.ScheduleChangeCurrentHours,
|
||||||
|
ScheduleChangeCurrentHoursOther: data.ScheduleChangeCurrentHoursOther.trim(),
|
||||||
|
ScheduleChangeDate: data.ScheduleChangeDate,
|
||||||
|
ScheduleChangeJustification: data.ScheduleChangeJustification
|
||||||
|
});
|
||||||
|
const employeeFields = keepOrClear(isSelected && isEmployee, {
|
||||||
|
ScheduleChangeHoursPerDay: numberColumn(data.ScheduleChangeHoursPerDay),
|
||||||
|
ScheduleChangeDaysPerWeek: numberColumn(data.ScheduleChangeDaysPerWeek)
|
||||||
|
});
|
||||||
|
const employerFields = keepOrClear(isSelected && !isEmployee, {
|
||||||
|
ScheduleChangeNewMode: data.ScheduleChangeNewMode,
|
||||||
|
ScheduleChangeNewModeOther: data.ScheduleChangeNewModeOther.trim(),
|
||||||
|
ScheduleChangeNewSchedule: data.ScheduleChangeNewSchedule,
|
||||||
|
ScheduleChangeNewScheduleOther: data.ScheduleChangeNewScheduleOther.trim(),
|
||||||
|
ScheduleChangeNewHours: data.ScheduleChangeNewHours,
|
||||||
|
ScheduleChangeNewHoursOther: data.ScheduleChangeNewHoursOther.trim(),
|
||||||
|
ScheduleChangeReason: data.ScheduleChangeReason
|
||||||
|
});
|
||||||
|
return { ...common, ...employeeFields, ...employerFields };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группа колонок: keep — значения как есть (пустые '' / undefined → null; false
|
||||||
|
* остаётся false), иначе все → null, чтобы SharePoint очистил поля, которых при
|
||||||
|
* текущем виде/роли на форме нет.
|
||||||
|
*/
|
||||||
|
function keepOrClear(
|
||||||
|
keep: boolean,
|
||||||
|
columns: Record<string, string | number | boolean | undefined>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
Object.keys(columns).forEach((key) => {
|
||||||
|
const value = columns[key];
|
||||||
|
const isEmpty = value === undefined || value === '';
|
||||||
|
result[key] = keep && !isEmpty ? value : null;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Колонки вида «Инициатива работодателя»: общие + группа графика (флажок а) +
|
||||||
|
* группа оклада (флажок б). Переменная доля и суммы частей считаются здесь же (splitSalary).
|
||||||
|
*/
|
||||||
|
function employerInitiativeColumns(data: IFormData): Record<string, unknown> {
|
||||||
|
const isSelected = data.ChangeTypes.includes('employer-initiative');
|
||||||
|
const parts = splitSalary(data.EmployerChangeNewSalary, data.EmployerChangeFixedPercent);
|
||||||
|
const common = keepOrClear(isSelected, {
|
||||||
|
EmployerChangeReason: data.EmployerChangeReason,
|
||||||
|
EmployerChangeReasonOther: data.EmployerChangeReasonOther.trim(),
|
||||||
|
EmployerChangeSchedule: data.EmployerChangeSchedule,
|
||||||
|
EmployerChangeSalary: data.EmployerChangeSalary,
|
||||||
|
EmployerChangeJustification: data.EmployerChangeJustification
|
||||||
|
});
|
||||||
|
const scheduleFields = keepOrClear(isSelected && data.EmployerChangeSchedule, {
|
||||||
|
EmployerChangeCurrentSchedule: data.EmployerChangeCurrentSchedule,
|
||||||
|
EmployerChangeCurrentScheduleOther: data.EmployerChangeCurrentScheduleOther.trim(),
|
||||||
|
EmployerChangeNewSchedule: data.EmployerChangeNewSchedule,
|
||||||
|
EmployerChangeNewScheduleOther: data.EmployerChangeNewScheduleOther.trim(),
|
||||||
|
EmployerChangeScheduleDate: data.EmployerChangeScheduleDate
|
||||||
|
});
|
||||||
|
const salaryFields = keepOrClear(isSelected && data.EmployerChangeSalary, {
|
||||||
|
EmployerChangeNewSalary: numberColumn(data.EmployerChangeNewSalary),
|
||||||
|
EmployerChangeFixedPercent: numberColumn(data.EmployerChangeFixedPercent),
|
||||||
|
EmployerChangeVariablePercent: parts?.variablePercent,
|
||||||
|
EmployerChangeFixedPart: parts?.fixed,
|
||||||
|
EmployerChangeVariablePart: parts?.variable,
|
||||||
|
EmployerChangeSalaryDate: data.EmployerChangeSalaryDate
|
||||||
|
});
|
||||||
|
return { ...common, ...scheduleFields, ...salaryFields };
|
||||||
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы. */
|
/** Поля элемента списка данных из модели формы. */
|
||||||
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,
|
||||||
...employeeColumns(data.Employee),
|
...employeeColumns(data.Employee),
|
||||||
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
|
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
|
||||||
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
|
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
|
||||||
|
ChangeInitiator: hasInitiatorChoice(data.ChangeTypes) ? data.ChangeInitiator || null : null,
|
||||||
...salaryIncreaseColumns(data),
|
...salaryIncreaseColumns(data),
|
||||||
...allowanceColumns(data),
|
...allowanceColumns(data),
|
||||||
...positionTransferColumns(data)
|
...positionTransferColumns(data),
|
||||||
|
...departmentTransferColumns(data, receivingHeadId),
|
||||||
|
...workplaceChangeColumns(data),
|
||||||
|
...scheduleChangeColumns(data),
|
||||||
|
...keepOrClear(data.ChangeTypes.includes('function-change'), {
|
||||||
|
FunctionChangeDate: data.FunctionChangeDate,
|
||||||
|
FunctionChangeReason: data.FunctionChangeReason
|
||||||
|
}),
|
||||||
|
...employerInitiativeColumns(data),
|
||||||
|
...keepOrClear(data.ChangeTypes.includes('temporary-transfer'), {
|
||||||
|
TemporaryTransferFromProject: data.TemporaryTransferFromProject.trim(),
|
||||||
|
TemporaryTransferFromProjectId: data.TemporaryTransferFromProjectId,
|
||||||
|
TemporaryTransferFromWork: data.TemporaryTransferFromWork.trim(),
|
||||||
|
TemporaryTransferToProject: data.TemporaryTransferToProject.trim(),
|
||||||
|
TemporaryTransferToProjectId: data.TemporaryTransferToProjectId,
|
||||||
|
TemporaryTransferToWork: data.TemporaryTransferToWork.trim(),
|
||||||
|
TemporaryTransferStartDate: data.TemporaryTransferStartDate,
|
||||||
|
TemporaryTransferEndDate: data.TemporaryTransferEndDate,
|
||||||
|
TemporaryTransferReason: data.TemporaryTransferReason,
|
||||||
|
TemporaryTransferJustification: data.TemporaryTransferJustification
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 +318,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
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { IEmployeeFullBasePerson } from '../../shared/dictionary';
|
||||||
|
import { InitiatorRole, hasInitiatorChoice } from './changeTypes';
|
||||||
|
import { IFormData, SetField, defaultInitiatorRole } from './model';
|
||||||
|
import { IContractEmployee } from './useContractEmployee';
|
||||||
|
|
||||||
|
/** Пропсы шапки (RequestSection), связанные с сотрудником и инициатором. */
|
||||||
|
export interface IInitiatorAutofill {
|
||||||
|
/** Тот же выбор сотрудника, но со сменой инициатора вслед за сотрудником. */
|
||||||
|
employeeControl: IContractEmployee;
|
||||||
|
/** Инициатор по умолчанию при выборе вида — для текущего сотрудника. */
|
||||||
|
defaultInitiator: InitiatorRole;
|
||||||
|
/** Ручной выбор инициатора — после него автоподстановка больше не вмешивается. */
|
||||||
|
onInitiatorChange: (role: InitiatorRole | '') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициатор следует за сотрудником, пока его не выбрали руками: сменили
|
||||||
|
* сотрудника на себя — «Работник», на другого — «Работодатель» (см.
|
||||||
|
* defaultInitiatorRole). Без этого инициатор, подставленный при выборе вида,
|
||||||
|
* оставался бы прежним после смены сотрудника.
|
||||||
|
*
|
||||||
|
* У открытой заявки (isEdit) инициатор уже сохранён — считаем его выбранным
|
||||||
|
* руками и не перебиваем.
|
||||||
|
*/
|
||||||
|
export function useInitiatorAutofill(
|
||||||
|
isEdit: boolean,
|
||||||
|
data: IFormData,
|
||||||
|
set: SetField,
|
||||||
|
applicantEmail: string,
|
||||||
|
employeeControl: IContractEmployee
|
||||||
|
): IInitiatorAutofill {
|
||||||
|
const isChosenByHandRef = useRef(isEdit);
|
||||||
|
|
||||||
|
const onPersonChange = (person: IEmployeeFullBasePerson | undefined): void => {
|
||||||
|
employeeControl.onPersonChange(person);
|
||||||
|
if (!isChosenByHandRef.current && hasInitiatorChoice(data.ChangeTypes)) {
|
||||||
|
set('ChangeInitiator', defaultInitiatorRole(person, applicantEmail));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInitiatorChange = (role: InitiatorRole | ''): void => {
|
||||||
|
isChosenByHandRef.current = true;
|
||||||
|
set('ChangeInitiator', role);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
employeeControl: { ...employeeControl, onPersonChange },
|
||||||
|
defaultInitiator: defaultInitiatorRole(employeeControl.person, applicantEmail),
|
||||||
|
onInitiatorChange
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
RequestNumber,
|
RequestNumber,
|
||||||
SubmitActions,
|
SubmitActions,
|
||||||
getDraftDeleteHandler,
|
getDraftDeleteHandler,
|
||||||
useRequestSubmit
|
useRequestSubmit,
|
||||||
|
FormNotice
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
composeRemoveRowRule,
|
composeRemoveRowRule,
|
||||||
@@ -44,7 +45,16 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, IOrganizationRecord, primaryPosition } from './model';
|
import {
|
||||||
|
FORM_ID,
|
||||||
|
IFormData,
|
||||||
|
EMPTY_DATA,
|
||||||
|
SetField,
|
||||||
|
IOrganizationRecord,
|
||||||
|
primaryPosition,
|
||||||
|
NOTICE_TITLE,
|
||||||
|
NOTICE_ITEMS
|
||||||
|
} from './model';
|
||||||
import { saveDismissalRequest, dismissalApprovalService } from './submit';
|
import { saveDismissalRequest, dismissalApprovalService } from './submit';
|
||||||
import { loadDismissalRequest } from './load';
|
import { loadDismissalRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -374,6 +384,8 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
|
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './DismissalView.module.scss';
|
import styles from './DismissalView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
|
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, FormNotice, buildPrintTitle } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { parseRoute } from '../../shared/routing';
|
import { parseRoute } from '../../shared/routing';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||||
import { loadDismissalRequest } from './load';
|
import { loadDismissalRequest } from './load';
|
||||||
import { dismissalApprovalService, updateAssetsAcceptor } from './submit';
|
import { dismissalApprovalService, updateAssetsAcceptor } from './submit';
|
||||||
import { loadClearanceRequest } from '../Clearance/load';
|
import { loadClearanceRequest } from '../Clearance/load';
|
||||||
@@ -58,6 +58,8 @@ interface IDismissalViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявление на увольнение';
|
||||||
|
|
||||||
// Заявление на увольнение только для просмотра (режим согласующего).
|
// Заявление на увольнение только для просмотра (режим согласующего).
|
||||||
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
|
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
@@ -70,6 +72,8 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
// ФИО увольняющегося — для имени печатного документа.
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Итог автосоздания обходного листа после согласования (см. onDecided ниже).
|
// Итог автосоздания обходного листа после согласования (см. onDecided ниже).
|
||||||
@@ -107,6 +111,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -265,7 +270,9 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
{printGate === 'allowed' ? (
|
{printGate === 'allowed' ? (
|
||||||
<PrintButton />
|
<PrintButton
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению">
|
<span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению">
|
||||||
Печать станет доступна после согласования обходного листа
|
Печать станет доступна после согласования обходного листа
|
||||||
@@ -275,7 +282,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявление на увольнение"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
@@ -285,6 +292,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
|
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
|
|||||||
- **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения
|
- **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения
|
||||||
отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»).
|
отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»).
|
||||||
- **Заголовок элемента** (`Title`): `Заявление на увольнение — {ФИО} — дд.мм.гггг`.
|
- **Заголовок элемента** (`Title`): `Заявление на увольнение — {ФИО} — дд.мм.гггг`.
|
||||||
|
- **Примечание** (с 10-29, `FormNotice`, тексты `NOTICE_TITLE`/`NOTICE_ITEMS` в `model.ts`) — плашка
|
||||||
|
вверху формы и просмотра про срок уведомления по ст. 56 ТК РК. Текст от HR, дословно; на печать не идёт.
|
||||||
|
|
||||||
## Совместители: организации таблицей, удаление синхронизировано с маршрутом
|
## Совместители: организации таблицей, удаление синхронизировано с маршрутом
|
||||||
Сотрудник может числиться сразу в НЕСКОЛЬКИХ компаниях (совместительство).
|
Сотрудник может числиться сразу в НЕСКОЛЬКИХ компаниях (совместительство).
|
||||||
|
|||||||
@@ -149,3 +149,18 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||||
/** Заявление нужно подавать не позднее чем за столько месяцев до последнего рабочего дня. */
|
/** Заявление нужно подавать не позднее чем за столько месяцев до последнего рабочего дня. */
|
||||||
export const MIN_NOTICE_MONTHS = 1;
|
export const MIN_NOTICE_MONTHS = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Примечание вверху формы и просмотра (см. shared/ui/FormNotice). Текст — от HR,
|
||||||
|
* дословно. «Не менее чем за один месяц» — это MIN_NOTICE_MONTHS: поменяли срок —
|
||||||
|
* поправьте и текст.
|
||||||
|
*/
|
||||||
|
export const NOTICE_TITLE = 'Примечание';
|
||||||
|
export const NOTICE_ITEMS: string[] = [
|
||||||
|
'Срок уведомления. Согласно п. 1 ст. 56 Трудового кодекса РК, работник вправе по своей инициативе ' +
|
||||||
|
'расторгнуть трудовой договор, уведомив работодателя письменно не менее чем за один месяц, за исключением ' +
|
||||||
|
'случаев, предусмотренных п. 3 этой статьи. Если трудовым договором установлен более длительный срок ' +
|
||||||
|
'уведомления, применяется срок из трудового договора.',
|
||||||
|
'Увольнение до истечения срока уведомления. Дата увольнения раньше чем через месяц с даты подачи заявления ' +
|
||||||
|
'возможна только по согласованию с работодателем, либо в случаях, предусмотренных п. 3 ст. 56 ТК РК.'
|
||||||
|
];
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './EmployeeTransferView.module.scss';
|
import styles from './EmployeeTransferView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
buildPrintTitle
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -41,6 +49,8 @@ interface IEmployeeTransferViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявка на перемещение сотрудника';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
const NOOP_SET: SetField = () => undefined;
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
@@ -56,6 +66,8 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
// ФИО перемещаемого — для имени печатного документа.
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
||||||
@@ -89,6 +101,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -136,13 +149,18 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на перемещение сотрудника"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
PrintButton,
|
PrintButton,
|
||||||
FieldChangesProvider,
|
FieldChangesProvider,
|
||||||
FieldChangesBanner,
|
FieldChangesBanner,
|
||||||
issueLabels
|
issueLabels,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { HIRE_CHANGE_TRACKING } from './changeTracking';
|
import { HIRE_CHANGE_TRACKING } from './changeTracking';
|
||||||
@@ -87,6 +88,7 @@ const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.
|
|||||||
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
|
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
|
||||||
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
|
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
|
||||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||||
|
const FORM_TITLE = 'Заявка на прием';
|
||||||
|
|
||||||
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
|
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
|
||||||
// те же секции, что и форма подачи; редактирование выключено через
|
// те же секции, что и форма подачи; редактирование выключено через
|
||||||
@@ -382,7 +384,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
<Icon name="copy" size="sm" />
|
<Icon name="copy" size="sm" />
|
||||||
Организация рабочего места на основе этой
|
Организация рабочего места на основе этой
|
||||||
</Button>
|
</Button>
|
||||||
{isApprovedStatus(status) && <PrintButton />}
|
{isApprovedStatus(status) && (
|
||||||
|
<PrintButton
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.HireCandidateName, code, itemId })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasEditableFields && (
|
{hasEditableFields && (
|
||||||
@@ -400,7 +406,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на прием"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
@@ -452,6 +458,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
|
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
|
printable={false}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
PrintButton,
|
PrintButton,
|
||||||
FieldChangesProvider,
|
FieldChangesProvider,
|
||||||
FieldChangesBanner
|
FieldChangesBanner,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { SELECTION_CHANGE_TRACKING } from './changeTracking';
|
import { SELECTION_CHANGE_TRACKING } from './changeTracking';
|
||||||
@@ -80,6 +81,7 @@ interface IRecruitingSelectionViewProps {
|
|||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||||
|
const FORM_TITLE = 'Заявка на подбор';
|
||||||
|
|
||||||
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
||||||
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
|
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
|
||||||
@@ -366,7 +368,10 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
{isApprovedStatus(status) && <PrintButton />}
|
{/* Подбор — на вакансию, конкретного человека ещё нет: имя файла без ФИО. */}
|
||||||
|
{isApprovedStatus(status) && (
|
||||||
|
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: '', code, itemId })} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fullEditable && (
|
{fullEditable && (
|
||||||
@@ -384,7 +389,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на подбор"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
@@ -438,6 +443,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
|
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
|
printable={false}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
AttachmentList,
|
AttachmentList,
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead
|
OrgLetterhead,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
@@ -60,6 +61,8 @@ interface IReferralViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Реферальная рекомендация';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
const NOOP_SET: SetField = () => undefined;
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
@@ -189,14 +192,19 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
{isApprovedStatus(status) && (
|
{isApprovedStatus(status) && (
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.CandidateName, code, itemId })}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Реферальная рекомендация"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
@@ -234,6 +242,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
|
printable={false}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
Icon
|
Icon,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +58,8 @@ interface IServiceRequestViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявка на услуги';
|
||||||
|
|
||||||
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
|
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
|
||||||
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
|
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
|
||||||
@@ -166,13 +169,18 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
|||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.RequesterName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на услуги"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './SkudExplanationView.module.scss';
|
import styles from './SkudExplanationView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
buildPrintTitle
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -130,7 +138,12 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.EmployeeFio, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormCard
|
<FormCard
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ interface ISkudRouteTablesProps {
|
|||||||
/**
|
/**
|
||||||
* Маршрут согласования + исполнение (если исполнители есть). Рисуется дважды:
|
* Маршрут согласования + исполнение (если исполнители есть). Рисуется дважды:
|
||||||
* на экране под карточкой и внутри печатного блока под запиской — на печати
|
* на экране под карточкой и внутри печатного блока под запиской — на печати
|
||||||
* таблица маршрута заменяет подпись сотрудника.
|
* таблица маршрута заменяет подпись сотрудника. Исполнение на печать не идёт
|
||||||
|
* (printable={false}), как во всех формах, — только согласование.
|
||||||
*/
|
*/
|
||||||
export const SkudRouteTables: React.FC<ISkudRouteTablesProps> = ({ approvers, executors, search }) => {
|
export const SkudRouteTables: React.FC<ISkudRouteTablesProps> = ({ approvers, executors, search }) => {
|
||||||
const hasExecutors = !executors.loading && executors.rows.length > 0;
|
const hasExecutors = !executors.loading && executors.rows.length > 0;
|
||||||
@@ -26,6 +27,7 @@ export const SkudRouteTables: React.FC<ISkudRouteTablesProps> = ({ approvers, ex
|
|||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
emptyText="Исполнители не назначены."
|
emptyText="Исполнители не назначены."
|
||||||
|
printable={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
AttachmentList,
|
AttachmentList,
|
||||||
PrintButton,
|
PrintButton,
|
||||||
Icon
|
Icon,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +57,8 @@ interface ITicket1CViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявка в отдел 1С';
|
||||||
|
|
||||||
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
||||||
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
@@ -185,12 +188,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<PrintButton />
|
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.FIO, code, itemId })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка в отдел 1С"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
@@ -224,6 +227,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
|
printable={false}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
AttachmentList,
|
AttachmentList,
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
Icon
|
Icon,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,8 @@ interface ITmcTransferViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Служебная записка на перемещение ТМЦ';
|
||||||
|
|
||||||
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
||||||
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
@@ -169,13 +172,18 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
|||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.AuthorName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Служебная записка на перемещение ТМЦ"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
@@ -204,6 +212,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
|||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
|
printable={false}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
|||||||
@@ -33,6 +33,11 @@
|
|||||||
(черновик проверяется так же строго, как отправка).
|
(черновик проверяется так же строго, как отправка).
|
||||||
- **Замещающий** (`Substitute`, People) — необязателен.
|
- **Замещающий** (`Substitute`, People) — необязателен.
|
||||||
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
|
- **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`.
|
||||||
|
- **Место для подписи на печати** (с 10-29, `PrintSignature`) — как в «Заявке на отпуск»:
|
||||||
|
столбиком «ФИО / линия / (подпись)» в правом верхнем углу шапки под статусом; ФИО сотрудника,
|
||||||
|
а не инициатора.
|
||||||
|
- **Таблица «Исполнение» на печать не идёт** (с 10-29, `rp-no-print`) — как в «Заявке на отпуск»;
|
||||||
|
на печати только маршрут согласования.
|
||||||
|
|
||||||
## Отличия от «Заявка на отпуск» (hr-vacation)
|
## Отличия от «Заявка на отпуск» (hr-vacation)
|
||||||
- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются.
|
- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
SubmitActions,
|
SubmitActions,
|
||||||
getDraftDeleteHandler,
|
getDraftDeleteHandler,
|
||||||
useRequestSubmit,
|
useRequestSubmit,
|
||||||
LeaveNotice
|
FormNotice
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
composeRemoveRowRule,
|
composeRemoveRowRule,
|
||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||||
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
|
import { organizationProjectRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
|
||||||
import { formIssues } from './validation';
|
import { formIssues } from './validation';
|
||||||
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
||||||
import { loadUnpaidLeaveRequest } from './load';
|
import { loadUnpaidLeaveRequest } from './load';
|
||||||
@@ -164,10 +164,14 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
// пользователя) — так работает подача за другого. У заявки на редактировании
|
// пользователя) — так работает подача за другого. У заявки на редактировании
|
||||||
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
|
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
|
||||||
//
|
//
|
||||||
// Кадровый сотрудник и исполнитель зависят от организации сотрудника (условия
|
// Кадровый сотрудник и исполнитель зависят от организации сотрудника и проекта
|
||||||
// «Organization содержит …» в админке) — см. shared/dictionary/organizationRouteFields.ts.
|
// (условия «Organization содержит …», «Project содержит …» в админке) — см.
|
||||||
// Организация приходит позже выбора сотрудника, поэтому она в ключе перезагрузки.
|
// shared/dictionary/organizationRouteFields.ts. Организация приходит позже
|
||||||
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]);
|
// выбора сотрудника, поэтому она в ключе перезагрузки.
|
||||||
|
const routeFieldValues = useMemo(
|
||||||
|
() => organizationProjectRouteFieldValues(data.Organization, data.Project),
|
||||||
|
[data.Organization, data.Project]
|
||||||
|
);
|
||||||
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
|
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -335,7 +339,7 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
|
|||||||
@@ -15,3 +15,12 @@
|
|||||||
gap: $sp-3;
|
gap: $sp-3;
|
||||||
margin-bottom: $sp-4;
|
margin-bottom: $sp-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Правый угол шапки: статус, под ним (только в печати) подпись сотрудника.
|
||||||
|
// headerRight у FormCard раскладывает содержимое в строку — здесь нужен столбик.
|
||||||
|
.headerAside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
LeaveNotice
|
FormNotice,
|
||||||
|
PrintSignature,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
@@ -49,6 +51,8 @@ interface IUnpaidLeaveViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявление на отпуск без сохранения заработной платы';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
const NOOP_SET: SetField = () => undefined;
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
@@ -64,6 +68,8 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
// Ф.И.О. сотрудника (Employee/Title) — для строки подписи на печатном заявлении.
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
||||||
@@ -97,6 +103,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -144,13 +151,18 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявление на отпуск без сохранения заработной платы"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
@@ -158,9 +170,15 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
itemId={itemId}
|
itemId={itemId}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={
|
||||||
|
<div className={styles.headerAside}>
|
||||||
|
{status && <StatusBadge status={status} />}
|
||||||
|
{/* Подписывает сотрудник, а не инициатор: заявку могут оформить за него. */}
|
||||||
|
<PrintSignature name={employeeName} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
@@ -168,6 +186,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
|
||||||
|
{/* Исполнение на печать не идёт — это делает сам ExecutorsMatrix (printable={false}). */}
|
||||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export interface IFormData {
|
|||||||
Department: string;
|
Department: string;
|
||||||
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
|
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
|
||||||
DepartmentId: string;
|
DepartmentId: string;
|
||||||
/** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — необязательное, справочное. */
|
/** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — обязательный, от него зависит кадровик в маршруте. */
|
||||||
Project: string;
|
Project: string;
|
||||||
/** oid проекта ('' — если введён вручную без выбора из списка). */
|
/** oid проекта ('' — если введён вручную без выбора из списка). */
|
||||||
ProjectId: string;
|
ProjectId: string;
|
||||||
@@ -95,7 +95,7 @@ export const SUBSTITUTE_PLACEHOLDER = 'Начните вводить Ф.И.О.
|
|||||||
*/
|
*/
|
||||||
export const MIN_NOTICE_DAYS = 14;
|
export const MIN_NOTICE_DAYS = 14;
|
||||||
|
|
||||||
/** Памятка вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — по просьбе HR. */
|
/** Памятка вверху формы и просмотра (см. shared/ui/FormNotice). Текст — по просьбе HR. */
|
||||||
export const NOTICE_TITLE = 'Памятка';
|
export const NOTICE_TITLE = 'Памятка';
|
||||||
export const NOTICE_ITEMS: string[] = [
|
export const NOTICE_ITEMS: string[] = [
|
||||||
'После согласования заявления его необходимо распечатать с портала (кнопка «Печать» на странице заявки), ' +
|
'После согласования заявления его необходимо распечатать с портала (кнопка «Печать» на странице заявки), ' +
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Проект" htmlFor="Project">
|
<FormField label="Проект" htmlFor="Project" required>
|
||||||
<ProjectPicker
|
<ProjectPicker
|
||||||
id="Project"
|
id="Project"
|
||||||
context={context}
|
context={context}
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ function startDateIssue(data: IFormData): IFieldIssue | undefined {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проект: обязателен и выбран из справочника. От него зависит кадровик в
|
||||||
|
* маршруте (условия «Project содержит …»), а название, вписанное руками,
|
||||||
|
* условия могут не узнать — поэтому без ProjectId заявку не пропускаем.
|
||||||
|
*/
|
||||||
|
function projectIssue(data: IFormData): IFieldIssue | undefined {
|
||||||
|
if (!data.Project.trim()) return { field: 'Project', label: 'Проект' };
|
||||||
|
if (data.ProjectId) return undefined;
|
||||||
|
return { field: 'Project', label: 'Проект', message: 'выберите проект из списка' };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Количество дней: обязательно, целое и больше нуля. Верхнего предела нет:
|
* Количество дней: обязательно, целое и больше нуля. Верхнего предела нет:
|
||||||
* сверх 5 дней (обязательных при браке/рождении/смерти) — по договорённости с работодателем.
|
* сверх 5 дней (обязательных при браке/рождении/смерти) — по договорённости с работодателем.
|
||||||
@@ -37,6 +48,7 @@ export function formIssues(data: IFormData): IFieldIssue[] {
|
|||||||
const issues: Array<IFieldIssue | undefined> = [
|
const issues: Array<IFieldIssue | undefined> = [
|
||||||
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
|
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
|
||||||
data.Position.trim() ? undefined : { field: 'Position', label: 'Должность' },
|
data.Position.trim() ? undefined : { field: 'Position', label: 'Должность' },
|
||||||
|
projectIssue(data),
|
||||||
startDateIssue(data),
|
startDateIssue(data),
|
||||||
daysCountIssue(data),
|
daysCountIssue(data),
|
||||||
data.ReasonType
|
data.ReasonType
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
- **Комментарий** (с 10-28, `VacationComment`, Note) — необязательный, заполняет инициатор,
|
- **Комментарий** (с 10-28, `VacationComment`, Note) — необязательный, заполняет инициатор,
|
||||||
последним полем секции; в просмотре пустой не показывается. В DOCX для ЭДО (`vacationDocument.ts`) не идёт; в печать
|
последним полем секции; в просмотре пустой не показывается. В DOCX для ЭДО (`vacationDocument.ts`) не идёт; в печать
|
||||||
тоже не попадает (обёртка `rp-no-print` с `display: contents`, чтобы на экране поле оставалось ячейкой сетки).
|
тоже не попадает (обёртка `rp-no-print` с `display: contents`, чтобы на экране поле оставалось ячейкой сетки).
|
||||||
|
- **Место для подписи на печати** (с 10-29, `PrintSignature` из `shared/ui`) — столбиком «ФИО /
|
||||||
|
линия / (подпись)» в правом верхнем углу шапки под статусом (`headerAside`), только при печати. ФИО — **сотрудника** (`Employee/Title`), а не
|
||||||
|
инициатора: заявку могут оформить за сотрудника, а заявление подписывает он сам.
|
||||||
|
- **Таблица «Исполнение» на печать не идёт** (с 10-29, обёртка `rp-no-print`) — это внутренняя
|
||||||
|
работа кадров после согласования; на печати только маршрут согласования. На экране видна.
|
||||||
|
|
||||||
## Подписание в ЭДО
|
## Подписание в ЭДО
|
||||||
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
|
> **Сейчас выключено** (`VACATION_EDO_SIGNING_ENABLED = false` в `shared/config.ts`): решение последнего
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
SubmitActions,
|
SubmitActions,
|
||||||
getDraftDeleteHandler,
|
getDraftDeleteHandler,
|
||||||
useRequestSubmit,
|
useRequestSubmit,
|
||||||
LeaveNotice
|
FormNotice
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
composeRemoveRowRule,
|
composeRemoveRowRule,
|
||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
} from '../../shared/listTitles';
|
} from '../../shared/listTitles';
|
||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||||
import { formIssues } from './validation';
|
import { formIssues } from './validation';
|
||||||
import { organizationRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
|
import { organizationProjectRouteFieldValues } from '../../shared/dictionary/organizationRouteFields';
|
||||||
import { saveVacationRequest, vacationApprovalService } from './submit';
|
import { saveVacationRequest, vacationApprovalService } from './submit';
|
||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
@@ -175,11 +175,15 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
|
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
|
||||||
//
|
//
|
||||||
// Кадровый сотрудник и исполнитель в маршруте зависят от организации
|
// Кадровый сотрудник и исполнитель в маршруте зависят от организации
|
||||||
// сотрудника (условия «Organization содержит …» в админке) — см.
|
// сотрудника и проекта (условия «Organization содержит …», «Project
|
||||||
// shared/dictionary/organizationRouteFields.ts. Организация приходит позже
|
// содержит …» в админке) — см. shared/dictionary/organizationRouteFields.ts.
|
||||||
// выбора сотрудника (Employee/ByEmail), поэтому она в ключе перезагрузки:
|
// Организация приходит позже выбора сотрудника (Employee/ByEmail), поэтому
|
||||||
// маршрут пересчитается, когда придёт, а не останется рассчитанным без условий.
|
// она в ключе перезагрузки: маршрут пересчитается, когда придёт, а не
|
||||||
const routeFieldValues = useMemo(() => organizationRouteFieldValues(data.Organization), [data.Organization]);
|
// останется рассчитанным без условий.
|
||||||
|
const routeFieldValues = useMemo(
|
||||||
|
() => organizationProjectRouteFieldValues(data.Organization, data.Project),
|
||||||
|
[data.Organization, data.Project]
|
||||||
|
);
|
||||||
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
|
const routeOrgKey = routeFieldValues.map((f) => f.value).join('|');
|
||||||
const loadMatrix = useCallback(
|
const loadMatrix = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -354,7 +358,7 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
)}
|
)}
|
||||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||||
|
|
||||||
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
|
|
||||||
<FormModeProvider readOnly={readOnly}>
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
|
|||||||
@@ -15,3 +15,12 @@
|
|||||||
gap: $sp-3;
|
gap: $sp-3;
|
||||||
margin-bottom: $sp-4;
|
margin-bottom: $sp-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Правый угол шапки: статус, под ним (только в печати) подпись сотрудника.
|
||||||
|
// headerRight у FormCard раскладывает содержимое в строку — здесь нужен столбик.
|
||||||
|
.headerAside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: $sp-3;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
LeaveNotice
|
FormNotice,
|
||||||
|
PrintSignature,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
@@ -56,6 +58,8 @@ interface IVacationViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявление на оплачиваемый ежегодный трудовой отпуск';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
const NOOP_SET: SetField = () => undefined;
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
@@ -206,13 +210,18 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявление на оплачиваемый ежегодный трудовой отпуск"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
@@ -220,15 +229,22 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
itemId={itemId}
|
itemId={itemId}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={
|
||||||
|
<div className={styles.headerAside}>
|
||||||
|
{status && <StatusBadge status={status} />}
|
||||||
|
{/* Подписывает сотрудник, а не инициатор: заявку могут оформить за него. */}
|
||||||
|
<PrintSignature name={employeeName} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||||
<FormModeProvider readOnly>
|
<FormModeProvider readOnly>
|
||||||
<RequestSection {...sectionProps} />
|
<RequestSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
</FormCard>
|
</FormCard>
|
||||||
|
|
||||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||||
|
{/* Исполнение на печать не идёт — это делает сам ExecutorsMatrix (printable={false}). */}
|
||||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export interface IFormData {
|
|||||||
Department: string;
|
Department: string;
|
||||||
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
|
/** Id отдела из справочника; '' — если введён вручную без выбора из списка. */
|
||||||
DepartmentId: string;
|
DepartmentId: string;
|
||||||
/** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — необязательное, справочное. */
|
/** Проект (справочник /Project, см. shared/dictionary/ProjectPicker) — обязательный, от него зависит кадровик в маршруте. */
|
||||||
Project: string;
|
Project: string;
|
||||||
/** oid проекта ('' — если введён вручную без выбора из списка). */
|
/** oid проекта ('' — если введён вручную без выбора из списка). */
|
||||||
ProjectId: string;
|
ProjectId: string;
|
||||||
@@ -87,7 +87,7 @@ export const MIN_NOTICE_DAYS = 7;
|
|||||||
export const MAX_DAYS = 24;
|
export const MAX_DAYS = 24;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Примечание вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — от HR,
|
* Примечание вверху формы и просмотра (см. shared/ui/FormNotice). Текст — от HR,
|
||||||
* дословно. «Одну календарную неделю» — это MIN_NOTICE_DAYS: поменяли срок — поправьте и текст.
|
* дословно. «Одну календарную неделю» — это MIN_NOTICE_DAYS: поменяли срок — поправьте и текст.
|
||||||
* Правило про часть не менее 14 дней только показываем, а не проверяем: форма не знает,
|
* Правило про часть не менее 14 дней только показываем, а не проверяем: форма не знает,
|
||||||
* брал ли сотрудник уже часть отпуска.
|
* брал ли сотрудник уже часть отпуска.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const RequestSection: React.FC<ISectionProps> = ({ data, set, context, on
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<OrganizationTable value={data.Organization} printIndex={printOrgIndex} />
|
<OrganizationTable value={data.Organization} printIndex={printOrgIndex} />
|
||||||
<FormField label="Проект" htmlFor="Project">
|
<FormField label="Проект" htmlFor="Project" required>
|
||||||
<ProjectPicker
|
<ProjectPicker
|
||||||
id="Project"
|
id="Project"
|
||||||
context={context}
|
context={context}
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ function startDateIssue(data: IFormData): IFieldIssue | undefined {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проект: обязателен и выбран из справочника. От него зависит кадровик в
|
||||||
|
* маршруте (условия «Project содержит …»), а название, вписанное руками,
|
||||||
|
* условия могут не узнать — поэтому без ProjectId заявку не пропускаем.
|
||||||
|
*/
|
||||||
|
function projectIssue(data: IFormData): IFieldIssue | undefined {
|
||||||
|
if (!data.Project.trim()) return { field: 'Project', label: 'Проект' };
|
||||||
|
if (data.ProjectId) return undefined;
|
||||||
|
return { field: 'Project', label: 'Проект', message: 'выберите проект из списка' };
|
||||||
|
}
|
||||||
|
|
||||||
/** Количество дней: обязательно, целое, от 1 до MAX_DAYS. */
|
/** Количество дней: обязательно, целое, от 1 до MAX_DAYS. */
|
||||||
function daysCountIssue(data: IFormData): IFieldIssue | undefined {
|
function daysCountIssue(data: IFormData): IFieldIssue | undefined {
|
||||||
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
|
if (!data.DaysCount.trim()) return { field: 'DaysCount', label: 'Количество дней' };
|
||||||
@@ -36,6 +47,7 @@ function daysCountIssue(data: IFormData): IFieldIssue | undefined {
|
|||||||
export function formIssues(data: IFormData): IFieldIssue[] {
|
export function formIssues(data: IFormData): IFieldIssue[] {
|
||||||
const issues: Array<IFieldIssue | undefined> = [
|
const issues: Array<IFieldIssue | undefined> = [
|
||||||
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
|
data.Employee ? undefined : { field: 'Employee', label: 'Сотрудник' },
|
||||||
|
projectIssue(data),
|
||||||
startDateIssue(data),
|
startDateIssue(data),
|
||||||
daysCountIssue(data)
|
daysCountIssue(data)
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -23,16 +23,28 @@
|
|||||||
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
|
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
|
||||||
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
|
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
|
||||||
согласовании»/«Выполнение» (`useApproverRights.ts`).
|
согласовании»/«Выполнение» (`useApproverRights.ts`).
|
||||||
- **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём
|
- **Поля «Необходимое ПО», «Доступы» и «Мебель» заполняет согласующий-руководитель**
|
||||||
этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
|
на своём этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
|
||||||
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой
|
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой
|
||||||
«Сохранить» и автоматически при «Согласовать» (`updateReviewFields`,
|
«Сохранить» и автоматически при «Согласовать» (`updateReviewFields`,
|
||||||
`DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only.
|
`DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only.
|
||||||
|
- **Мебель** — мультивыбор (`FURNITURE_OPTIONS`); при «Другое» появляется поле
|
||||||
|
своего значения. В SP — `WorkplaceFurnitureJson` (JSON-массив) и
|
||||||
|
`WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как
|
||||||
|
`WorkplaceFurnitureJson` (пункты через запятую, `routeFields.ts`) — на условие
|
||||||
|
шага исполнения «исполнитель по мебели».
|
||||||
|
- **Добавление исполнителей по правке согласующего** (`routeExecutorsSync.ts`):
|
||||||
|
маршрут планируется при подаче, поэтому если руководитель/Ербол поменял мебель
|
||||||
|
или ПО, при сохранении (и перед решением) строятся два плана исполнения — со
|
||||||
|
старыми и новыми значениями — и разница добавляется в конец исполнения
|
||||||
|
(«Ждёт очереди», `ApproverService.addMissingExecutors`). Никого не удаляем;
|
||||||
|
уже стоящих в исполнении (в любом статусе) не дублируем
|
||||||
|
(`shared/approval/missingExecutors.ts`).
|
||||||
- **Маска телефона** (PhoneInput).
|
- **Маска телефона** (PhoneInput).
|
||||||
- Печать — общий механизм (без «Вложений»).
|
- Печать — общий механизм (без «Вложений»).
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection
|
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection · FurnitureSection
|
||||||
|
|
||||||
## Точки входа
|
## Точки входа
|
||||||
- rptype: `it-workplace-setup`
|
- rptype: `it-workplace-setup`
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { loadWorkplaceRequest } from './load';
|
|||||||
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
import { FurnitureSection } from './sections/FurnitureSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||||
@@ -392,6 +393,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
<FormModeProvider readOnly={readOnly}>
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<EmployeeSection {...sectionProps} />
|
<EmployeeSection {...sectionProps} />
|
||||||
<EquipmentSection {...sectionProps} />
|
<EquipmentSection {...sectionProps} />
|
||||||
|
<FurnitureSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
</FieldChangesProvider>
|
</FieldChangesProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
Icon,
|
Icon,
|
||||||
FieldChangesProvider,
|
FieldChangesProvider,
|
||||||
FieldChangesBanner
|
FieldChangesBanner,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import {
|
import {
|
||||||
@@ -47,8 +48,10 @@ import { workplaceApprovalService, updateReviewFields, updateAllWorkplaceFields
|
|||||||
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
|
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
|
||||||
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
||||||
import { useApproverRights } from './useApproverRights';
|
import { useApproverRights } from './useApproverRights';
|
||||||
|
import { addExecutorsForEditedFields, IExecutorsSyncResult } from './routeExecutorsSync';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
import { FurnitureSection } from './sections/FurnitureSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IWorkplaceSetupViewProps {
|
interface IWorkplaceSetupViewProps {
|
||||||
@@ -64,13 +67,14 @@ interface IWorkplaceSetupViewProps {
|
|||||||
|
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».';
|
const FORM_TITLE = 'Организация рабочего места';
|
||||||
|
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО», «Доступы» и «Мебель».';
|
||||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||||
|
|
||||||
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
||||||
// Исключения (оба — только на своём активном шаге, см. useApproverRights):
|
// Исключения (оба — только на своём активном шаге, см. useApproverRights):
|
||||||
// - первый согласующий шаг (по маршруту — непосредственный руководитель) может
|
// - первый согласующий шаг (по маршруту — непосредственный руководитель) может
|
||||||
// заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает);
|
// заполнить «Необходимое ПО», «Доступы» и «Мебель» (инициатор их не знает);
|
||||||
// - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения.
|
// - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения.
|
||||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
@@ -102,6 +106,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||||
|
// Title списка данных (а не url) — для оповещений, шапки «Инициатор» и чтения инициатора.
|
||||||
|
const dataListName = listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID);
|
||||||
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
|
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
|
||||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||||
const attachments = useAttachmentEditor(dataListRef, itemId);
|
const attachments = useAttachmentEditor(dataListRef, itemId);
|
||||||
@@ -174,6 +180,18 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
};
|
};
|
||||||
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
|
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
|
||||||
|
|
||||||
|
const notify = (newStatus?: string): void => {
|
||||||
|
if (!requestTypeId) return;
|
||||||
|
const target = {
|
||||||
|
requestTypeId,
|
||||||
|
dataListName,
|
||||||
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||||
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
const saveAllFields = async (): Promise<void> => {
|
const saveAllFields = async (): Promise<void> => {
|
||||||
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
|
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
|
||||||
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date());
|
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date());
|
||||||
@@ -183,18 +201,36 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
await attachments.uploadPending();
|
await attachments.uploadPending();
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveReviewFields = (): Promise<void> =>
|
// updateReviewFields пишет только поля руководителя (ReviewFields), остальное из data игнорирует.
|
||||||
updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId);
|
const saveReviewFields = (): Promise<void> => updateReviewFields(itemId, data, listId);
|
||||||
|
|
||||||
// Полное право шире: оно включает и «ПО»/«Доступы».
|
// Полное право шире: оно включает и поля руководителя. После записи полей —
|
||||||
const saveFields = (): Promise<void> => (canEditAll ? saveAllFields() : saveReviewFields());
|
// исполнители, которых привела правка мебели/ПО (маршрут планировался при
|
||||||
|
// подаче и сам их не добавит, см. routeExecutorsSync.ts).
|
||||||
|
const saveFields = async (): Promise<IExecutorsSyncResult> => {
|
||||||
|
const before = savedData;
|
||||||
|
if (canEditAll) {
|
||||||
|
await saveAllFields();
|
||||||
|
} else {
|
||||||
|
await saveReviewFields();
|
||||||
|
setSavedData(data);
|
||||||
|
}
|
||||||
|
return addExecutorsForEditedFields(
|
||||||
|
{ context, approval, approverService, requestTypeId, itemId, dataListTitle: dataListName },
|
||||||
|
before,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
setSaveErrorMessage('');
|
setSaveErrorMessage('');
|
||||||
try {
|
try {
|
||||||
await saveFields();
|
const executorsSync = await saveFields();
|
||||||
setSaveState('saved');
|
setSaveState('saved');
|
||||||
|
// Новые исполнители должны появиться в таблице «Исполнение» — перечитываем маршрут.
|
||||||
|
if (executorsSync.addedCount > 0) setDecisionSeq((n) => n + 1);
|
||||||
|
if (executorsSync.activatedRowIds.length > 0) notify();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSaveErrorMessage(friendlyErrorMessage(e));
|
setSaveErrorMessage(friendlyErrorMessage(e));
|
||||||
setSaveState('error');
|
setSaveState('error');
|
||||||
@@ -225,18 +261,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
const sectionProps = { data, set, context, printOrgIndex };
|
const sectionProps = { data, set, context, printOrgIndex };
|
||||||
const canEditAnything = canEditAll || canEditReviewFields;
|
const canEditAnything = canEditAll || canEditReviewFields;
|
||||||
|
|
||||||
const notify = (newStatus?: string): void => {
|
|
||||||
if (!requestTypeId) return;
|
|
||||||
const target = {
|
|
||||||
requestTypeId,
|
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
|
||||||
};
|
|
||||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
|
||||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
|
||||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
@@ -249,7 +273,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<PrintSplitButton value={organizationText(data)} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={organizationText(data)}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.FullName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditAnything && (
|
{canEditAnything && (
|
||||||
@@ -268,13 +297,10 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={organizationText(data)} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={organizationText(data)} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Организация рабочего места"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine listTitle={dataListName} itemId={itemId} />
|
||||||
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
|
||||||
itemId={itemId}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
@@ -283,6 +309,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
<FormModeProvider readOnly={!canEditAll}>
|
<FormModeProvider readOnly={!canEditAll}>
|
||||||
<EmployeeSection {...sectionProps} />
|
<EmployeeSection {...sectionProps} />
|
||||||
<EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} />
|
<EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} />
|
||||||
|
<FurnitureSection {...sectionProps} approverEditable={canEditReviewFields} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
</FieldChangesProvider>
|
</FieldChangesProvider>
|
||||||
|
|
||||||
@@ -296,6 +323,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
{!executors.loading && executors.rows.length > 0 && (
|
{!executors.loading && executors.rows.length > 0 && (
|
||||||
<ApprovalMatrix
|
<ApprovalMatrix
|
||||||
editor={executors}
|
editor={executors}
|
||||||
|
printable={false}
|
||||||
search={(q) => approval.searchUsers(q)}
|
search={(q) => approval.searchUsers(q)}
|
||||||
title="Исполнение"
|
title="Исполнение"
|
||||||
roleLabel="Исполнитель"
|
roleLabel="Исполнитель"
|
||||||
|
|||||||
@@ -3,18 +3,25 @@ import { dataListTitle } from '../../shared/listTitles';
|
|||||||
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||||
import { ICompany } from '../../shared/dictionary';
|
import { ICompany } from '../../shared/dictionary';
|
||||||
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model';
|
import {
|
||||||
|
COMPANIES_JSON_FIELD,
|
||||||
|
FURNITURE_JSON_FIELD,
|
||||||
|
FURNITURE_OTHER_FIELD,
|
||||||
|
FORM_ID,
|
||||||
|
IFormData,
|
||||||
|
EMPTY_DATA
|
||||||
|
} from './model';
|
||||||
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON выбранных компаний → массив. Пусто — заявка до мультивыбора (тогда
|
* JSON-колонка с массивом → массив. Пусто — колонка не заполнена (заявка создана
|
||||||
* организации есть только текстом в Organization) или битый JSON.
|
* до появления поля: компании до мультивыбора, мебель до 2026-10-29) или битый JSON.
|
||||||
*/
|
*/
|
||||||
function parseCompanies(raw: unknown): ICompany[] {
|
function parseJsonArray<T>(raw: unknown): T[] {
|
||||||
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
return Array.isArray(parsed) ? (parsed as ICompany[]) : [];
|
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -50,6 +57,8 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
|
|||||||
...SCALAR_FIELDS,
|
...SCALAR_FIELDS,
|
||||||
'NeedsEquipment',
|
'NeedsEquipment',
|
||||||
COMPANIES_JSON_FIELD,
|
COMPANIES_JSON_FIELD,
|
||||||
|
FURNITURE_JSON_FIELD,
|
||||||
|
FURNITURE_OTHER_FIELD,
|
||||||
APPROVER_CHANGES_FIELD,
|
APPROVER_CHANGES_FIELD,
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode'
|
'RequestUniqueCode'
|
||||||
@@ -71,7 +80,9 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
|
|||||||
});
|
});
|
||||||
|
|
||||||
data.NeedsEquipment = item.NeedsEquipment === true;
|
data.NeedsEquipment = item.NeedsEquipment === true;
|
||||||
data.Companies = parseCompanies(item[COMPANIES_JSON_FIELD]);
|
data.Companies = parseJsonArray<ICompany>(item[COMPANIES_JSON_FIELD]);
|
||||||
|
data.Furniture = parseJsonArray<string>(item[FURNITURE_JSON_FIELD]);
|
||||||
|
data.FurnitureOther = (item[FURNITURE_OTHER_FIELD] as string) || '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { RequestTypeId } from '../../models/types';
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
import { ISelectOption } from '@tnsu/ui-kit';
|
||||||
import { ICompany } from '../../shared/dictionary';
|
import { ICompany } from '../../shared/dictionary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,6 +15,22 @@ export const FORM_ID: RequestTypeId = 'it-workplace-setup';
|
|||||||
*/
|
*/
|
||||||
export const COMPANIES_JSON_FIELD = 'WorkplaceCompaniesJson';
|
export const COMPANIES_JSON_FIELD = 'WorkplaceCompaniesJson';
|
||||||
|
|
||||||
|
/** Note-колонка с IFormData.Furniture в JSON. Префикс Workplace — по той же причине, что у компаний. */
|
||||||
|
export const FURNITURE_JSON_FIELD = 'WorkplaceFurnitureJson';
|
||||||
|
/** Text-колонка с IFormData.FurnitureOther. */
|
||||||
|
export const FURNITURE_OTHER_FIELD = 'WorkplaceFurnitureOther';
|
||||||
|
|
||||||
|
/** Значение-триггер «Другое» в «Мебели»: при нём показываем поле ручного ввода. */
|
||||||
|
export const FURNITURE_OTHER_VALUE = 'Другое';
|
||||||
|
|
||||||
|
export const FURNITURE_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Стол', label: 'Стол' },
|
||||||
|
{ value: 'Кресло', label: 'Кресло' },
|
||||||
|
{ value: 'Тумбочка', label: 'Тумбочка' },
|
||||||
|
{ value: 'Канцелярские принадлежности', label: 'Канцелярские принадлежности' },
|
||||||
|
{ value: FURNITURE_OTHER_VALUE, label: FURNITURE_OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка
|
* Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка
|
||||||
* «Организация рабочего места — данные».
|
* «Организация рабочего места — данные».
|
||||||
@@ -57,6 +74,11 @@ export interface IFormData {
|
|||||||
NeedsEquipment: boolean;
|
NeedsEquipment: boolean;
|
||||||
Software: string;
|
Software: string;
|
||||||
AccessRights: string;
|
AccessRights: string;
|
||||||
|
// Мебель (необязательно; заполняется вручную)
|
||||||
|
/** Выбранные пункты из FURNITURE_OPTIONS. В SP — JSON в колонке WorkplaceFurnitureJson. */
|
||||||
|
Furniture: string[];
|
||||||
|
/** Своё значение — только если выбрано «Другое», иначе ''. В SP — колонка WorkplaceFurnitureOther. */
|
||||||
|
FurnitureOther: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,5 +118,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Organization: '',
|
Organization: '',
|
||||||
NeedsEquipment: false,
|
NeedsEquipment: false,
|
||||||
Software: '',
|
Software: '',
|
||||||
AccessRights: ''
|
AccessRights: '',
|
||||||
|
Furniture: [],
|
||||||
|
FurnitureOther: ''
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
||||||
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
|
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
|
||||||
{ "internalName": "AccessRights", "title": "Доступы", "type": "Note" },
|
{ "internalName": "AccessRights", "title": "Доступы", "type": "Note" },
|
||||||
|
{ "internalName": "WorkplaceFurnitureJson", "title": "Мебель (JSON)", "type": "Note" },
|
||||||
|
{ "internalName": "WorkplaceFurnitureOther", "title": "Мебель (другое)", "type": "Text" },
|
||||||
{ "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" },
|
{ "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" },
|
||||||
|
|
||||||
{ "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }
|
{ "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import {
|
||||||
|
ApprovalListService,
|
||||||
|
ApproverService,
|
||||||
|
IApproverToWrite,
|
||||||
|
IMatrixRow,
|
||||||
|
IResolveFieldValue,
|
||||||
|
getPlannedMatrix,
|
||||||
|
planMissingExecutors
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import { getCreatedBy } from '../../shared/createdBy';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { IFormData } from './model';
|
||||||
|
import { routeFieldValues } from './routeFields';
|
||||||
|
|
||||||
|
/** Окружение просмотра заявки, нужное для добавления исполнителей. */
|
||||||
|
export interface IExecutorsSyncContext {
|
||||||
|
context: WebPartContext;
|
||||||
|
approval: ApprovalListService;
|
||||||
|
approverService: ApproverService;
|
||||||
|
/** Id типа заявки в approval-route; undefined — ещё не загружен. */
|
||||||
|
requestTypeId: number | undefined;
|
||||||
|
itemId: number;
|
||||||
|
/** Title списка данных заявки — по нему читаем инициатора (getCreatedBy). */
|
||||||
|
dataListTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IExecutorsSyncResult {
|
||||||
|
addedCount: number;
|
||||||
|
/** Строки, ставшие активными, — им нужно оповещение. */
|
||||||
|
activatedRowIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTHING_ADDED: IExecutorsSyncResult = { addedCount: 0, activatedRowIds: [] };
|
||||||
|
|
||||||
|
function toWrite(row: IMatrixRow): IApproverToWrite {
|
||||||
|
return {
|
||||||
|
email: row.email,
|
||||||
|
position: row.position,
|
||||||
|
allowReassign: row.allowReassign,
|
||||||
|
slaHours: row.slaHours,
|
||||||
|
askOverdueReason: row.askOverdueReason,
|
||||||
|
autoApprove: row.autoApprove
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Согласующий поправил «Мебель» или «Необходимое ПО» в поданной заявке — добавить
|
||||||
|
* исполнителей, которых эти поля приводят по маршруту из админки (иначе, напр.,
|
||||||
|
* мебель, отмеченная руководителем, осталась бы без исполнителя: маршрут
|
||||||
|
* планировался при подаче).
|
||||||
|
*
|
||||||
|
* Строим два плана исполнения — со старыми и с новыми мебелью/ПО — и добавляем
|
||||||
|
* разницу (planMissingExecutors). Остальные поля маршрута (организации, отдел)
|
||||||
|
* в обоих планах берём новые: правка организаций полным правом исполнителей
|
||||||
|
* не добавляет — такого не просили, а маршрут по ним уже был при подаче.
|
||||||
|
*
|
||||||
|
* Маршрут резолвится по инициатору (Автор элемента), как при подаче.
|
||||||
|
* Ошибка — с пояснением, что поля уже сохранены: молча терять исполнителя нельзя.
|
||||||
|
*/
|
||||||
|
export async function addExecutorsForEditedFields(
|
||||||
|
sync: IExecutorsSyncContext,
|
||||||
|
before: IFormData,
|
||||||
|
after: IFormData
|
||||||
|
): Promise<IExecutorsSyncResult> {
|
||||||
|
const fieldsBefore = routeFieldValues({ Companies: after.Companies, Furniture: before.Furniture }, before.Software);
|
||||||
|
const fieldsAfter = routeFieldValues(after, after.Software);
|
||||||
|
if (JSON.stringify(fieldsBefore) === JSON.stringify(fieldsAfter)) return NOTHING_ADDED;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!sync.requestTypeId) throw new Error('не загружен тип заявки');
|
||||||
|
const initiator = await getCreatedBy(sync.dataListTitle, sync.itemId);
|
||||||
|
if (!initiator?.email) throw new Error('не удалось определить инициатора заявки');
|
||||||
|
|
||||||
|
const planExecution = (fieldValues: IResolveFieldValue[]): Promise<IMatrixRow[]> =>
|
||||||
|
getPlannedMatrix(
|
||||||
|
sync.context,
|
||||||
|
sync.requestTypeId as number,
|
||||||
|
initiator.email,
|
||||||
|
sync.approval,
|
||||||
|
'Execution',
|
||||||
|
after.DepartmentId,
|
||||||
|
fieldValues
|
||||||
|
);
|
||||||
|
const [plannedBefore, plannedAfter, currentRows] = await Promise.all([
|
||||||
|
planExecution(fieldsBefore),
|
||||||
|
planExecution(fieldsAfter),
|
||||||
|
sync.approval.getMatrixForRequest(sync.itemId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toAdd = planMissingExecutors(plannedBefore, plannedAfter, currentRows);
|
||||||
|
if (toAdd.length === 0) return NOTHING_ADDED;
|
||||||
|
const activatedRowIds = await sync.approverService.addMissingExecutors(sync.itemId, toAdd.map(toWrite));
|
||||||
|
return { addedCount: toAdd.length, activatedRowIds };
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Изменения сохранены, но не удалось добавить исполнителей по маршруту: ${friendlyErrorMessage(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IResolveFieldValue } from '../../shared/approval';
|
import { IResolveFieldValue } from '../../shared/approval';
|
||||||
import { COMPANIES_JSON_FIELD, IFormData } from './model';
|
import { COMPANIES_JSON_FIELD, FURNITURE_JSON_FIELD, IFormData } from './model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Значения полей заявки для условных шагов маршрута (fieldValues при resolve).
|
* Значения полей заявки для условных шагов маршрута (fieldValues при resolve).
|
||||||
@@ -11,12 +11,18 @@ import { COMPANIES_JSON_FIELD, IFormData } from './model';
|
|||||||
* строку (массив → 400), а «содержит» ищет подстроку. Тот же формат, что в
|
* строку (массив → 400), а «содержит» ищет подстроку. Тот же формат, что в
|
||||||
* заявках на приём и подбор (см. RecruitingHireForm).
|
* заявках на приём и подбор (см. RecruitingHireForm).
|
||||||
*
|
*
|
||||||
* Маршрут планируется при подаче, поэтому условие по Software сработает,
|
* Мебель — «WorkplaceFurnitureJson содержит Стол» и т.п. (исполнитель по мебели):
|
||||||
* только если «Необходимое ПО» указал сам инициатор (руководитель заполняет
|
* пункты одной строкой через запятую — «содержит» ищет подстроку, разделитель
|
||||||
* его позже, на своём шаге).
|
* ему безразличен, а в диагностике админки так читается легче. Сортируем, чтобы
|
||||||
|
* порядок кликов не менял строку и не перепланировал матрицу впустую.
|
||||||
|
*
|
||||||
|
* Маршрут планируется при подаче, поэтому условия по Software и мебели при
|
||||||
|
* подаче сработают, только если их указал сам инициатор. Если их потом
|
||||||
|
* заполнит руководитель, недостающих исполнителей добавляет просмотр заявки
|
||||||
|
* при сохранении правок.
|
||||||
*/
|
*/
|
||||||
export function routeFieldValues(
|
export function routeFieldValues(
|
||||||
data: Pick<IFormData, 'Companies'>,
|
data: Pick<IFormData, 'Companies' | 'Furniture'>,
|
||||||
software: string
|
software: string
|
||||||
): IResolveFieldValue[] {
|
): IResolveFieldValue[] {
|
||||||
const fieldValues: IResolveFieldValue[] = [];
|
const fieldValues: IResolveFieldValue[] = [];
|
||||||
@@ -29,5 +35,9 @@ export function routeFieldValues(
|
|||||||
if (softwareText) {
|
if (softwareText) {
|
||||||
fieldValues.push({ fieldName: 'Software', value: softwareText });
|
fieldValues.push({ fieldName: 'Software', value: softwareText });
|
||||||
}
|
}
|
||||||
|
const furnitureText = data.Furniture.slice().sort().join(', ');
|
||||||
|
if (furnitureText) {
|
||||||
|
fieldValues.push({ fieldName: FURNITURE_JSON_FIELD, value: furnitureText });
|
||||||
|
}
|
||||||
return fieldValues;
|
return fieldValues;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection, FormField, FormModeProvider, MultiChoiceField, TextInput } from '../../../shared/ui';
|
||||||
|
import { FURNITURE_OPTIONS, FURNITURE_OTHER_VALUE, ISectionProps } from '../model';
|
||||||
|
|
||||||
|
interface IFurnitureSectionProps extends ISectionProps {
|
||||||
|
/** Согласующий-руководитель на своём шаге может отметить мебель, даже когда
|
||||||
|
* остальная форма read-only (как ПО и доступы в EquipmentSection). */
|
||||||
|
approverEditable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FurnitureSection: React.FC<IFurnitureSectionProps> = ({ data, set, approverEditable }) => {
|
||||||
|
const hasOther = data.Furniture.indexOf(FURNITURE_OTHER_VALUE) !== -1;
|
||||||
|
|
||||||
|
const handleFurnitureChange = (values: string[]): void => {
|
||||||
|
set('Furniture', values);
|
||||||
|
// Сняли «Другое» — своё значение больше не относится к заявке, не храним его.
|
||||||
|
if (values.indexOf(FURNITURE_OTHER_VALUE) === -1) set('FurnitureOther', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fields = (
|
||||||
|
<>
|
||||||
|
<FormField label="Мебель" fieldKey="Furniture" full>
|
||||||
|
<MultiChoiceField
|
||||||
|
value={data.Furniture}
|
||||||
|
onChange={handleFurnitureChange}
|
||||||
|
options={FURNITURE_OPTIONS}
|
||||||
|
placeholder="Выберите, что нужно"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{hasOther && (
|
||||||
|
<FormField label="Мебель (другое)" htmlFor="FurnitureOther" full>
|
||||||
|
<TextInput id="FurnitureOther" value={data.FurnitureOther} onChange={(v) => set('FurnitureOther', v)} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapseSection title="Мебель" icon="chair">
|
||||||
|
{/* MultiChoiceField берёт режим только из контекста (своего readOnly у него нет),
|
||||||
|
поэтому право руководителя даём вложенным провайдером, перекрывающим внешний. */}
|
||||||
|
{approverEditable ? <FormModeProvider readOnly={false}>{fields}</FormModeProvider> : fields}
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||||
import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, organizationText } from './model';
|
import {
|
||||||
|
COMPANIES_JSON_FIELD,
|
||||||
|
FURNITURE_JSON_FIELD,
|
||||||
|
FURNITURE_OTHER_FIELD,
|
||||||
|
FORM_ID,
|
||||||
|
IFormData,
|
||||||
|
organizationText
|
||||||
|
} from './model';
|
||||||
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,22 +20,30 @@ export function workplaceApprovalService(listId?: string): ApprovalListService {
|
|||||||
return approvalServiceFor(FORM_ID, listId);
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Поля, которые заполняет согласующий-руководитель на своём шаге (см. updateReviewFields). */
|
||||||
|
export type ReviewFields = Pick<IFormData, 'Software' | 'AccessRights' | 'Furniture' | 'FurnitureOther'>;
|
||||||
|
|
||||||
|
/** Колонки SP «Мебели»: массив — JSON-ом, пустые значения — null. */
|
||||||
|
function furnitureFields(data: Pick<IFormData, 'Furniture' | 'FurnitureOther'>): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
[FURNITURE_JSON_FIELD]: data.Furniture.length > 0 ? JSON.stringify(data.Furniture) : null,
|
||||||
|
[FURNITURE_OTHER_FIELD]: data.FurnitureOther === '' ? null : data.FurnitureOther
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
|
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
|
||||||
* (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля.
|
* (инициатор часто не знает нужное ПО, доступы и мебель). Пишет только эти поля.
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest): элемент уже
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest): элемент уже
|
||||||
* существует, поэтому читаем/пишем напрямую по url его списка, если он задан.
|
* существует, поэтому читаем/пишем напрямую по url его списка, если он задан.
|
||||||
*/
|
*/
|
||||||
export async function updateReviewFields(
|
export async function updateReviewFields(id: number, fields: ReviewFields, listId?: string): Promise<void> {
|
||||||
id: number,
|
|
||||||
fields: { Software: string; AccessRights: string },
|
|
||||||
listId?: string
|
|
||||||
): Promise<void> {
|
|
||||||
const list = resolveDataList(FORM_ID, listId);
|
const list = resolveDataList(FORM_ID, listId);
|
||||||
await list.items.getById(id).update({
|
await list.items.getById(id).update({
|
||||||
Software: fields.Software === '' ? null : fields.Software,
|
Software: fields.Software === '' ? null : fields.Software,
|
||||||
AccessRights: fields.AccessRights === '' ? null : fields.AccessRights,
|
AccessRights: fields.AccessRights === '' ? null : fields.AccessRights,
|
||||||
|
...furnitureFields(fields)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +78,14 @@ function buildContentFields(data: IFormData): Record<string, unknown> {
|
|||||||
item[COMPANIES_JSON_FIELD] = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
|
item[COMPANIES_JSON_FIELD] = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (key === 'Furniture') {
|
||||||
|
Object.assign(item, furnitureFields(data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'FurnitureOther') {
|
||||||
|
// Пишется вместе с Furniture (см. выше).
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (key === 'Organization') {
|
if (key === 'Organization') {
|
||||||
// Текстовая копия выбранных компаний — для печати и списка в SP (см. organizationText).
|
// Текстовая копия выбранных компаний — для печати и списка в SP (см. organizationText).
|
||||||
const text = organizationText(data);
|
const text = organizationText(data);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { effectiveUserEmail } from '../../shared/impersonation';
|
|||||||
import { FORM_ID } from './model';
|
import { FORM_ID } from './model';
|
||||||
|
|
||||||
export interface IApproverRights {
|
export interface IApproverRights {
|
||||||
/** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО» и «Доступы». */
|
/** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО», «Доступы» и «Мебель». */
|
||||||
canEditReviewFields: boolean;
|
canEditReviewFields: boolean;
|
||||||
/** Участник маршрута из shared/approval/fullEditRights.ts правит все поля и вложения. */
|
/** Участник маршрута из shared/approval/fullEditRights.ts правит все поля и вложения. */
|
||||||
canEditAll: boolean;
|
canEditAll: boolean;
|
||||||
@@ -28,7 +28,7 @@ const FULL_EDIT_STATUSES: ReadonlyArray<string> = [REQUEST_STATUS.onApproval, RE
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Что текущий пользователь может править в поданной заявке.
|
* Что текущий пользователь может править в поданной заявке.
|
||||||
* - «ПО»/«Доступы» — только на СВОЁМ активном шаге согласования, если это первый шаг.
|
* - «ПО»/«Доступы»/«Мебель» — только на СВОЁМ активном шаге согласования, если это первый шаг.
|
||||||
* - Все поля — если пользователь есть в маршруте (согласование или исполнение)
|
* - Все поля — если пользователь есть в маршруте (согласование или исполнение)
|
||||||
* НЕЗАВИСИМО от того, чья сейчас очередь, пока заявка в работе (решение
|
* НЕЗАВИСИМО от того, чья сейчас очередь, пока заявка в работе (решение
|
||||||
* Тимура 2026-10-07: так в «Организации рабочего места» работает Ербол).
|
* Тимура 2026-10-07: так в «Организации рабочего места» работает Ербол).
|
||||||
|
|||||||
@@ -321,7 +321,8 @@ export class ApprovalListService {
|
|||||||
return this.resolveUserId(email);
|
return this.resolveUserId(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляет одну строку согласования с заданным Order (для делегата).
|
// Добавляет одну строку согласования с заданным Order (для делегата, ручного
|
||||||
|
// добавления участника и исполнителя, которого привела правка полей).
|
||||||
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
||||||
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
||||||
// строку ещё раз, если делегат недоступен (см. ApproverService.delegate).
|
// строку ещё раз, если делегат недоступен (см. ApproverService.delegate).
|
||||||
@@ -342,6 +343,12 @@ export class ApprovalListService {
|
|||||||
[FIELDS.order]: order,
|
[FIELDS.order]: order,
|
||||||
[FIELDS.allowReassign]: approver.allowReassign,
|
[FIELDS.allowReassign]: approver.allowReassign,
|
||||||
[FIELDS.phase]: phase,
|
[FIELDS.phase]: phase,
|
||||||
|
// SLA шага из маршрута админки — у исполнителя, добавленного по правке
|
||||||
|
// полей (ApproverService.addMissingExecutors). У делегата их нет: пусто/Нет,
|
||||||
|
// как и раньше. ?? null, а не || null: ноль часов — значимое значение.
|
||||||
|
[FIELDS.slaHours]: approver.slaHours ?? null,
|
||||||
|
[FIELDS.askOverdueReason]: approver.askOverdueReason ?? null,
|
||||||
|
[FIELDS.autoApprove]: approver.autoApprove ?? false,
|
||||||
...(delegatedById ? { [`${FIELDS.delegatedBy}Id`]: delegatedById } : {}),
|
...(delegatedById ? { [`${FIELDS.delegatedBy}Id`]: delegatedById } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import '@pnp/sp/webs';
|
|||||||
import '@pnp/sp/lists';
|
import '@pnp/sp/lists';
|
||||||
import { IList } from '@pnp/sp/lists';
|
import { IList } from '@pnp/sp/lists';
|
||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import { ApprovalListService } from './approvalList.service';
|
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||||
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
|
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
|
||||||
import { IApprovalRow } from './models/IApprovalRow';
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
import { emitRequestsChanged } from '../requestEvents';
|
import { emitRequestsChanged } from '../requestEvents';
|
||||||
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
|
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
|
||||||
import { planStepAdd } from './routeStepAdd';
|
import { IStepAddRow, planStepAdd } from './routeStepAdd';
|
||||||
|
|
||||||
function isActiveStatus(status: string): boolean {
|
function isActiveStatus(status: string): boolean {
|
||||||
return status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.onExecution;
|
return status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.onExecution;
|
||||||
@@ -209,6 +209,40 @@ export class ApproverService {
|
|||||||
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
|
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавить в поданную заявку исполнителей, которых привела правка полей
|
||||||
|
* маршрута согласующим (КОГО — решает planMissingExecutors). Каждый встаёт в
|
||||||
|
* КОНЕЦ исполнения «Ждёт очереди», по тем же правилам, что addStep
|
||||||
|
* (planStepAdd), но без права редактора маршрута: людей назначает маршрут из
|
||||||
|
* админки, а не пользователь. SLA шага переносится из маршрута.
|
||||||
|
*
|
||||||
|
* Возвращает Id строк, ставших активными (обычно пусто); им нужно оповещение.
|
||||||
|
*/
|
||||||
|
public async addMissingExecutors(requestId: number, executors: IApproverToWrite[]): Promise<number[]> {
|
||||||
|
if (executors.length === 0) return [];
|
||||||
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
|
const activeBefore = new Set(rows.filter((r) => isActiveStatus(r.status)).map((r) => r.id));
|
||||||
|
|
||||||
|
// По одному и с локальным дописыванием rows: иначе второй исполнитель
|
||||||
|
// получил бы тот же order, что первый, и стал бы параллельным шагом.
|
||||||
|
const planned: IStepAddRow[] = rows.slice();
|
||||||
|
for (const executor of executors) {
|
||||||
|
const approverId = await this.approval.getUserIdByEmail(executor.email);
|
||||||
|
const plan = planStepAdd(planned, APPROVAL_PHASE.execution, approverId);
|
||||||
|
if (!plan.ok) throw new Error(plan.error);
|
||||||
|
await this.approval.addApproverRow(requestId, executor, plan.order, REQUEST_STATUS.queued, APPROVAL_PHASE.execution);
|
||||||
|
planned.push({ approverId, order: plan.order, status: REQUEST_STATUS.queued, phase: APPROVAL_PHASE.execution });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.approval.activateNextQueued(requestId);
|
||||||
|
const rowsAfter = await this.approval.getForRequest(requestId);
|
||||||
|
|
||||||
|
// Новым исполнителям — права на элемент данных (см. addStep).
|
||||||
|
await this.touchRequest(requestId);
|
||||||
|
emitRequestsChanged();
|
||||||
|
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
|
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
|
||||||
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
|
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
@import '../../../../../styles/design-tokens';
|
@import '../../../../../styles/design-tokens';
|
||||||
|
|
||||||
// Ширина колонки со временем — под «29.09.2026 11:02»; уже — текст переносится.
|
// Кружок события на линии ленты.
|
||||||
$time-col-width: 124px;
|
$marker-size: 24px;
|
||||||
// Ниже этой ширины время встаёт над текстом события, а не слева от него.
|
// Толщина линии, соединяющей события.
|
||||||
$feed-stack-breakpoint: 640px;
|
$line-width: 2px;
|
||||||
|
// Зазор между событиями — линия обязана его перекрыть, иначе рвётся.
|
||||||
|
$event-gap: $sp-4;
|
||||||
|
|
||||||
.feed {
|
.feed {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $sp-4;
|
gap: $sp-5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round {
|
.round {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $sp-2;
|
gap: $sp-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Номер отправки + плашка итога (см. RoundTitle). Капслок — только у номера:
|
||||||
|
// повесь его на весь заголовок, и плашка унаследовала бы его вместе с разрядкой.
|
||||||
.roundTitle {
|
.roundTitle {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-1 $sp-2;
|
||||||
padding-bottom: $sp-1;
|
padding-bottom: $sp-1;
|
||||||
border-bottom: 1px solid $gray-200;
|
border-bottom: 1px solid $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roundNumber {
|
||||||
font-size: $fs-overline;
|
font-size: $fs-overline;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 1.4px;
|
letter-spacing: 1.4px;
|
||||||
@@ -30,62 +41,122 @@ $feed-stack-breakpoint: 640px;
|
|||||||
.events {
|
.events {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $sp-3;
|
gap: $event-gap;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event {
|
.event {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: $sp-3;
|
gap: $sp-3;
|
||||||
font-size: $fs-sm;
|
font-size: $fs-sm;
|
||||||
color: $gray-900;
|
color: $gray-900;
|
||||||
}
|
|
||||||
|
|
||||||
.current {
|
// Линия от кружка к следующему событию — лента читается как хронология, а не
|
||||||
padding: $sp-2 $sp-3;
|
// как стопка одинаковых строк. У последнего события отправки линии нет:
|
||||||
border-radius: $r-sm;
|
// отправки разделены заголовками, а не одной сплошной нитью.
|
||||||
background: $gold-50;
|
&:not(:last-child)::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: $marker-size;
|
||||||
|
bottom: -$event-gap;
|
||||||
|
left: calc((#{$marker-size} - #{$line-width}) / 2);
|
||||||
|
width: $line-width;
|
||||||
|
background: $gray-200;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.marker {
|
.marker {
|
||||||
flex: 0 0 auto;
|
display: flex;
|
||||||
width: 20px;
|
flex: 0 0 $marker-size;
|
||||||
text-align: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: $marker-size;
|
||||||
|
height: $marker-size;
|
||||||
|
border: 1.5px solid;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.4;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markerNeutral { color: $navy-700; }
|
// Цвета — те же смысловые, что у плашки события и полосы комментария
|
||||||
.markerPositive { color: $green-600; }
|
// (см. historyKinds.ts): кружок, плашка и комментарий одного события — одного цвета.
|
||||||
.markerWarning { color: $amber-700; }
|
.markerNeutral {
|
||||||
.markerNegative { color: $red-500; }
|
border-color: $navy-600;
|
||||||
.markerCurrent { color: $gold-600; }
|
background: $navy-50;
|
||||||
|
color: $navy-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerPositive {
|
||||||
|
border-color: $green-600;
|
||||||
|
background: $green-50;
|
||||||
|
color: $green-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerWarning {
|
||||||
|
border-color: $gold-500;
|
||||||
|
background: $gold-100;
|
||||||
|
color: $amber-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerNegative {
|
||||||
|
border-color: $red-500;
|
||||||
|
background: $red-50;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerCurrent {
|
||||||
|
border-color: $gold-500;
|
||||||
|
background: $gold-50;
|
||||||
|
color: $gold-600;
|
||||||
|
animation: current-pulse 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расходящееся кольцо вокруг кружка «сейчас».
|
||||||
|
@keyframes current-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba($gold-500, .45); }
|
||||||
|
70% { box-shadow: 0 0 0 8px rgba($gold-500, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba($gold-500, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.markerCurrent {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.eventBody {
|
.eventBody {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: $sp-1;
|
||||||
min-width: 0;
|
min-width: 0; // иначе длинный комментарий распирает ленту вбок
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventLine {
|
// Плашка + время + длительность. На узкой ширине переносятся сами (flex-wrap),
|
||||||
|
// отдельный медиазапрос не нужен. min-height — чтобы плашка стояла вровень
|
||||||
|
// с центром кружка.
|
||||||
|
.eventHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: $sp-1 $sp-3;
|
gap: $sp-1 $sp-2;
|
||||||
|
min-height: $marker-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
flex: 0 0 $time-col-width;
|
font-size: $fs-label;
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: $feed-stack-breakpoint) {
|
.actor {
|
||||||
flex-basis: 100%;
|
overflow-wrap: anywhere;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
@@ -93,12 +164,6 @@ $feed-stack-breakpoint: 640px;
|
|||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment {
|
|
||||||
color: $gray-700;
|
|
||||||
font-style: italic;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
font-size: $fs-label;
|
font-size: $fs-label;
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ApprovalListService } from '../approvalList.service';
|
import { ApprovalListService } from '../approvalList.service';
|
||||||
import { IApprovalHistory } from './historyModel';
|
import { IApprovalHistory, roundOutcome } from './historyModel';
|
||||||
import { loadApprovalHistory } from './approvalHistory.service';
|
import { loadApprovalHistory } from './approvalHistory.service';
|
||||||
import { CurrentHolderRow } from './CurrentHolderRow';
|
import { CurrentHolderRow } from './CurrentHolderRow';
|
||||||
import { HistoryEventRow } from './HistoryEventRow';
|
import { HistoryEventRow } from './HistoryEventRow';
|
||||||
|
import { RoundTitle } from './RoundTitle';
|
||||||
import { HISTORY_TEXT } from './historyTexts';
|
import { HISTORY_TEXT } from './historyTexts';
|
||||||
import styles from './ApprovalHistory.module.scss';
|
import styles from './ApprovalHistory.module.scss';
|
||||||
|
|
||||||
@@ -49,18 +50,22 @@ export const ApprovalHistoryFeed: React.FC<Props> = ({ approval, dataListRef, re
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.feed}>
|
<div className={styles.feed}>
|
||||||
{rounds.map((round, i) => (
|
{rounds.map((round, i) => {
|
||||||
|
// «Сейчас» — только у последней (верхней) отправки: прошлые уже закончились.
|
||||||
|
const isLatest = i === 0;
|
||||||
|
const isActive = isLatest && current.length > 0;
|
||||||
|
return (
|
||||||
<section key={round.number} className={styles.round}>
|
<section key={round.number} className={styles.round}>
|
||||||
<div className={styles.roundTitle}>{HISTORY_TEXT.round(round.number)}</div>
|
<RoundTitle number={round.number} outcome={roundOutcome(round, isActive)} />
|
||||||
<ul className={styles.events}>
|
<ul className={styles.events}>
|
||||||
{/* «Сейчас» — только у последней (верхней) отправки. */}
|
{isLatest && current.map((holder, j) => <CurrentHolderRow key={`current-${j}`} holder={holder} />)}
|
||||||
{i === 0 && current.map((holder, j) => <CurrentHolderRow key={`current-${j}`} holder={holder} />)}
|
|
||||||
{round.events.map((event, j) => (
|
{round.events.map((event, j) => (
|
||||||
<HistoryEventRow key={`${event.time}-${event.kind}-${j}`} event={event} />
|
<HistoryEventRow key={`${event.time}-${event.kind}-${j}`} event={event} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<div className={styles.note}>{HISTORY_TEXT.partialNote}</div>
|
<div className={styles.note}>{HISTORY_TEXT.partialNote}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { Badge } from '@tnsu/ui-kit';
|
||||||
import { formatDuration, ICurrentHolder } from './historyModel';
|
import { formatDuration, ICurrentHolder } from './historyModel';
|
||||||
import { HISTORY_TEXT } from './historyTexts';
|
import { HISTORY_TEXT } from './historyTexts';
|
||||||
import styles from './ApprovalHistory.module.scss';
|
import styles from './ApprovalHistory.module.scss';
|
||||||
@@ -11,16 +12,22 @@ interface Props {
|
|||||||
export const CurrentHolderRow: React.FC<Props> = ({ holder }) => {
|
export const CurrentHolderRow: React.FC<Props> = ({ holder }) => {
|
||||||
const waitedMs = holder.since ? Date.now() - new Date(holder.since).getTime() : NaN;
|
const waitedMs = holder.since ? Date.now() - new Date(holder.since).getTime() : NaN;
|
||||||
return (
|
return (
|
||||||
<li className={`${styles.event} ${styles.current}`}>
|
<li className={styles.event}>
|
||||||
|
{/* Пульсирующий кружок — единственное «живое» место ленты: всё остальное уже
|
||||||
|
случилось, а это ещё ждёт. */}
|
||||||
<span className={`${styles.marker} ${styles.markerCurrent}`} aria-hidden="true">
|
<span className={`${styles.marker} ${styles.markerCurrent}`} aria-hidden="true">
|
||||||
●
|
●
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.eventBody}>
|
<div className={styles.eventBody}>
|
||||||
<div className={styles.eventLine}>
|
<div className={styles.eventHead}>
|
||||||
<span className={styles.time}>{HISTORY_TEXT.now}</span>
|
<Badge variant="info" size="sm">
|
||||||
<strong>{holder.person || '—'}</strong>
|
{HISTORY_TEXT.awaiting}
|
||||||
|
</Badge>
|
||||||
{waitedMs > 0 && <span className={styles.meta}>{HISTORY_TEXT.waiting(formatDuration(waitedMs))}</span>}
|
{waitedMs > 0 && <span className={styles.meta}>{HISTORY_TEXT.waiting(formatDuration(waitedMs))}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.actor}>
|
||||||
|
<strong>{holder.person || '—'}</strong>
|
||||||
|
</div>
|
||||||
{holder.stepTitle && <div className={styles.meta}>{holder.stepTitle}</div>}
|
{holder.stepTitle && <div className={styles.meta}>{holder.stepTitle}</div>}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { formatDuration, HistoryEventKind, IHistoryEvent } from './historyModel';
|
import { Badge } from '@tnsu/ui-kit';
|
||||||
import { eventAction, HISTORY_TEXT } from './historyTexts';
|
import { ApprovalComment, CommentTone } from '../ui/ApprovalComment';
|
||||||
|
import { formatDuration, IHistoryEvent } from './historyModel';
|
||||||
|
import { BADGE_BY_TONE, KIND_VIEW } from './historyKinds';
|
||||||
|
import { eventDetails, eventLabel, HISTORY_TEXT } from './historyTexts';
|
||||||
import styles from './ApprovalHistory.module.scss';
|
import styles from './ApprovalHistory.module.scss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: IHistoryEvent;
|
event: IHistoryEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Значок и цвет события — по смыслу решения, как у статусов в StatusBadge.
|
const MARKER_CLASS: Record<CommentTone, string> = {
|
||||||
const MARKER: Record<HistoryEventKind, { symbol: string; className: string }> = {
|
positive: styles.markerPositive,
|
||||||
submitted: { symbol: '↗', className: styles.markerNeutral },
|
negative: styles.markerNegative,
|
||||||
resubmitted: { symbol: '↗', className: styles.markerNeutral },
|
warning: styles.markerWarning,
|
||||||
approved: { symbol: '✓', className: styles.markerPositive },
|
neutral: styles.markerNeutral,
|
||||||
executed: { symbol: '✓', className: styles.markerPositive },
|
|
||||||
returned: { symbol: '↩', className: styles.markerWarning },
|
|
||||||
rejected: { symbol: '✕', className: styles.markerNegative },
|
|
||||||
delegated: { symbol: '→', className: styles.markerNeutral },
|
|
||||||
replaced: { symbol: '⇄', className: styles.markerNeutral },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatDateTime(iso: string): string {
|
export function formatDateTime(iso: string): string {
|
||||||
@@ -26,26 +24,35 @@ export function formatDateTime(iso: string): string {
|
|||||||
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Одно событие ленты: время · кто — что сделал · за сколько + комментарий. */
|
/**
|
||||||
|
* Одно событие ленты. Сверху — ЧТО произошло (цветная плашка) и когда: это то,
|
||||||
|
* за чем пользователь открывает историю. Ниже — кто, на каком шаге и почему.
|
||||||
|
*/
|
||||||
export const HistoryEventRow: React.FC<Props> = ({ event }) => {
|
export const HistoryEventRow: React.FC<Props> = ({ event }) => {
|
||||||
const marker = MARKER[event.kind];
|
const view = KIND_VIEW[event.kind];
|
||||||
|
const details = eventDetails(event);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={styles.event}>
|
<li className={styles.event}>
|
||||||
<span className={`${styles.marker} ${marker.className}`} aria-hidden="true">
|
<span className={`${styles.marker} ${MARKER_CLASS[view.tone]}`} aria-hidden="true">
|
||||||
{marker.symbol}
|
{view.symbol}
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.eventBody}>
|
<div className={styles.eventBody}>
|
||||||
<div className={styles.eventLine}>
|
<div className={styles.eventHead}>
|
||||||
|
<Badge variant={BADGE_BY_TONE[view.tone]} size="sm">
|
||||||
|
{eventLabel(event.kind)}
|
||||||
|
</Badge>
|
||||||
<span className={styles.time}>{formatDateTime(event.time)}</span>
|
<span className={styles.time}>{formatDateTime(event.time)}</span>
|
||||||
<span>
|
|
||||||
<strong>{event.actor || '—'}</strong> — {eventAction(event)}
|
|
||||||
</span>
|
|
||||||
{event.durationMs !== undefined && (
|
{event.durationMs !== undefined && (
|
||||||
<span className={styles.meta}>{HISTORY_TEXT.took(formatDuration(event.durationMs))}</span>
|
<span className={styles.meta}>{HISTORY_TEXT.took(formatDuration(event.durationMs))}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.actor}>
|
||||||
|
<strong>{event.actor || '—'}</strong>
|
||||||
|
{details && <span className={styles.meta}> · {details}</span>}
|
||||||
|
</div>
|
||||||
{event.stepTitle && <div className={styles.meta}>{event.stepTitle}</div>}
|
{event.stepTitle && <div className={styles.meta}>{event.stepTitle}</div>}
|
||||||
{event.comment && <div className={styles.comment}>«{event.comment}»</div>}
|
{event.comment && <ApprovalComment text={event.comment} tone={view.tone} />}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Badge } from '@tnsu/ui-kit';
|
||||||
|
import { RoundOutcome } from './historyModel';
|
||||||
|
import { outcomeBadge } from './historyKinds';
|
||||||
|
import { HISTORY_TEXT, outcomeLabel } from './historyTexts';
|
||||||
|
import styles from './ApprovalHistory.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
number: number;
|
||||||
|
/** Чем закончилась отправка (см. roundOutcome); нет — только номер. */
|
||||||
|
outcome?: RoundOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заголовок отправки: номер + плашка итога. По одним заголовкам видна вся
|
||||||
|
* судьба заявки («вернули, вернули, в работе») без чтения событий.
|
||||||
|
*/
|
||||||
|
export const RoundTitle: React.FC<Props> = ({ number, outcome }) => (
|
||||||
|
<div className={styles.roundTitle}>
|
||||||
|
<span className={styles.roundNumber}>{HISTORY_TEXT.round(number)}</span>
|
||||||
|
{outcome && (
|
||||||
|
<Badge variant={outcomeBadge(outcome)} size="sm">
|
||||||
|
{outcomeLabel(outcome)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { BadgeVariant } from '@tnsu/ui-kit';
|
||||||
|
import { CommentTone } from '../ui/ApprovalComment';
|
||||||
|
import { HistoryEventKind, RoundOutcome } from './historyModel';
|
||||||
|
|
||||||
|
/** Как событие выглядит в ленте: значок в кружке и цвет (кружок, плашка, комментарий). */
|
||||||
|
export interface IKindView {
|
||||||
|
symbol: string;
|
||||||
|
tone: CommentTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Цвет — по смыслу решения, как у статусов в StatusBadge: зелёное — пройдено,
|
||||||
|
// золотое — вернули, красное — отказ, синее — служебное движение заявки.
|
||||||
|
export const KIND_VIEW: Record<HistoryEventKind, IKindView> = {
|
||||||
|
submitted: { symbol: '↗', tone: 'neutral' },
|
||||||
|
resubmitted: { symbol: '↗', tone: 'neutral' },
|
||||||
|
approved: { symbol: '✓', tone: 'positive' },
|
||||||
|
executed: { symbol: '✓', tone: 'positive' },
|
||||||
|
returned: { symbol: '↩', tone: 'warning' },
|
||||||
|
rejected: { symbol: '✕', tone: 'negative' },
|
||||||
|
delegated: { symbol: '→', tone: 'neutral' },
|
||||||
|
replaced: { symbol: '⇄', tone: 'neutral' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BADGE_BY_TONE: Record<CommentTone, BadgeVariant> = {
|
||||||
|
positive: 'success',
|
||||||
|
negative: 'danger',
|
||||||
|
warning: 'warning',
|
||||||
|
neutral: 'neutral',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Цвет плашки итога отправки: у решения — тот же, что у его события в ленте;
|
||||||
|
* «в работе» — синий, как плашка «Ожидает решения» у текущего шага.
|
||||||
|
*/
|
||||||
|
export function outcomeBadge(outcome: RoundOutcome): BadgeVariant {
|
||||||
|
return outcome === 'inProgress' ? 'info' : BADGE_BY_TONE[KIND_VIEW[outcome].tone];
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
buildApprovalHistory,
|
buildApprovalHistory,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
|
HistoryEventKind,
|
||||||
|
IHistoryRound,
|
||||||
IRequestVersion,
|
IRequestVersion,
|
||||||
IRouteRowVersion,
|
IRouteRowVersion,
|
||||||
|
roundOutcome,
|
||||||
rowEvents,
|
rowEvents,
|
||||||
submitEvents,
|
submitEvents,
|
||||||
} from './historyModel';
|
} from './historyModel';
|
||||||
@@ -131,6 +134,44 @@ describe('buildApprovalHistory', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Отправка из событий заданных видов — в том же порядке, что в ленте: НОВЫЕ СВЕРХУ. */
|
||||||
|
function round(...kinds: HistoryEventKind[]): IHistoryRound {
|
||||||
|
return { number: 1, events: kinds.map((kind) => ({ kind, time: '2026-09-24T10:00:00Z', actor: 'Анна Ким' })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('roundOutcome', () => {
|
||||||
|
it('возврат — итог отправки', () => {
|
||||||
|
expect(roundOutcome(round('returned', 'approved', 'submitted'), false)).toBe('returned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отказ — итог отправки', () => {
|
||||||
|
expect(roundOutcome(round('rejected', 'submitted'), false)).toBe('rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('возврат важнее «в работе»: вернули, а заново ещё не отправили', () => {
|
||||||
|
expect(roundOutcome(round('returned', 'submitted'), true)).toBe('returned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('заявка сейчас у кого-то на шаге — «в работе», даже если часть шагов уже согласована', () => {
|
||||||
|
expect(roundOutcome(round('resubmitted'), true)).toBe('inProgress');
|
||||||
|
expect(roundOutcome(round('approved', 'resubmitted'), true)).toBe('inProgress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без возврата и не в работе — самое свежее решение', () => {
|
||||||
|
expect(roundOutcome(round('approved', 'approved', 'resubmitted'), false)).toBe('approved');
|
||||||
|
expect(roundOutcome(round('executed', 'approved', 'submitted'), false)).toBe('executed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('делегирование и замена решением не считаются', () => {
|
||||||
|
expect(roundOutcome(round('delegated', 'approved', 'submitted'), false)).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('решений нет — итога нет', () => {
|
||||||
|
expect(roundOutcome(round('delegated', 'submitted'), false)).toBeUndefined();
|
||||||
|
expect(roundOutcome(round(), false)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('formatDuration', () => {
|
describe('formatDuration', () => {
|
||||||
it('минуты, часы, дни', () => {
|
it('минуты, часы, дни', () => {
|
||||||
expect(formatDuration(30 * 1000)).toBe('1 мин');
|
expect(formatDuration(30 * 1000)).toBe('1 мин');
|
||||||
|
|||||||
@@ -181,6 +181,42 @@ export function buildApprovalHistory(rows: IRouteRowVersion[][], requestVersions
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Виды событий-решений — только ими может закончиться отправка. */
|
||||||
|
export type DecisionKind = 'approved' | 'executed' | 'returned' | 'rejected';
|
||||||
|
|
||||||
|
/** Итог отправки для плашки в её заголовке. 'inProgress' — заявка сейчас у кого-то на шаге. */
|
||||||
|
export type RoundOutcome = DecisionKind | 'inProgress';
|
||||||
|
|
||||||
|
const DECISION_EVENT_KINDS: HistoryEventKind[] = ['approved', 'executed', 'returned', 'rejected'];
|
||||||
|
// Решения, после которых отправка заканчивается: дальше автор либо правит
|
||||||
|
// и отправляет заново (новая отправка), либо заявка закрыта отказом.
|
||||||
|
const CLOSING_KINDS: DecisionKind[] = ['returned', 'rejected'];
|
||||||
|
|
||||||
|
function isDecisionKind(kind: HistoryEventKind): kind is DecisionKind {
|
||||||
|
return DECISION_EVENT_KINDS.indexOf(kind) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чем закончилась отправка. Правила по порядку:
|
||||||
|
* 1. был возврат или отказ — это итог (отправка на нём оборвалась);
|
||||||
|
* 2. заявка сейчас у кого-то на шаге — «в работе»;
|
||||||
|
* 3. иначе — последнее решение («согласовано» / «выполнено»);
|
||||||
|
* 4. решений нет (история сохранилась не полностью) — итога нет.
|
||||||
|
* @param round отправка; события в ней НОВЫЕ СВЕРХУ (см. buildApprovalHistory)
|
||||||
|
* @param isActive у кого-то на шаге сейчас именно эта отправка — имеет смысл
|
||||||
|
* только для последней (верхней)
|
||||||
|
*/
|
||||||
|
export function roundOutcome(round: IHistoryRound, isActive: boolean): RoundOutcome | undefined {
|
||||||
|
for (const event of round.events) {
|
||||||
|
if (isDecisionKind(event.kind) && CLOSING_KINDS.indexOf(event.kind) >= 0) return event.kind;
|
||||||
|
}
|
||||||
|
if (isActive) return 'inProgress';
|
||||||
|
for (const event of round.events) {
|
||||||
|
if (isDecisionKind(event.kind)) return event.kind;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const MINUTE_MS = 60 * 1000;
|
const MINUTE_MS = 60 * 1000;
|
||||||
const HOUR_MS = 60 * MINUTE_MS;
|
const HOUR_MS = 60 * MINUTE_MS;
|
||||||
const DAY_MS = 24 * HOUR_MS;
|
const DAY_MS = 24 * HOUR_MS;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user