2 Commits

Author SHA1 Message Date
gorbenko.pavel 4ad2a19a70 Реестр пакетов и ссылки на git.tnsu.kz — по https
Tests / frontend-test (pull_request) Successful in 4m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:02:04 +05:00
gorbenko.pavel 95b0f8f21e Реестр пакетов: организация переименована Tansu → tansu
Tests / frontend-test (pull_request) Successful in 4m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:35:15 +05:00
94 changed files with 270 additions and 2948 deletions
+1 -1
View File
@@ -1 +1 @@
@tnsu:registry=http://git.tnsu.kz/api/packages/Tansu/npm/ @tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
+2 -2
View File
@@ -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
View File
@@ -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 видов изменения (один, данные под несколько), поля по каждому виду по ТЗ кадров (кроме «Продления срока ТД»); инициатор работник/работодатель; подписанные вложения по «Что приложить» | ⏳ код готов (кроме вида 6), списки/тип/маршрут не заведены | | [`hr-contract-change`](../src/webparts/requestPortal/forms/ContractChange/README.md) | Изменение условий трудового договора | Отдел кадров | да | — | да | сотрудник из `Employee/FullBase` (и без учётки M365), снимок в текстовых колонках; маршрут по сотруднику, без учётки — по заявителю; 10 видов изменения (один, данные под несколько); поля по видам — в работе | ⏳ каркас, списки/тип/маршрут не заведены |
| [`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
View File
@@ -9887,7 +9887,7 @@
}, },
"node_modules/@tnsu/ui-kit": { "node_modules/@tnsu/ui-kit": {
"version": "0.14.0", "version": "0.14.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.14.0/ui-kit-0.14.0.tgz",
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==", "integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
@@ -156,7 +156,6 @@ 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="Исполнитель"
@@ -315,7 +315,6 @@ 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="Исполнитель"
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import styles from './ContractChangeForm.module.scss'; import styles from './ContractChangeForm.module.scss';
import { import {
Alert, Alert,
@@ -18,14 +18,20 @@ 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';
@@ -40,12 +46,9 @@ 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 { prepareFilesFor } from './byType/attachmentSlots'; import { missingRequiredAttachments, 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';
@@ -110,21 +113,33 @@ 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 { matrix, executors } = useContractRoute({ const routeUserEmail = routeEmail(employeeControl.person, applicantEmail);
const loadMatrix = useCallback(
() =>
loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
getPlannedMatrix(context, requestTypeId as number, routeUserEmail, approval, 'Approval')
),
[context, approval, editId, requestStatus, routeUserEmail, requestTypeId]
);
const matrix = useMatrixEditor(
loadMatrix,
`${routeUserEmail}-${editId ?? 'new'}`,
!readOnly,
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!routeUserEmail && !!requestTypeId)),
(login) => approval.getJobTitle(login)
);
const executors = useFormExecutors({
readOnly,
context, context,
approval, approval,
requestTypeId, requestTypeId,
routeUserEmail: routeEmail(employeeControl.person, applicantEmail), userEmail: routeUserEmail,
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;
@@ -135,6 +150,17 @@ 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;
@@ -147,7 +173,7 @@ const ContractChangeForm: React.FC<IRequestFormProps> = ({
requestId, requestId,
rtId, rtId,
data, data,
matrixToApprovers(matrix.rows), toApprovers(matrix.rows),
executorsToWrite(executors.rows), executorsToWrite(executors.rows),
status, status,
writeListId writeListId
@@ -181,9 +207,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 typeError = changeTypeSubmitError(data, attachments); const missingFiles = missingRequiredAttachments(data, attachments);
if (typeError) return typeError; if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
if (matrix.error) { if (matrix.error) {
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
} }
@@ -253,7 +279,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} {...headerProps} /> <RequestSection data={data} set={set} context={context} employeeControl={employeeControl} />
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} /> <ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
</FormModeProvider> </FormModeProvider>
@@ -8,12 +8,10 @@
`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; маршрут А/Б —
> по инициатору), печатный бланк, заведение типа и маршрутов в админке.
## Маршрут ## Маршрут
- Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает). - Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает).
@@ -62,43 +60,7 @@ RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины | | Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно | | Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
| Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно | | Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно |
| Перевод в другое подразделение | `DepartmentTransfer` | новое подразделение (`DepartmentPicker`, имя + id), дата перевода, причина (усиление подразделения / перераспределение функций / вакансия / иное), обоснование, **принимающий руководитель** (People, обязателен при отправке) — см. ниже | | Остальные 7 | — | ещё не разобраны с кадрами |
| Изменение места работы (другая местность) | `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): фиксированные
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
@@ -1,7 +1,8 @@
import * as React from 'react'; import * as React from 'react';
import { FormGrid, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui'; import { CollapseSection, 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';
@@ -14,13 +15,13 @@ import styles from './AllowanceSection.module.scss';
* (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение * (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение
* «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ). * «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ).
* *
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал. * Поля — прямо в CollapseSection, без 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 (
<FormGrid> <CollapseSection title={CHANGE_TYPE_LABELS.allowance} icon="calculator">
<FormField label="Основание" htmlFor="AllowanceReason" full> <FormField label="Основание" htmlFor="AllowanceReason" full>
<Select <Select
id="AllowanceReason" id="AllowanceReason"
@@ -98,6 +99,6 @@ export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachmen
required required
/> />
)} )}
</FormGrid> </CollapseSection>
); );
}; };
@@ -1,6 +0,0 @@
// Поля вида идут сразу под шапкой (сотрудник · вид · инициатор) отдельной сеткой
// FormGrid. У сетки ui-kit нет внешнего отступа, а расстояние между строками полей —
// 18px (row-gap FormGrid): тот же отступ, чтобы стык двух сеток был не виден.
.typeFields {
margin-top: 18px;
}
@@ -4,13 +4,6 @@ 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: поля видов добавляются по мере
@@ -20,28 +13,15 @@ import styles from './ChangeTypeSections.module.scss';
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 ? ( return Section ? <Section key={type} {...props} /> : undefined;
<div key={type} className={styles.typeFields}>
<Section {...props} />
</div>
) : undefined;
})} })}
</> </>
); );
@@ -1,62 +0,0 @@
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>
);
@@ -1,13 +0,0 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины перевода в другое подразделение — по шаблону текста ТЗ кадров (а–г).
* Работника и работодателя не различаем (как в переводе на должность).
* Значения совпадают с choices колонки DepartmentTransferReason (provisioning.json).
*/
export const DEPARTMENT_TRANSFER_REASONS: ISelectOption[] = [
'Усиление подразделения (рост объёма работ / запуск нового объекта)',
'Перераспределение функций между подразделениями',
'Вакансия, соответствующая квалификации работника',
'Иное'
].map((reason) => ({ value: reason, label: reason }));
@@ -1,55 +0,0 @@
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('Руководитель');
});
});
@@ -1,68 +0,0 @@
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]);
}
@@ -1,15 +0,0 @@
@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;
}
@@ -1,179 +0,0 @@
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>
);
};
@@ -1,36 +0,0 @@
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 = 'Документ-обоснование';
@@ -1,43 +0,0 @@
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');
});
});
@@ -1,50 +0,0 @@
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 };
}
@@ -1,49 +0,0 @@
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>
);
};
@@ -1,5 +0,0 @@
/**
* Вложение «Новая должностная инструкция» — по ТЗ «приложить документ», обязательно.
* Отдельная подпись от «Должностной инструкции» перевода на должность: это разные слоты.
*/
export const NEW_JOB_DESCRIPTION_ATTACHMENT = 'Новая должностная инструкция';
@@ -1,41 +0,0 @@
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>
</>
);
};
@@ -1,49 +0,0 @@
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>
)}
</>
);
@@ -1,9 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import { FormGrid, FormField, TextInput } from '../../../../shared/ui'; import { CollapseSection, FormField, TextInput, TextArea, Select } 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
* причина (а–г), обоснование и обязательная должностная инструкция. Текущая * причина (а–г), обоснование и обязательная должностная инструкция. Текущая
* должность уже видна в «Организации» — отдельного «было» нет. * должность уже видна в «Организации» — отдельного «было» нет.
* *
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал. * Поля — прямо в CollapseSection, без 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 (
<FormGrid> <CollapseSection title={CHANGE_TYPE_LABELS['position-transfer']} icon="people">
<FormField label="Новая должность" htmlFor="PositionTransferNewPosition"> <FormField label="Новая должность" htmlFor="PositionTransferNewPosition">
<PositionPicker <PositionPicker
context={context} context={context}
@@ -37,20 +37,22 @@ export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, co
onChange={(v) => set('PositionTransferDate', v)} onChange={(v) => set('PositionTransferDate', v)}
/> />
</FormField> </FormField>
<InitiatorReasonFields <FormField label="Причина" htmlFor="PositionTransferReason" full>
initiator={data.ChangeInitiator} <Select
reason={{ id="PositionTransferReason"
id: 'PositionTransferReason', value={data.PositionTransferReason}
value: data.PositionTransferReason, onChange={(v) => set('PositionTransferReason', v)}
options: POSITION_TRANSFER_REASONS, options={POSITION_TRANSFER_REASONS}
onChange: (v) => set('PositionTransferReason', v) placeholder="— выберите —"
}}
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}
@@ -61,6 +63,6 @@ export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, co
required required
/> />
)} )}
</FormGrid> </CollapseSection>
); );
}; };
@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { FormGrid, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui'; import { CollapseSection, 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';
@@ -8,10 +9,11 @@ import { SALARY_INCREASE_REASONS } from './reasons';
* кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные, * кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные,
* без ограничений — по решению от 2026-10-28. * без ограничений — по решению от 2026-10-28.
* *
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал. * Поля — прямо в CollapseSection, без FormGrid: тело секции само является
* двухколоночной сеткой, вложенная FormGrid сжала бы всё в одну её ячейку.
*/ */
export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => ( export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => (
<FormGrid> <CollapseSection title={CHANGE_TYPE_LABELS['salary-increase']} icon="calculator">
<FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent"> <FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent">
<MoneyInput <MoneyInput
id="SalaryIncreaseCurrent" id="SalaryIncreaseCurrent"
@@ -50,5 +52,5 @@ export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) =>
onChange={(v) => set('SalaryIncreaseJustification', v)} onChange={(v) => set('SalaryIncreaseJustification', v)}
/> />
</FormField> </FormField>
</FormGrid> </CollapseSection>
); );
@@ -1,153 +0,0 @@
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>
);
};
@@ -1,23 +0,0 @@
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 = 'Новый график';
@@ -1,15 +0,0 @@
@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;
}
@@ -1,99 +0,0 @@
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>
);
@@ -1,17 +0,0 @@
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} месяцев, и дата окончания не раньше даты начала.`;
@@ -1,23 +0,0 @@
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();
});
});
@@ -1,25 +0,0 @@
/**
* Временный перевод — не дольше 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())}`;
}
@@ -1,8 +0,0 @@
@import '../../../../../../styles/design-tokens';
// Ошибка загрузки справочника под полем (не прячем за пустым списком).
.fieldError {
margin-top: $sp-1;
font-size: $fs-label;
color: $red-500;
}
@@ -1,132 +0,0 @@
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>
);
};
@@ -1,12 +0,0 @@
import { ISelectOption } from '../../../../shared/ui';
/**
* Причины изменения места работы — по шаблону текста ТЗ кадров (а–в).
* Работника и работодателя не различаем (как в видах 3–4).
* Значения совпадают с choices колонки WorkplaceChangeReason (provisioning.json).
*/
export const WORKPLACE_CHANGE_REASONS: ISelectOption[] = [
'Открытие нового объекта',
'Переезд подразделения / работодателя в другую местность',
'Иное'
].map((reason) => ({ value: reason, label: reason }));
@@ -1,48 +0,0 @@
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,9 +3,6 @@ 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)
@@ -26,17 +23,6 @@ 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;
} }
@@ -49,16 +35,14 @@ 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 && !hasAttachment(editor, slot.category)) .filter((slot) => slot.required)
.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);
} }
@@ -1,83 +0,0 @@
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;
}
@@ -1,38 +0,0 @@
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,12 +28,13 @@ 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': 'Временный перевод при производственной необходимости / простое'
}; };
@@ -48,39 +49,6 @@ 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,9 +2,8 @@ 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, initiatorRoleFromText } from './changeTypes'; import { ChangeType, changeTypeFromLabel } 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 {
@@ -89,7 +88,6 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
'EmployeePosition', 'EmployeePosition',
'EmployeeDepartment', 'EmployeeDepartment',
'ChangeTypes', 'ChangeTypes',
'ChangeInitiator',
'SalaryIncreaseCurrent', 'SalaryIncreaseCurrent',
'SalaryIncreaseNew', 'SalaryIncreaseNew',
'SalaryIncreaseDate', 'SalaryIncreaseDate',
@@ -105,76 +103,13 @@ export async function loadContractChangeRequest(itemId: number, listId?: string)
'PositionTransferNewPosition', 'PositionTransferNewPosition',
'PositionTransferDate', 'PositionTransferDate',
'PositionTransferReason', 'PositionTransferReason',
'PositionTransferJustification', 'PositionTransferJustification'
'DepartmentTransferNewDepartment', )()) as Record<string, unknown>;
'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 — иначе из-за часового пояса уезжает на день назад.
@@ -191,69 +126,7 @@ 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,9 +1,8 @@
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, InitiatorRole, INITIATOR_EMPLOYEE, INITIATOR_EMPLOYER } from './changeTypes'; import { ChangeType } 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';
/** /**
@@ -34,12 +33,6 @@ export interface IFormData {
* задел под несколько, см. changeTypes.ts. [] — не выбран. * задел под несколько, см. changeTypes.ts. [] — не выбран.
*/ */
ChangeTypes: ChangeType[]; ChangeTypes: ChangeType[];
/**
* Инициатор — только у видов, где по ТЗ им может быть и работник
* (hasInitiatorChoice). От роли зависят поля: у работника — текст заявления,
* у работодателя — причины + обоснование. '' — не выбран / у вида выбора нет.
*/
ChangeInitiator: InitiatorRole | '';
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные. // ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */ /** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
@@ -81,126 +74,6 @@ 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;
} }
/** Обновление одного поля формы с сохранением типа значения. */ /** Обновление одного поля формы с сохранением типа значения. */
@@ -218,7 +91,6 @@ 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: '',
@@ -234,67 +106,7 @@ 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: ''
}; };
/** Плейсхолдер для поля выбора сотрудника. */ /** Плейсхолдер для поля выбора сотрудника. */
@@ -310,40 +122,12 @@ 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,12 +20,11 @@
"Перевод в другое подразделение", "Перевод в другое подразделение",
"Изменение места работы (другая местность)", "Изменение места работы (другая местность)",
"Продление срока трудового договора", "Продление срока трудового договора",
"Изменение режима / графика работы по соглашению сторон", "Изменение режима / графика работы по соглашению сторон; неполное рабочее время по просьбе работника",
"Изменение трудовой функции (без смены должности)", "Изменение трудовой функции (без смены должности)",
"Изменение условий труда по инициативе работодателя", "Изменение условий труда по инициативе работодателя по причинам экономического, технологического характера, изменений в организации и (или) сокращения объёма работ (снижение оклада, сокращение рабочего времени, изменение графика)",
"Временный перевод при производственной необходимости / простое" "Временный перевод при производственной необходимости / простое"
] }, ] },
{ "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 },
@@ -59,90 +58,6 @@
"Открытие нового объекта / проекта, перераспределение функций", "Открытие нового объекта / проекта, перераспределение функций",
"Иное" "Иное"
] }, ] },
{ "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,21 +7,13 @@ import {
personFromWorkplace, personFromWorkplace,
workplaceKey workplaceKey
} from '../../../shared/dictionary'; } from '../../../shared/dictionary';
import { import { ChangeType, CHANGE_TYPE_OPTIONS } from '../changeTypes';
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';
@@ -29,13 +21,6 @@ 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;
@@ -45,14 +30,7 @@ const NOOP = (): void => undefined;
* в какой организации (у совместителя их несколько) и что именно меняется. * в какой организации (у совместителя их несколько) и что именно меняется.
* Поля конкретных видов — отдельными секциями (byType/). * Поля конкретных видов — отдельными секциями (byType/).
*/ */
export const RequestSection: React.FC<IRequestSectionProps> = ({ export const RequestSection: React.FC<IRequestSectionProps> = ({ data, set, context, employeeControl }) => {
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);
@@ -67,12 +45,7 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
// встанет MultiChoiceField, модель и SP уже готовы. // встанет MultiChoiceField, модель и SP уже готовы.
const selectedType = data.ChangeTypes[0] || ''; const selectedType = data.ChangeTypes[0] || '';
const handleTypeChange = (value: string): void => { const handleTypeChange = (value: string): void => {
const types: ChangeType[] = value ? [value as ChangeType] : []; set('ChangeTypes', value ? [value as ChangeType] : []);
set('ChangeTypes', types);
// Инициатор есть только у видов с выбором: у остальных очищаем, у этих —
// подставляем по умолчанию, если ещё не выбран.
if (!hasInitiatorChoice(types)) set('ChangeInitiator', '');
else if (!data.ChangeInitiator && defaultInitiator) set('ChangeInitiator', defaultInitiator);
}; };
return ( return (
@@ -109,22 +82,6 @@ export const RequestSection: React.FC<IRequestSectionProps> = ({
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,10 +1,8 @@
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
import { IEmployeeFullBaseRecord } from '../../shared/dictionary'; import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
import { ensureEmployee } from '../../shared/employee'; import { CHANGE_TYPE_LABELS } from './changeTypes';
import { CHANGE_TYPE_LABELS, INITIATOR_EMPLOYEE, hasInitiatorChoice } from './changeTypes';
import { FORM_ID, IFormData } from './model'; import { FORM_ID, IFormData } from './model';
import { splitSalary } from './byType/EmployerInitiative/salaryParts';
/** /**
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над * Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
@@ -43,16 +41,6 @@ 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);
@@ -120,185 +108,25 @@ 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: employerReason(data, data.PositionTransferReason) ?? null, PositionTransferReason: 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, receivingHeadId: number | undefined): Record<string, unknown> { function buildItem(data: IFormData, status: string): 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;
/** /**
@@ -318,7 +146,7 @@ export async function saveContractChangeRequest(
status: string, status: string,
listId?: string listId?: string
): Promise<ISaveResult> { ): Promise<ISaveResult> {
const item = buildItem(data, status, await receivingHeadUserId(data)); const item = buildItem(data, status);
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;
@@ -1,91 +0,0 @@
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
}));
}
@@ -1,52 +0,0 @@
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,8 +10,7 @@ import {
RequestNumber, RequestNumber,
SubmitActions, SubmitActions,
getDraftDeleteHandler, getDraftDeleteHandler,
useRequestSubmit, useRequestSubmit
FormNotice
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
composeRemoveRowRule, composeRemoveRowRule,
@@ -45,16 +44,7 @@ import {
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { import { FORM_ID, IFormData, EMPTY_DATA, SetField, IOrganizationRecord, primaryPosition } from './model';
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';
@@ -384,8 +374,6 @@ 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, FormNotice } from '../../shared/ui'; import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } 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, NOTICE_TITLE, NOTICE_ITEMS } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } 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';
@@ -285,7 +285,6 @@ 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,8 +61,6 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
- **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения - **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения
отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»). отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»).
- **Заголовок элемента** (`Title`): `Заявление на увольнение — {ФИО} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Заявление на увольнение — {ФИО} — дд.мм.гггг`.
- **Примечание** (с 10-29, `FormNotice`, тексты `NOTICE_TITLE`/`NOTICE_ITEMS` в `model.ts`) — плашка
вверху формы и просмотра про срок уведомления по ст. 56 ТК РК. Текст от HR, дословно; на печать не идёт.
## Совместители: организации таблицей, удаление синхронизировано с маршрутом ## Совместители: организации таблицей, удаление синхронизировано с маршрутом
Сотрудник может числиться сразу в НЕСКОЛЬКИХ компаниях (совместительство). Сотрудник может числиться сразу в НЕСКОЛЬКИХ компаниях (совместительство).
@@ -149,18 +149,3 @@ 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 ТК РК.'
];
@@ -452,7 +452,6 @@ 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="Исполнитель"
@@ -438,7 +438,6 @@ 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="Исполнитель"
@@ -234,7 +234,6 @@ 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="Исполнитель"
@@ -11,8 +11,7 @@ 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;
@@ -27,7 +26,6 @@ export const SkudRouteTables: React.FC<ISkudRouteTablesProps> = ({ approvers, ex
title="Исполнение" title="Исполнение"
roleLabel="Исполнитель" roleLabel="Исполнитель"
emptyText="Исполнители не назначены." emptyText="Исполнители не назначены."
printable={false}
/> />
)} )}
</> </>
@@ -224,7 +224,6 @@ 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="Исполнитель"
@@ -204,7 +204,6 @@ 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,11 +33,6 @@
(черновик проверяется так же строго, как отправка). (черновик проверяется так же строго, как отправка).
- **Замещающий** (`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,
FormNotice LeaveNotice
} 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 { organizationProjectRouteFieldValues } from '../../shared/dictionary/organizationRouteFields'; import { organizationRouteFieldValues } 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,14 +164,10 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
// пользователя) — так работает подача за другого. У заявки на редактировании // пользователя) — так работает подача за другого. У заявки на редактировании
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора. // маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
// //
// Кадровый сотрудник и исполнитель зависят от организации сотрудника и проекта // Кадровый сотрудник и исполнитель зависят от организации сотрудника (условия
// (условия «Organization содержит …», «Project содержит …» в админке) — см. // «Organization содержит …» в админке) — см. shared/dictionary/organizationRouteFields.ts.
// 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(
() => () =>
@@ -339,7 +335,7 @@ const UnpaidLeaveForm: 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} /> <LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
@@ -15,12 +15,3 @@
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,8 +9,7 @@ import {
FormModeProvider, FormModeProvider,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
FormNotice, LeaveNotice
PrintSignature
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
@@ -65,8 +64,6 @@ 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) идёт в печать —
@@ -100,7 +97,6 @@ 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);
@@ -162,15 +158,9 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
itemId={itemId} itemId={itemId}
/> />
} }
headerRight={ headerRight={status ? <StatusBadge status={status} /> : undefined}
<div className={styles.headerAside}>
{status && <StatusBadge status={status} />}
{/* Подписывает сотрудник, а не инициатор: заявку могут оформить за него. */}
<PrintSignature name={employeeName} />
</div>
}
> >
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} /> <LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly> <FormModeProvider readOnly>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
@@ -178,7 +168,6 @@ 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/FormNotice). Текст — по просьбе HR. */ /** Памятка вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — по просьбе 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" required> <FormField label="Проект" htmlFor="Project">
<ProjectPicker <ProjectPicker
id="Project" id="Project"
context={context} context={context}
@@ -16,17 +16,6 @@ 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 дней (обязательных при браке/рождении/смерти) — по договорённости с работодателем.
@@ -48,7 +37,6 @@ 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,11 +31,6 @@
- **Комментарий** (с 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,
FormNotice LeaveNotice
} 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 { organizationProjectRouteFieldValues } from '../../shared/dictionary/organizationRouteFields'; import { organizationRouteFieldValues } 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,15 +175,11 @@ const VacationForm: React.FC<IRequestFormProps> = ({
// маршрут читается из уже записанных строк, чтобы не затереть правки инициатора. // маршрут читается из уже записанных строк, чтобы не затереть правки инициатора.
// //
// Кадровый сотрудник и исполнитель в маршруте зависят от организации // Кадровый сотрудник и исполнитель в маршруте зависят от организации
// сотрудника и проекта (условия «Organization содержит …», «Project // сотрудника (условия «Organization содержит …» в админке) — см.
// содержит …» в админке) — см. 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(
() => () =>
@@ -358,7 +354,7 @@ const VacationForm: 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} /> <LeaveNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<RequestSection {...sectionProps} /> <RequestSection {...sectionProps} />
@@ -15,12 +15,3 @@
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,8 +10,7 @@ import {
FormModeProvider, FormModeProvider,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
FormNotice, LeaveNotice
PrintSignature
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
@@ -221,22 +220,15 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
itemId={itemId} itemId={itemId}
/> />
} }
headerRight={ headerRight={status ? <StatusBadge status={status} /> : undefined}
<div className={styles.headerAside}>
{status && <StatusBadge status={status} />}
{/* Подписывает сотрудник, а не инициатор: заявку могут оформить за него. */}
<PrintSignature name={employeeName} />
</div>
}
> >
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} /> <LeaveNotice 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/FormNotice). Текст — от HR, * Примечание вверху формы и просмотра (см. shared/ui/LeaveNotice). Текст — от 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" required> <FormField label="Проект" htmlFor="Project">
<ProjectPicker <ProjectPicker
id="Project" id="Project"
context={context} context={context}
@@ -19,17 +19,6 @@ 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: 'Количество дней' };
@@ -47,7 +36,6 @@ 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)
]; ];
@@ -296,7 +296,6 @@ 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="Исполнитель"
@@ -1,36 +1,25 @@
@import '../../../../../styles/design-tokens'; @import '../../../../../styles/design-tokens';
// Кружок события на линии ленты. // Ширина колонки со временем — под «29.09.2026 11:02»; уже — текст переносится.
$marker-size: 24px; $time-col-width: 124px;
// Толщина линии, соединяющей события. // Ниже этой ширины время встаёт над текстом события, а не слева от него.
$line-width: 2px; $feed-stack-breakpoint: 640px;
// Зазор между событиями — линия обязана его перекрыть, иначе рвётся.
$event-gap: $sp-4;
.feed { .feed {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $sp-5; gap: $sp-4;
} }
.round { .round {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $sp-3; gap: $sp-2;
} }
// Номер отправки + плашка итога (см. 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;
@@ -41,122 +30,62 @@ $event-gap: $sp-4;
.events { .events {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $event-gap; gap: $sp-3;
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;
// Линия от кружка к следующему событию — лента читается как хронология, а не
// как стопка одинаковых строк. У последнего события отправки линии нет:
// отправки разделены заголовками, а не одной сплошной нитью.
&: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;
} }
.current {
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: $gold-50;
} }
.marker { .marker {
display: flex; flex: 0 0 auto;
flex: 0 0 $marker-size; width: 20px;
align-items: center; text-align: 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; line-height: 1.4;
} }
// Цвета — те же смысловые, что у плашки события и полосы комментария .markerNeutral { color: $navy-700; }
// (см. historyKinds.ts): кружок, плашка и комментарий одного события — одного цвета. .markerPositive { color: $green-600; }
.markerNeutral { .markerWarning { color: $amber-700; }
border-color: $navy-600; .markerNegative { color: $red-500; }
background: $navy-50; .markerCurrent { color: $gold-600; }
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: $sp-1; gap: 2px;
min-width: 0; // иначе длинный комментарий распирает ленту вбок min-width: 0;
} }
// Плашка + время + длительность. На узкой ширине переносятся сами (flex-wrap), .eventLine {
// отдельный медиазапрос не нужен. min-height — чтобы плашка стояла вровень
// с центром кружка.
.eventHead {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: baseline;
gap: $sp-1 $sp-2; gap: $sp-1 $sp-3;
min-height: $marker-size;
} }
.time { .time {
font-size: $fs-label; flex: 0 0 $time-col-width;
color: $gray-500; color: $gray-500;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
}
.actor { @media (max-width: $feed-stack-breakpoint) {
overflow-wrap: anywhere; flex-basis: 100%;
}
} }
.meta { .meta {
@@ -164,6 +93,12 @@ $event-gap: $sp-4;
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,10 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import { ApprovalListService } from '../approvalList.service'; import { ApprovalListService } from '../approvalList.service';
import { IApprovalHistory, roundOutcome } from './historyModel'; import { IApprovalHistory } 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';
@@ -50,22 +49,18 @@ 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}>
<RoundTitle number={round.number} outcome={roundOutcome(round, isActive)} /> <div className={styles.roundTitle}>{HISTORY_TEXT.round(round.number)}</div>
<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,5 +1,4 @@
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';
@@ -12,21 +11,15 @@ 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}> <li className={`${styles.event} ${styles.current}`}>
{/* Пульсирующий кружок — единственное «живое» место ленты: всё остальное уже
случилось, а это ещё ждёт. */}
<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.eventHead}> <div className={styles.eventLine}>
<Badge variant="info" size="sm"> <span className={styles.time}>{HISTORY_TEXT.now}</span>
{HISTORY_TEXT.awaiting}
</Badge>
{waitedMs > 0 && <span className={styles.meta}>{HISTORY_TEXT.waiting(formatDuration(waitedMs))}</span>}
</div>
<div className={styles.actor}>
<strong>{holder.person || '—'}</strong> <strong>{holder.person || '—'}</strong>
{waitedMs > 0 && <span className={styles.meta}>{HISTORY_TEXT.waiting(formatDuration(waitedMs))}</span>}
</div> </div>
{holder.stepTitle && <div className={styles.meta}>{holder.stepTitle}</div>} {holder.stepTitle && <div className={styles.meta}>{holder.stepTitle}</div>}
</div> </div>
@@ -1,20 +1,22 @@
import * as React from 'react'; import * as React from 'react';
import { Badge } from '@tnsu/ui-kit'; import { formatDuration, HistoryEventKind, IHistoryEvent } from './historyModel';
import { ApprovalComment, CommentTone } from '../ui/ApprovalComment'; import { eventAction, HISTORY_TEXT } from './historyTexts';
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;
} }
const MARKER_CLASS: Record<CommentTone, string> = { // Значок и цвет события — по смыслу решения, как у статусов в StatusBadge.
positive: styles.markerPositive, const MARKER: Record<HistoryEventKind, { symbol: string; className: string }> = {
negative: styles.markerNegative, submitted: { symbol: '↗', className: styles.markerNeutral },
warning: styles.markerWarning, resubmitted: { symbol: '↗', className: styles.markerNeutral },
neutral: styles.markerNeutral, approved: { symbol: '✓', className: styles.markerPositive },
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 {
@@ -24,35 +26,26 @@ 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 view = KIND_VIEW[event.kind]; const marker = MARKER[event.kind];
const details = eventDetails(event);
return ( return (
<li className={styles.event}> <li className={styles.event}>
<span className={`${styles.marker} ${MARKER_CLASS[view.tone]}`} aria-hidden="true"> <span className={`${styles.marker} ${marker.className}`} aria-hidden="true">
{view.symbol} {marker.symbol}
</span> </span>
<div className={styles.eventBody}> <div className={styles.eventBody}>
<div className={styles.eventHead}> <div className={styles.eventLine}>
<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 && <ApprovalComment text={event.comment} tone={view.tone} />} {event.comment && <div className={styles.comment}>«{event.comment}»</div>}
</div> </div>
</li> </li>
); );
@@ -1,27 +0,0 @@
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>
);
@@ -1,37 +0,0 @@
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,11 +1,8 @@
import { import {
buildApprovalHistory, buildApprovalHistory,
formatDuration, formatDuration,
HistoryEventKind,
IHistoryRound,
IRequestVersion, IRequestVersion,
IRouteRowVersion, IRouteRowVersion,
roundOutcome,
rowEvents, rowEvents,
submitEvents, submitEvents,
} from './historyModel'; } from './historyModel';
@@ -134,44 +131,6 @@ 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,42 +181,6 @@ 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;
@@ -1,4 +1,4 @@
import { HistoryEventKind, IHistoryEvent, RoundOutcome } from './historyModel'; import { HistoryEventKind, IHistoryEvent } from './historyModel';
// Тексты «Истории согласования» — в одном месте (задел под локализацию). // Тексты «Истории согласования» — в одном месте (задел под локализацию).
// Формулировки безличные («согласовано», а не «согласовала»): пол по имени не // Формулировки безличные («согласовано», а не «согласовала»): пол по имени не
@@ -9,43 +9,28 @@ export const HISTORY_TEXT = {
empty: 'Пока нет событий: заявка ещё не отправлялась.', empty: 'Пока нет событий: заявка ещё не отправлялась.',
errorPrefix: 'Не удалось загрузить историю:', errorPrefix: 'Не удалось загрузить историю:',
round: (n: number): string => `Отправка ${n}`, round: (n: number): string => `Отправка ${n}`,
inProgress: 'В работе', now: 'сейчас',
awaiting: 'Ожидает решения', waiting: (duration: string): string => `ждёт ${duration}`,
waiting: (duration: string): string => `уже ${duration}`,
took: (duration: string): string => `за ${duration}`, took: (duration: string): string => `за ${duration}`,
delegatedTo: (name: string): string => `кому: ${name}`,
partialNote: partialNote:
'Показаны события, сохранённые в SharePoint. Решения из отправок до включения истории могли не сохраниться.', 'Показаны события, сохранённые в SharePoint. Решения из отправок до включения истории могли не сохраниться.',
}; };
// С заглавной: текст стоит первым словом в плашке события. const ACTION: Record<HistoryEventKind, string> = {
const ACTION_LABEL: Record<HistoryEventKind, string> = { submitted: 'отправлено на согласование',
submitted: 'Отправлено на согласование', resubmitted: 'отправлено повторно',
resubmitted: 'Отправлено повторно', approved: 'согласовано',
approved: 'Согласовано', executed: 'выполнено',
executed: 'Выполнено', returned: 'возвращено на доработку',
returned: 'Возвращено на доработку', rejected: 'отклонено',
rejected: 'Отклонено', delegated: 'передано',
delegated: 'Передано', replaced: 'заменён исполнитель',
replaced: 'Заменён исполнитель',
}; };
/** Что произошло — текст плашки: «Согласовано», «Возвращено на доработку». */ /** Что произошло — «согласовано», «передано: Асель Самаева», «заменён исполнитель: А → Б». */
export function eventLabel(kind: HistoryEventKind): string { export function eventAction(event: IHistoryEvent): string {
return ACTION_LABEL[kind]; const action = ACTION[event.kind];
} if (event.kind === 'delegated' && event.target) return `${action}: ${event.target}`;
if (event.kind === 'replaced') return `${action}: ${event.previous || '—'} → ${event.target || '—'}`;
/** Итог отправки — текст плашки в её заголовке. Решения — теми же словами, что в ленте. */ return action;
export function outcomeLabel(outcome: RoundOutcome): string {
return outcome === 'inProgress' ? HISTORY_TEXT.inProgress : ACTION_LABEL[outcome];
}
/**
* Над кем действие — дописывается к имени автора: «кому: Асель Самаева» для
* передачи, «Иванов → Петров» для замены. У прочих событий пусто.
*/
export function eventDetails(event: IHistoryEvent): string {
if (event.kind === 'delegated' && event.target) return HISTORY_TEXT.delegatedTo(event.target);
if (event.kind === 'replaced') return `${event.previous || '—'} → ${event.target || '—'}`;
return '';
} }
@@ -22,8 +22,7 @@ $quote-line-height: 19px;
} }
// Цвет — у полосы и кавычки, фон лишь подтон: текст должен читаться так же // Цвет — у полосы и кавычки, фон лишь подтон: текст должен читаться так же
// спокойно, как остальная таблица, а не кричать. Белую карточку пробовали — // спокойно, как остальная таблица, а не кричать.
// внутри формы она похожа на поле ввода.
.neutral { .neutral {
border-left-color: $navy-600; border-left-color: $navy-600;
background: $navy-50; background: $navy-50;
@@ -3,7 +3,7 @@ import styles from './ApprovalComment.module.scss';
import { REQUEST_STATUS } from '../models/statuses'; import { REQUEST_STATUS } from '../models/statuses';
import { useTextOverflow } from './useTextOverflow'; import { useTextOverflow } from './useTextOverflow';
export type CommentTone = 'positive' | 'negative' | 'warning' | 'neutral'; type CommentTone = 'positive' | 'negative' | 'warning' | 'neutral';
// Цвет полосы — по решению на шаге: причину отказа или возврата видно с первого // Цвет полосы — по решению на шаге: причину отказа или возврата видно с первого
// взгляда, ещё до того, как глаз дойдёт до колонки «Статус». // взгляда, ещё до того, как глаз дойдёт до колонки «Статус».
@@ -31,23 +31,18 @@ export interface IApprovalCommentProps {
text: string; text: string;
/** Статус строки маршрута (см. REQUEST_STATUS) — от него цвет полосы. */ /** Статус строки маршрута (см. REQUEST_STATUS) — от него цвет полосы. */
status?: string; status?: string;
/**
* Цвет полосы напрямую — для мест, где статуса строки нет (напр. лента
* «Истории согласования» знает вид события, а не статус). Важнее status.
*/
tone?: CommentTone;
} }
/** /**
* Комментарий участника маршрута к своему решению. Длинный текст свёрнут до * Комментарий участника маршрута к своему решению. Длинный текст свёрнут до
* нескольких строк (см. $comment-visible-lines), остальное — по кнопке. * нескольких строк (см. $comment-visible-lines), остальное — по кнопке.
*/ */
export const ApprovalComment: React.FC<IApprovalCommentProps> = ({ text, status, tone: toneProp }) => { export const ApprovalComment: React.FC<IApprovalCommentProps> = ({ text, status }) => {
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
const textRef = React.useRef<HTMLDivElement>(null); const textRef = React.useRef<HTMLDivElement>(null);
const isClamped = useTextOverflow(textRef, text); const isClamped = useTextOverflow(textRef, text);
const tone = toneProp || (status && TONE_BY_STATUS[status]) || 'neutral'; const tone = (status && TONE_BY_STATUS[status]) || 'neutral';
// В развёрнутом виде обрезки нет, и замер честно вернёт false, но кнопка // В развёрнутом виде обрезки нет, и замер честно вернёт false, но кнопка
// «Свернуть» при этом всё равно нужна. // «Свернуть» при этом всё равно нужна.
const showToggle = isExpanded || isClamped; const showToggle = isExpanded || isClamped;
@@ -76,13 +76,6 @@ export interface IApprovalMatrixProps {
* editable. В черновике не нужен: там пикер добавляет строку локально. * editable. В черновике не нужен: там пикер добавляет строку локально.
*/ */
onAddRow?: (user: ISiteUser) => void; onAddRow?: (user: ISiteUser) => void;
/**
* Попадает ли блок в печатную версию заявки. По умолчанию да — маршрут
* согласования печатается. Исполнение (внутренняя работа после согласования)
* не печатается ни в одной форме: false у ExecutorsMatrix и у блоков
* «Исполнение», выведенных через ApprovalMatrix напрямую.
*/
printable?: boolean;
} }
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */ /** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
@@ -102,7 +95,6 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
canMoveRow, canMoveRow,
onMoveRow, onMoveRow,
onAddRow, onAddRow,
printable = true,
}) => { }) => {
const { rows, loading, error, editable, blocks, retry } = editor; const { rows, loading, error, editable, blocks, retry } = editor;
// Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт. // Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт.
@@ -219,14 +211,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
// Комментарий занимает всю ширину между объединёнными колонками «Шаг» и // Комментарий занимает всю ширину между объединёнными колонками «Шаг» и
// стрелок: «Согласующий» + «Должность» + те необязательные колонки, что // стрелок: «Согласующий» + «Должность» + те необязательные колонки, что
// сейчас показаны. Колонку действий не захватывает — её ячейка из строки // сейчас показаны. Колонку действий не захватывает — в ней своя пустая ячейка.
// участника объединена и на строку комментария (rowSpan у .matrixActions).
const optionalColumns = [hasDepartment, hasOrganization, hasStatuses, hasDates, hasSla]; const optionalColumns = [hasDepartment, hasOrganization, hasStatuses, hasDates, hasSla];
const commentColSpan = 2 + optionalColumns.filter(Boolean).length; const commentColSpan = 2 + optionalColumns.filter(Boolean).length;
const hasActionsCol = editable || hasRemovable; const hasActionsCol = editable || hasRemovable;
return ( return (
<div className={printable ? styles.matrixWrap : `${styles.matrixWrap} rp-no-print`}> <div className={styles.matrixWrap}>
<div className={styles.matrixTitle}>{title}</div> <div className={styles.matrixTitle}>{title}</div>
{waiting && <div className={styles.matrixNote}>{waitingText}</div>} {waiting && <div className={styles.matrixNote}>{waitingText}</div>}
{!waiting && loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>} {!waiting && loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
@@ -346,11 +337,8 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
)} )}
</td> </td>
)} )}
{/* Кнопки — на участника вместе с его комментарием, как номер
шага и стрелки: иначе стрелки стоят по центру пары строк,
а кнопки — по центру одной, и они расходятся по высоте. */}
{hasActionsCol && ( {hasActionsCol && (
<td className={styles.matrixActions} rowSpan={r.comment ? 2 : 1}> <td className={styles.matrixActions}>
{editable && ( {editable && (
<button <button
type="button" type="button"
@@ -391,13 +379,14 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
</tr> </tr>
{/* Комментарий — отдельной строкой во всю ширину под участником: {/* Комментарий — отдельной строкой во всю ширину под участником:
в узкой колонке «Согласующий» текст ломался по слову на строку. в узкой колонке «Согласующий» текст ломался по слову на строку.
Ячейки «Шаг», стрелок и действий её не повторяют — их накрывает Ячейки «Шаг» и стрелок её не повторяют — их накрывает rowSpan
rowSpan из строки участника. */} (см. block.span); колонка действий — пустая ячейка. */}
{r.comment && ( {r.comment && (
<tr ref={registerRow(commentRowKey(rowKeys[i]))} className={commentClassNames.join(' ')}> <tr ref={registerRow(commentRowKey(rowKeys[i]))} className={commentClassNames.join(' ')}>
<td colSpan={commentColSpan} className={styles.commentCell}> <td colSpan={commentColSpan} className={styles.commentCell}>
<ApprovalComment text={r.comment} status={r.status} /> <ApprovalComment text={r.comment} status={r.status} />
</td> </td>
{hasActionsCol && <td />}
</tr> </tr>
)} )}
</React.Fragment> </React.Fragment>
@@ -13,7 +13,6 @@ interface IExecutorsMatrixProps {
/** /**
* Таблица исполнителей — рисуется только когда в маршруте из админки они есть: * Таблица исполнителей — рисуется только когда в маршруте из админки они есть:
* у форм без фазы исполнения блока нет вовсе (кроме редактора маршрута, см. canEditExecutors). * у форм без фазы исполнения блока нет вовсе (кроме редактора маршрута, см. canEditExecutors).
* В печатную версию заявки не попадает (printable={false}) — на печати только согласование.
*/ */
export const ExecutorsMatrix: React.FC<IExecutorsMatrixProps> = ({ editor, search, title = 'Исполнение' }) => { export const ExecutorsMatrix: React.FC<IExecutorsMatrixProps> = ({ editor, search, title = 'Исполнение' }) => {
// Редактор маршрута видит блок и пустым — иначе исполнителя некуда добавить. // Редактор маршрута видит блок и пустым — иначе исполнителя некуда добавить.
@@ -27,7 +26,6 @@ export const ExecutorsMatrix: React.FC<IExecutorsMatrixProps> = ({ editor, searc
canRemoveRow={editor.editable ? () => true : undefined} canRemoveRow={editor.editable ? () => true : undefined}
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
emptyText="Исполнители не назначены." emptyText="Исполнители не назначены."
printable={false}
/> />
); );
}; };
@@ -159,10 +159,9 @@
// Строка участника, под которой идёт его комментарий (см. ApprovalComment): // Строка участника, под которой идёт его комментарий (см. ApprovalComment):
// разделитель между ними убираем, иначе комментарий читается как отдельная // разделитель между ними убираем, иначе комментарий читается как отдельная
// запись маршрута. Объединённые ячейки (rowSpan) не трогаем: «Шаг» и стрелки // запись маршрута. Ячейки «Шаг» и стрелок не трогаем — они объединены на весь
// накрывают весь шаг, действия — участника с комментарием, и их нижняя // шаг (rowSpan), и их нижняя граница — это граница всего шага.
// граница — это граница всей записи. .rowWithComment > td:not(.matrixStepCol):not(.matrixMoveCol) {
.rowWithComment > td:not(.matrixStepCol):not(.matrixMoveCol):not(.matrixActions) {
border-bottom-color: transparent; border-bottom-color: transparent;
} }
@@ -389,6 +388,11 @@
flex: 1 1 100%; flex: 1 1 100%;
} }
// Пустая ячейка под колонкой действий на телефоне дала бы лишний отступ.
.commentRow > td:empty {
display: none;
}
.matrixActions, .matrixActions,
.matrixMoveCol { .matrixMoveCol {
justify-content: flex-start; justify-content: flex-start;
@@ -1,4 +1,4 @@
import { organizationProjectRouteFieldValues, organizationRouteFieldValues } from './organizationRouteFields'; import { organizationRouteFieldValues } from './organizationRouteFields';
describe('organizationRouteFieldValues', () => { describe('organizationRouteFieldValues', () => {
it('одна организация — её название', () => { it('одна организация — её название', () => {
@@ -28,18 +28,3 @@ describe('organizationRouteFieldValues', () => {
expect(organizationRouteFieldValues('')).toEqual([]); expect(organizationRouteFieldValues('')).toEqual([]);
}); });
}); });
describe('organizationProjectRouteFieldValues', () => {
it('организация и проект — две записи', () => {
expect(organizationProjectRouteFieldValues('ТОО TANSU Construction — Инженер', ' Офис TANSU ')).toEqual([
{ fieldName: 'Organization', value: 'ТОО TANSU Construction' },
{ fieldName: 'Project', value: 'Офис TANSU' },
]);
});
it('проект не выбран — только организация', () => {
expect(organizationProjectRouteFieldValues('ТОО АСТЕЛ-К — Экономист', '')).toEqual([
{ fieldName: 'Organization', value: 'ТОО АСТЕЛ-К' },
]);
});
});
@@ -22,16 +22,3 @@ export function organizationRouteFieldValues(organization: string): IResolveFiel
const names = companies.filter((name, i) => companies.indexOf(name) === i).sort().join(' '); const names = companies.filter((name, i) => companies.indexOf(name) === i).sort().join(' ');
return names ? [{ fieldName: 'Organization', value: names }] : []; return names ? [{ fieldName: 'Organization', value: names }] : [];
} }
/**
* Организация + проект — для форм, где кадровик зависит и от места работы
* (отпуск, отпуск без содержания): в офисе — кадровик компании («Organization
* содержит TANSU И Project содержит Офис TANSU»), на объекте — кадровик
* объекта («Project содержит …»). Проект — название из справочника /Project,
* как его показывает поле формы.
*/
export function organizationProjectRouteFieldValues(organization: string, project: string): IResolveFieldValue[] {
const fieldValues = organizationRouteFieldValues(organization);
const projectName = project.trim();
return projectName ? [...fieldValues, { fieldName: 'Project', value: projectName }] : fieldValues;
}
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { Alert } from '@tnsu/ui-kit'; import { Alert } from '@tnsu/ui-kit';
import styles from './FormNotice.module.scss'; import styles from './LeaveNotice.module.scss';
interface Props { interface Props {
/** Заголовок плашки, например «Примечание» или «Памятка». */ /** Заголовок плашки, например «Примечание» или «Памятка». */
@@ -10,13 +10,12 @@ interface Props {
} }
/** /**
* Плашка с правилами вверху формы и её просмотра — «Примечание», «Памятка» * Плашка с правилами для заявлений на отпуск (трудовой и без содержания).
* (отпуск, отпуск без содержания, увольнение). Тексты у форм разные — от * Тексты у форм разные, поэтому приходят из model.ts каждой формы.
* кадров, дословно, — поэтому приходят из model.ts каждой формы.
* Вариант warn, а не info: нейтральная плашка терялась на фоне формы. * Вариант warn, а не info: нейтральная плашка терялась на фоне формы.
* На печать не выводится — это подсказка для сотрудника, а не часть заявления. * На печать не выводится — это подсказка для сотрудника, а не часть заявления.
*/ */
export const FormNotice: React.FC<Props> = ({ title, items }) => { export const LeaveNotice: React.FC<Props> = ({ title, items }) => {
if (items.length === 0) return null; if (items.length === 0) return null;
return ( return (
@@ -1,43 +0,0 @@
@import '../../../../../styles/design-tokens';
// Длина линии ≈ 5 см на листе A4 — хватает на роспись от руки.
$signature-line-width: 200px;
// Место под роспись между ФИО и линией.
$signature-height: $sp-8;
// На экране блока нет: расписываться негде.
.signature {
display: none;
}
@media print {
// Столбик, а не строка «ФИО ____»: в шапке рядом с заголовком места мало,
// строка в ширину сжала бы заголовок до трёх строк.
.signature {
display: flex;
flex-direction: column;
align-items: center;
// min-width, а не width: длинное ФИО растягивает столбик (и линию), а не
// вылезает за край листа.
min-width: $signature-line-width;
font-size: $fs-sm;
color: #000;
break-inside: avoid;
page-break-inside: avoid;
}
.name {
white-space: nowrap; // перенос фамилии на бланке выглядит как опечатка
}
.line {
align-self: stretch;
height: $signature-height;
border-bottom: 1px solid #000;
}
.caption {
margin-top: 2px;
font-size: $fs-caption;
}
}
@@ -1,23 +0,0 @@
import * as React from 'react';
import styles from './PrintSignature.module.scss';
const CAPTION = '(подпись)';
interface IPrintSignatureProps {
/** ФИО того, кто подписывает. Пусто — печатается только линия. */
name?: string;
}
/**
* Место для подписи на печатном заявлении — столбиком: ФИО, под ним место под
* роспись и линия, под линией «(подпись)». Узкий столбик рассчитан на правый
* верхний угол шапки (FormCard headerRight), как угол бумажного заявления.
* Видно только при печати — на экране расписываться негде.
*/
export const PrintSignature: React.FC<IPrintSignatureProps> = ({ name }) => (
<div className={styles.signature}>
{name && <span className={styles.name}>{name}</span>}
<span className={styles.line} />
<span className={styles.caption}>{CAPTION}</span>
</div>
);
@@ -50,7 +50,6 @@ export { OrganizationField } from './OrganizationField/OrganizationField';
export { OrganizationTable } from './OrganizationField/OrganizationTable'; export { OrganizationTable } from './OrganizationField/OrganizationTable';
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton'; export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead'; export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
export { PrintSignature } from './PrintSignature/PrintSignature';
export { LinkField } from './LinkField/LinkField'; export { LinkField } from './LinkField/LinkField';
export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay'; export { ErrorOverlay } from './ErrorOverlay/ErrorOverlay';
export { SubmitActions } from './SubmitActions/SubmitActions'; export { SubmitActions } from './SubmitActions/SubmitActions';
@@ -60,6 +59,6 @@ export { issueLabels } from './FieldIssues/fieldIssues';
export { getDraftDeleteHandler } from './SubmitActions/getDraftDeleteHandler'; export { getDraftDeleteHandler } from './SubmitActions/getDraftDeleteHandler';
export { InitiatorLine } from './InitiatorLine/InitiatorLine'; export { InitiatorLine } from './InitiatorLine/InitiatorLine';
export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine'; export type { IInitiatorLineProps } from './InitiatorLine/InitiatorLine';
export { FormNotice } from './FormNotice/FormNotice'; export { LeaveNotice } from './LeaveNotice/LeaveNotice';
export { useRequestSubmit } from './useRequestSubmit'; export { useRequestSubmit } from './useRequestSubmit';
export type { IRequestSubmit, SubmitKind } from './useRequestSubmit'; export type { IRequestSubmit, SubmitKind } from './useRequestSubmit';