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
144 changed files with 445 additions and 4552 deletions
-4
View File
@@ -30,10 +30,6 @@
"resource": "9232d9e2-9807-4753-808f-07250e5474b0", "resource": "9232d9e2-9807-4753-808f-07250e5474b0",
"scope": "Reports.Read" "scope": "Reports.Read"
}, },
{
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
"scope": "Logs.Write"
},
{ {
"resource": "Microsoft Flow Service", "resource": "Microsoft Flow Service",
"scope": "Flows.Read.All" "scope": "Flows.Read.All"
-4
View File
@@ -1,4 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/webpack-patch.schema.json",
"patchFiles": ["./node_modules/@tnsu/monitoring-web/spfx/webpack-patch.cjs"]
}
+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
@@ -26,7 +26,7 @@
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | | Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` | | То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` | | Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
| Заполнять «Необходимое ПО», «Доступы» и «Мебель» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) | | Заполнять «Необходимое ПО» и «Доступы» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` | | Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) | | Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` | | Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
+28 -136
View File
@@ -17,9 +17,7 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@pnp/telemetry-js": "2.0.0", "@tnsu/ui-kit": "^0.14.0",
"@tnsu/monitoring-web": "^0.3.4",
"@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0", "jszip": "~3.8.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
@@ -343,7 +341,7 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -353,7 +351,7 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
@@ -384,7 +382,7 @@
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -410,7 +408,7 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/compat-data": "^7.29.7", "@babel/compat-data": "^7.29.7",
@@ -427,7 +425,7 @@
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -459,7 +457,7 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-module-imports": "^7.29.7", "@babel/helper-module-imports": "^7.29.7",
@@ -505,7 +503,7 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -515,7 +513,7 @@
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.29.7", "@babel/template": "^7.29.7",
@@ -3981,7 +3979,7 @@
"version": "2.3.5", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
@@ -8728,28 +8726,6 @@
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
} }
}, },
"node_modules/@rrweb/rrweb-plugin-console-record": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@rrweb/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.2.0.tgz",
"integrity": "sha512-2c6LEsoH3+1NFcHckcwuErg7bkpFadt9++uKJh2x9BtWUDxoVKzLp+qLCq6f90MLTjSIABDkAiCswVskENZT3g==",
"license": "MIT",
"peerDependencies": {
"@rrweb/utils": "^2.1.1",
"rrweb": "^2.1.1"
}
},
"node_modules/@rrweb/types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.2.0.tgz",
"integrity": "sha512-2gTA/pRpIbuET123oSdX5ZUDZfWCOIMYBvpSai3fr5pk9LcbccPqqaXflX+roP4oKenLjNtJPGpFK3BfYeBKnA==",
"license": "MIT"
},
"node_modules/@rrweb/utils": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.2.0.tgz",
"integrity": "sha512-e8FEp8wLswqgjDHpQNUUVfEXeDsrRMOVouvqIGKvnPxKYonjLlh0mU3O/17QNvCixFG5eUFR1B2jgTdiLlZxeQ==",
"license": "MIT"
},
"node_modules/@rushstack/debug-certificate-manager": { "node_modules/@rushstack/debug-certificate-manager": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz", "resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz",
@@ -9909,32 +9885,10 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@tnsu/monitoring-web": {
"version": "0.3.4",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fmonitoring-web/-/0.3.4/monitoring-web-0.3.4.tgz",
"integrity": "sha512-tQe0ZM1tBVO3XtbNFliRaaooqvCX3gb/Q/j2lV6+rYCXprodBZByQ6PLrCu/P0me/rW8KdQ02hu7avyLXXfkoQ==",
"license": "UNLICENSED",
"dependencies": {
"@rrweb/rrweb-plugin-console-record": "^2.2.0",
"rrweb": "^2.2.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/@tnsu/ui-kit": { "node_modules/@tnsu/ui-kit": {
"version": "0.15.0", "version": "0.14.0",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.15.0/ui-kit-0.15.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-vs+7NPem7PDaYj00nwaFztkw2K/nOkw6m2bvqgMABZG5rWjLVqfyFyiopKFkPYgqPWcMPwmQuxoFnc9xi7rwjQ==", "integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
@@ -10068,12 +10022,6 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT"
},
"node_modules/@types/ejs": { "node_modules/@types/ejs": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
@@ -10302,7 +10250,7 @@
"version": "26.1.2", "version": "26.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~8.3.0" "undici-types": "~8.3.0"
@@ -10331,7 +10279,6 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/qs": { "node_modules/@types/qs": {
@@ -10361,7 +10308,6 @@
"version": "17.0.45", "version": "17.0.45",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz",
"integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==", "integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@@ -10373,7 +10319,6 @@
"version": "17.0.17", "version": "17.0.17",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.17.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.17.tgz",
"integrity": "sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg==", "integrity": "sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/react": "^17" "@types/react": "^17"
@@ -10396,7 +10341,6 @@
"version": "0.26.0", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==", "integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/send": { "node_modules/@types/send": {
@@ -11683,12 +11627,6 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/@xstate/fsm": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
"license": "MIT"
},
"node_modules/@xtuc/ieee754": { "node_modules/@xtuc/ieee754": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -12575,15 +12513,6 @@
} }
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -12608,7 +12537,7 @@
"version": "2.11.7", "version": "2.11.7",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz",
"integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==", "integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.cjs" "baseline-browser-mapping": "dist/cli.cjs"
@@ -12782,7 +12711,7 @@
"version": "4.28.7", "version": "4.28.7",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -12991,7 +12920,7 @@
"version": "1.0.30001806", "version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -13416,7 +13345,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": { "node_modules/cookie": {
@@ -14305,7 +14234,7 @@
"version": "1.5.398", "version": "1.5.398",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz",
"integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==", "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==",
"dev": true, "devOptional": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/embla-carousel": { "node_modules/embla-carousel": {
@@ -14635,7 +14564,7 @@
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -16055,7 +15984,7 @@
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -19204,7 +19133,7 @@
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"json5": "lib/cli.js" "json5": "lib/cli.js"
@@ -19884,12 +19813,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp": { "node_modules/mkdirp": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
@@ -20012,6 +19935,7 @@
"version": "3.3.16", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -20153,7 +20077,7 @@
"version": "2.0.51", "version": "2.0.51",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -20818,6 +20742,7 @@
"version": "8.5.25", "version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -22093,40 +22018,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/rrdom": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.2.0.tgz",
"integrity": "sha512-gxcBJWVtS/Xz+Ys8xcjPoWqr+C7wz/MS8NgmKw136I47Xygy17FYOGZqoGwEyvBn724jRDK1ljS+8w9FsZStOg==",
"license": "MIT",
"dependencies": {
"rrweb-snapshot": "^2.2.0"
}
},
"node_modules/rrweb": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.2.0.tgz",
"integrity": "sha512-ofVzPq11/yIQ7WhVb++rJGI2LoPPrxdqu0QAvRA4KGeXJ7LH52jQ0VlRH4+VXn5PA/7pz85/rRkQyBUqT1LraQ==",
"license": "MIT",
"dependencies": {
"@rrweb/types": "^2.2.0",
"@rrweb/utils": "^2.2.0",
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"mitt": "^3.0.0",
"rrdom": "^2.2.0",
"rrweb-snapshot": "^2.2.0"
}
},
"node_modules/rrweb-snapshot": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.2.0.tgz",
"integrity": "sha512-Tg/PiCLy+zV9C0Jfb4IAGABtt5l/UOvwW5xjFk41ZQhBfUjeqPP1rZ9HMDrFPc7cawCUEenWw54+g/4vluO5lg==",
"license": "MIT",
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/rtl-css-js": { "node_modules/rtl-css-js": {
"version": "1.16.1", "version": "1.16.1",
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz", "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
@@ -23236,6 +23127,7 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -24515,7 +24407,7 @@
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicorn-magic": { "node_modules/unicorn-magic": {
@@ -24569,7 +24461,7 @@
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
+1 -3
View File
@@ -21,9 +21,7 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@pnp/telemetry-js": "2.0.0", "@tnsu/ui-kit": "^0.14.0",
"@tnsu/monitoring-web": "^0.3.4",
"@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0", "jszip": "~3.8.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
@@ -9,8 +9,6 @@ import {
} from '@microsoft/sp-property-pane'; } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base'; import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { IReadonlyTheme } from '@microsoft/sp-component-base'; import { IReadonlyTheme } from '@microsoft/sp-component-base';
import PnPTelemetry from '@pnp/telemetry-js';
import { initMonitoring, spfxTokenProvider } from '@tnsu/monitoring-web';
import * as strings from 'RequestPortalWebPartStrings'; import * as strings from 'RequestPortalWebPartStrings';
import RequestPortal from './components/RequestPortal'; import RequestPortal from './components/RequestPortal';
@@ -100,14 +98,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
} }
protected onInit(): Promise<void> { protected onInit(): Promise<void> {
// Логи, ошибки, console.error и запись экрана в мониторинг (monitoring.tnsu.kz). Токен — молча через
// AAD веб-части (разрешение Logs.Write в package-solution.json); при отладке в workbench окружение — development.
// Один на страницу: повторный вызов (несколько веб-частей) библиотека игнорирует сама.
initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) });
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
// Телеметрия PnP (её включает @pnp/spfx-controls-react) шлёт fetch на pnptelemetryproxy.azurewebsites.net
// без catch: где адрес закрыт прокси или блокировщиком, это необработанный отказ промиса в мониторинге.
PnPTelemetry.getInstance().optOut();
initSP(this.context); // единый клиент PnPjs для записи в списки initSP(this.context); // единый клиент PnPjs для записи в списки
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они // Адрес approval-route должен быть выставлен ДО загрузки партиций — они
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './BusinessTripView.module.scss'; import styles from './BusinessTripView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, buildPrintTitle } from '../../shared/ui'; import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
ApprovalMatrix, ApprovalMatrix,
@@ -55,7 +55,6 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -85,7 +84,6 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -130,9 +128,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintButton <PrintButton />
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
@@ -160,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="Исполнитель"
@@ -11,8 +11,6 @@ export interface ILoadedRequest {
status: string; status: string;
/** Уникальный код заявки; '' — если запись создана до появления поля. */ /** Уникальный код заявки; '' — если запись создана до появления поля. */
code: string; code: string;
/** ФИО командируемого (Employee/Title) — для имени печатного документа. */
employeeName: string;
} }
/** /**
@@ -39,12 +37,11 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
'TripObligationAccepted', 'TripObligationAccepted',
'RequestStatus', 'RequestStatus',
'RequestUniqueCode', 'RequestUniqueCode',
'Employee/EMail', 'Employee/EMail'
'Employee/Title'
) )
.expand('Employee')()) as Record<string, unknown>; .expand('Employee')()) as Record<string, unknown>;
const employee = item.Employee as { EMail?: string; Title?: string } | undefined; const employee = item.Employee as { EMail?: string } | undefined;
const data: IFormData = { const data: IFormData = {
...EMPTY_DATA, ...EMPTY_DATA,
@@ -64,7 +61,6 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
return { return {
data, data,
status: (item.RequestStatus as string) || '', status: (item.RequestStatus as string) || '',
code: (item.RequestUniqueCode as string) || '', code: (item.RequestUniqueCode as string) || ''
employeeName: employee?.Title || ''
}; };
} }
@@ -2,16 +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 './ClearanceView.module.scss'; import styles from './ClearanceView.module.scss';
import { import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
Alert,
Button,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
buildPrintTitle
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -57,8 +48,6 @@ interface IClearanceViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error'; type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const FORM_TITLE = 'Обходной лист';
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые // Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения, // заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
// отдел подбора — парковочная карта (см. approverRights.ts). // отдел подбора — парковочная карта (см. approverRights.ts).
@@ -71,8 +60,6 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
// ФИО увольняемого — для имени печатного документа.
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false); const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
@@ -136,7 +123,6 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -288,12 +274,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton <PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div> </div>
{canEditAnything && ( {canEditAnything && (
@@ -314,7 +295,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title={FORM_TITLE} title="Обходной лист"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -334,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="Исполнитель"
@@ -43,8 +43,7 @@ import {
dataListTitle, dataListTitle,
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField, withoutFormula } from './model'; import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { useBsuCatalog } from './bsu/useBsuCatalog';
import { saveConcreteRequest, concreteRequestApprovalService } from './submit'; import { saveConcreteRequest, concreteRequestApprovalService } from './submit';
import { loadConcreteRequest } from './load'; import { loadConcreteRequest } from './load';
import { HeaderSection } from './sections/HeaderSection'; import { HeaderSection } from './sections/HeaderSection';
@@ -168,19 +167,6 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
.catch(() => undefined); .catch(() => undefined);
}; };
// Справочник БСУ нужен только при заполнении — в режиме чтения показываем сохранённые названия.
const bsu = useBsuCatalog(context, data.BsuId, !readOnly);
// Рецепты позиций принадлежат конкретному заводу — при смене БСУ сбрасываем их.
const handleBsuChange = (bsuId: string, bsuName: string): void => {
setData((prev) => ({
...prev,
BsuId: bsuId,
BsuName: bsuName,
Items: bsuId === prev.BsuId ? prev.Items : prev.Items.map(withoutFormula)
}));
};
// Маршрут согласования зависит от начальника участка И проекта (в админке // Маршрут согласования зависит от начальника участка И проекта (в админке
// approval-route для этого типа заявки настроено условие ByProject) — // approval-route для этого типа заявки настроено условие ByProject) —
// planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока // planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока
@@ -219,10 +205,10 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
}); });
const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0); const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0);
const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && data.BsuName.trim() && hasItems); const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems);
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (!isValid) { if (!isValid) {
return 'Заполните обязательные поля: дата поставки, начальник участка, проект, БСУ и хотя бы одну позицию (класс смеси + количество).'; return 'Заполните обязательные поля: дата поставки, начальник участка, проект и хотя бы одну позицию (класс смеси + количество).';
} }
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
@@ -317,9 +303,7 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
set, set,
context, context,
onSiteChiefChange: handleSiteChiefChange, onSiteChiefChange: handleSiteChiefChange,
onForemanChange: handleForemanChange, onForemanChange: handleForemanChange
bsu: readOnly ? undefined : bsu,
onBsuChange: handleBsuChange
}; };
// «Удалить черновик» — только у сохранённой заявки в статусе «Черновик». // «Удалить черновик» — только у сохранённой заявки в статусе «Черновик».
@@ -2,15 +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 './ConcreteRequestView.module.scss'; import styles from './ConcreteRequestView.module.scss';
import { import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
buildPrintTitle
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -50,8 +42,6 @@ interface IConcreteRequestViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявка на бетон';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined; const NOOP_SET: SetField = () => undefined;
@@ -147,18 +137,13 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton <PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.SiteChiefName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title={FORM_TITLE} title="Заявка на бетон"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -51,20 +51,11 @@
здесь другой, поэтому у формы свой редактор. Карточки (`FormGrid`/`FormField`), здесь другой, поэтому у формы свой редактор. Карточки (`FormGrid`/`FormField`),
а не строки плоской таблицы — адаптивнее на мобильных, чем строка с а не строки плоской таблицы — адаптивнее на мобильных, чем строка с
горизонтальным скроллом. горизонтальным скроллом.
- **БСУ и рецепты** (`bsu/`, API `https://bsu.tnsu.kz/api/v1`, токен того же
AAD-приложения, что у approval-route): в шапке выбирается БСУ (`GET /bsu` →
`BsuId`/`BsuName`, обязателен), в позиции — рецепт этого БСУ
(`GET /bsu/{id}/formulas`): в позицию пишутся `formulaId`, название в
`mixClass` и снимок состава `ingredients` (на заводе рецепт могут поменять).
Смена БСУ сбрасывает выбранные рецепты (`withoutFormula`). API не ответил —
отправку **не блокируем**: предупреждение и ручной ввод (название БСУ /
класс смеси текстом, как до справочника). В просмотре API не вызывается —
показываются сохранённые названия.
- **Заголовок элемента** (`Title`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`.
## Секции формы ## Секции формы
HeaderSection (дата поставки · начальник участка · организация (read-only) · HeaderSection (дата поставки · начальник участка · организация (read-only) ·
проект · БСУ · мастер участка) + ItemsEditor (табличная часть поставок) проект · мастер участка) + ItemsEditor (табличная часть поставок)
## Точки входа ## Точки входа
- rptype: `wh-concrete-request` - rptype: `wh-concrete-request`
@@ -1,15 +0,0 @@
/** БСУ (бетонно-смесительный узел) — ответ GET /bsu. */
export interface IBsu {
id: string;
/** Бэкенд может отдать null — подпись в списке строит bsuLabel. */
name: string | null;
}
/** Рецепт (формула) смеси конкретного БСУ — ответ GET /bsu/{id}/formulas. */
export interface IBsuFormula {
id: number;
/** Марка/класс («В25», «pp M100»). Бывает пустой строкой. */
name: string;
/** Состав одной строкой: «Бункер 1: 670; Цемент 1: 400; Вода: 146; …». */
ingredients: string;
}
@@ -1,40 +0,0 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { BSU_API_BASE_URL, BSU_API_CLIENT_ID } from '../../../shared/config';
import { IBsu, IBsuFormula } from './IBsu';
async function getJson<T>(context: WebPartContext, path: string): Promise<T> {
const client = await context.aadHttpClientFactory.getClient(BSU_API_CLIENT_ID);
const response: HttpClientResponse = await client.get(`${BSU_API_BASE_URL}${path}`, AadHttpClient.configurations.v1);
if (!response.ok) {
throw new Error(`БСУ ${path}: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Список БСУ в рамках сессии не меняется — грузим один раз (как projects.service).
let bsuCache: Promise<IBsu[]> | undefined;
export function getBsuList(context: WebPartContext): Promise<IBsu[]> {
if (!bsuCache) {
bsuCache = getJson<IBsu[]>(context, '/bsu').catch((e) => {
bsuCache = undefined;
throw e;
});
}
return bsuCache;
}
/** Рецепты выбранного БСУ. Не кэшируем: БСУ переключают редко, а рецепты на заводе могут правиться. */
export function getBsuFormulas(context: WebPartContext, bsuId: string): Promise<IBsuFormula[]> {
return getJson<IBsuFormula[]>(context, `/bsu/${encodeURIComponent(bsuId)}/formulas`);
}
/** Подпись БСУ в списке: имени может не быть — тогда хотя бы начало id, чтобы пункты различались. */
export function bsuLabel(bsu: IBsu): string {
return bsu.name?.trim() || `БСУ ${bsu.id.slice(0, 8)}`;
}
/** Подпись рецепта: пустое имя встречается (id 13) — показываем номер. */
export function formulaLabel(formula: IBsuFormula): string {
return formula.name.trim() || `Рецепт №${formula.id}`;
}
@@ -1,62 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { friendlyErrorMessage } from '../../../shared/diagnostics';
import { getBsuFormulas, getBsuList } from './bsu.service';
import { IBsu, IBsuFormula } from './IBsu';
/** Список из API с явными состояниями загрузки и ошибки. */
export interface IRemoteList<T> {
items: T[];
loading: boolean;
/** '' — ошибки нет. */
error: string;
}
/** Справочник БСУ для формы: список заводов и рецепты выбранного. */
export interface IBsuCatalog {
bsu: IRemoteList<IBsu>;
formulas: IRemoteList<IBsuFormula>;
}
function emptyList<T>(): IRemoteList<T> {
return { items: [], loading: false, error: '' };
}
/** load = null — грузить нечего (выключено или не выбран БСУ). load должен быть стабильным (useCallback). */
function useRemoteList<T>(load: (() => Promise<T[]>) | null): IRemoteList<T> {
const [state, setState] = useState<IRemoteList<T>>(emptyList);
useEffect(() => {
if (!load) {
setState(emptyList());
return;
}
let alive = true;
setState({ items: [], loading: true, error: '' });
load()
.then((items) => {
if (alive) setState({ items, loading: false, error: '' });
})
.catch((e) => {
if (alive) setState({ items: [], loading: false, error: friendlyErrorMessage(e) });
});
return () => {
alive = false;
};
}, [load]);
return state;
}
/**
* Заводы и рецепты выбранного завода. enabled = false (заявка только для чтения) —
* ничего не грузим: в просмотре показываются сохранённые в заявке названия.
*/
export function useBsuCatalog(context: WebPartContext, bsuId: string, enabled: boolean): IBsuCatalog {
const loadBsu = useCallback(() => getBsuList(context), [context]);
const loadFormulas = useCallback(() => getBsuFormulas(context, bsuId), [context, bsuId]);
const bsu = useRemoteList(enabled ? loadBsu : null);
const formulas = useRemoteList(enabled && bsuId ? loadFormulas : null);
return { bsu, formulas };
}
@@ -45,8 +45,6 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom
'Organization', 'Organization',
'Project', 'Project',
'ProjectId', 'ProjectId',
'BsuId',
'BsuName',
'ItemsJson', 'ItemsJson',
'RequestStatus', 'RequestStatus',
'RequestUniqueCode' 'RequestUniqueCode'
@@ -64,8 +62,6 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom
Organization: (item.Organization as string) || '', Organization: (item.Organization as string) || '',
Project: (item.Project as string) || '', Project: (item.Project as string) || '',
ProjectId: (item.ProjectId as string) || '', ProjectId: (item.ProjectId as string) || '',
BsuId: (item.BsuId as string) || '',
BsuName: (item.BsuName as string) || '',
Items: parseItems(item.ItemsJson) Items: parseItems(item.ItemsJson)
}; };
@@ -1,7 +1,6 @@
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { ISelectOption } from '../../shared/ui'; import { ISelectOption } from '../../shared/ui';
import { RequestTypeId } from '../../models/types'; import { RequestTypeId } from '../../models/types';
import { IBsuCatalog } from './bsu/useBsuCatalog';
/** /**
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков * Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
@@ -19,18 +18,8 @@ export const FORM_ID: RequestTypeId = 'wh-concrete-request';
* согласования (ByProject, см. IFormData). * согласования (ByProject, см. IFormData).
*/ */
export interface IConcreteItem { export interface IConcreteItem {
/** /** Класс/марка смеси и краткая характеристика (напр. «C20/25 F75 W6»). */
* Класс/марка смеси. Выбран рецепт БСУ — название рецепта; API БСУ недоступен
* или заявка старая — свободный текст (напр. «C20/25 F75 W6»).
*/
mixClass: string; mixClass: string;
/**
* id рецепта БСУ (GET /bsu/{id}/formulas). Нет — рецепт введён вручную или
* заявка подана до справочника БСУ, поэтому поле необязательное.
*/
formulaId?: number;
/** Состав рецепта на момент подачи — снимок: на заводе рецепт могут поменять. */
ingredients?: string;
/** Количество, м³. */ /** Количество, м³. */
volume: string; volume: string;
/** Подвижность смеси — см. WORKABILITY_OPTIONS. */ /** Подвижность смеси — см. WORKABILITY_OPTIONS. */
@@ -49,12 +38,6 @@ export const EMPTY_CONCRETE_ITEM: IConcreteItem = {
note: '' note: ''
}; };
/** Позиция без рецепта БСУ. Рецепт, введённый вручную (без formulaId), не трогаем. */
export function withoutFormula(item: IConcreteItem): IConcreteItem {
if (item.formulaId === undefined) return item;
return { ...item, formulaId: undefined, mixClass: '', ingredients: '' };
}
/** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */ /** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */
export const WORKABILITY_OPTIONS: ISelectOption[] = [ export const WORKABILITY_OPTIONS: ISelectOption[] = [
{ value: 'П1', label: 'П1' }, { value: 'П1', label: 'П1' },
@@ -107,10 +90,6 @@ export interface IFormData {
Project: string; Project: string;
/** oid проекта из справочника; '' — введён вручную без выбора. */ /** oid проекта из справочника; '' — введён вручную без выбора. */
ProjectId: string; ProjectId: string;
/** uuid БСУ (бетонного завода) из API БСУ; '' — введён вручную (API недоступен). */
BsuId: string;
/** Название БСУ — для просмотра и печати без обращения к API. */
BsuName: string;
/** Табличная часть — позиции поставки (сериализуется в ItemsJson). */ /** Табличная часть — позиции поставки (сериализуется в ItemsJson). */
Items: IConcreteItem[]; Items: IConcreteItem[];
} }
@@ -129,10 +108,6 @@ export interface ISectionProps {
onSiteChiefChange?: (login: string) => void; onSiteChiefChange?: (login: string) => void;
/** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */ /** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */
onForemanChange?: (login: string) => void; onForemanChange?: (login: string) => void;
/** Справочник БСУ. Не передан (просмотр) — БСУ и рецепты показываются сохранённым текстом. */
bsu?: IBsuCatalog;
/** Смена БСУ сбрасывает выбранные в позициях рецепты (они от прежнего завода). */
onBsuChange?: (bsuId: string, bsuName: string) => void;
} }
export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…'; export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…';
@@ -147,7 +122,5 @@ export const EMPTY_DATA: IFormData = {
Organization: '', Organization: '',
Project: '', Project: '',
ProjectId: '', ProjectId: '',
BsuId: '',
BsuName: '',
Items: [] Items: []
}; };
@@ -12,8 +12,6 @@
{ "internalName": "Organization", "title": "Организация", "type": "Note" }, { "internalName": "Organization", "title": "Организация", "type": "Note" },
{ "internalName": "Project", "title": "Проект", "type": "Text", "required": true }, { "internalName": "Project", "title": "Проект", "type": "Text", "required": true },
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" }, { "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
{ "internalName": "BsuId", "title": "Id БСУ (API БСУ)", "type": "Text" },
{ "internalName": "BsuName", "title": "БСУ", "type": "Text" },
{ "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" } { "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" }
] ]
@@ -1,51 +0,0 @@
import * as React from 'react';
import { Alert, FormField, Select, TextInput } from '../../../shared/ui';
import { IBsuCatalog } from '../bsu/useBsuCatalog';
import { bsuLabel } from '../bsu/bsu.service';
interface Props {
bsuId: string;
bsuName: string;
/** Не передан (просмотр) — показываем сохранённое название. */
catalog?: IBsuCatalog;
onChange: (bsuId: string, bsuName: string) => void;
}
const LABEL = 'БСУ (бетонный завод)';
/**
* Выбор БСУ из API. Если API не ответил — отправку не блокируем: предупреждение
* и ручной ввод названия (BsuId остаётся пустым).
*/
export const BsuField: React.FC<Props> = ({ bsuId, bsuName, catalog, onChange }) => {
if (!catalog || catalog.bsu.error) {
return (
<FormField label={LABEL} htmlFor="BsuName" required>
{catalog && (
<Alert variant="warn">
Не удалось загрузить список БСУ ({catalog.bsu.error}). Введите название вручную.
</Alert>
)}
<TextInput id="BsuName" value={bsuName} onChange={(v) => onChange('', v)} placeholder="Название БСУ" />
</FormField>
);
}
const options = catalog.bsu.items.map((b) => ({ value: b.id, label: bsuLabel(b) }));
return (
<FormField label={LABEL} htmlFor="BsuId" required>
<Select
id="BsuId"
value={bsuId}
onChange={(id) => {
const picked = catalog.bsu.items.find((b) => b.id === id);
onChange(id, picked ? bsuLabel(picked) : '');
}}
options={options}
placeholder={catalog.bsu.loading ? 'Загрузка…' : 'Выберите БСУ'}
readOnly={catalog.bsu.loading}
/>
</FormField>
);
};
@@ -2,18 +2,15 @@ import * as React from 'react';
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
import { ProjectPicker } from '../../../shared/dictionary'; import { ProjectPicker } from '../../../shared/dictionary';
import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model'; import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model';
import { BsuField } from './BsuField';
/** Шапка заявки на бетон: дата поставки, начальник участка (составил), проект, БСУ, мастер участка. */ /** Шапка заявки на бетон: дата поставки, начальник участка (составил), мастер участка. */
export const HeaderSection: React.FC<ISectionProps> = ({ export const HeaderSection: React.FC<ISectionProps> = ({
data, data,
set, set,
context, context,
printOrgIndex, printOrgIndex,
onSiteChiefChange, onSiteChiefChange,
onForemanChange, onForemanChange
bsu,
onBsuChange
}) => ( }) => (
<FormGrid> <FormGrid>
<FormField label="Дата поставки" htmlFor="RequestDate" required> <FormField label="Дата поставки" htmlFor="RequestDate" required>
@@ -40,18 +37,6 @@ export const HeaderSection: React.FC<ISectionProps> = ({
placeholder="Начните вводить название или код проекта…" placeholder="Начните вводить название или код проекта…"
/> />
</FormField> </FormField>
<BsuField
bsuId={data.BsuId}
bsuName={data.BsuName}
catalog={bsu}
onChange={
onBsuChange ??
((id, name) => {
set('BsuId', id);
set('BsuName', name);
})
}
/>
<FormField label="Мастер участка" htmlFor="ForemanEmail" full> <FormField label="Мастер участка" htmlFor="ForemanEmail" full>
<PeopleField <PeopleField
context={context} context={context}
@@ -49,61 +49,6 @@
} }
} }
// Состав рецепта БСУ под полем «Класс смеси» (RecipeIngredients).
.recipe {
margin-top: $sp-2;
padding: $sp-3;
border: 1px solid $navy-100;
border-left: 3px solid $gold-500;
border-radius: $r-sm;
background: $white;
font-family: $font-sans;
}
.recipeLabel {
display: block;
margin-bottom: $sp-2;
font-size: $fs-label;
font-weight: 600;
color: $navy-800;
}
.recipeList {
display: flex;
flex-wrap: wrap;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
.recipeChip {
display: inline-flex;
align-items: baseline;
gap: $sp-2;
padding: $sp-1 $sp-3;
border-radius: $r-full;
background: $navy-50;
border: 1px solid $navy-100;
font-size: $fs-sm;
}
.recipeName {
color: $gray-700;
}
.recipeAmount {
font-weight: 600;
color: $gray-900;
font-variant-numeric: tabular-nums;
}
.recipeRaw {
font-size: $fs-sm;
color: $gray-700;
overflow-wrap: anywhere;
}
.empty { .empty {
padding: $sp-3 0; padding: $sp-3 0;
font-family: $font-sans; font-family: $font-sans;
@@ -2,25 +2,22 @@ import * as React from 'react';
import styles from './ItemsEditor.module.scss'; import styles from './ItemsEditor.module.scss';
import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui'; import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui';
import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model'; import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model';
import { IRecipePicker, MixClassField } from './MixClassField';
export interface IItemsEditorProps { export interface IItemsEditorProps {
value: IConcreteItem[]; value: IConcreteItem[];
onChange: (rows: IConcreteItem[]) => void; onChange: (rows: IConcreteItem[]) => void;
/** Рецепты БСУ; не передан — класс смеси вводится текстом (см. MixClassField). */
recipePicker?: IRecipePicker;
} }
/** /**
* Табличная часть заявки на бетон: класс смеси (рецепт БСУ), объём, подвижность, * Табличная часть заявки на бетон: класс смеси, объём, подвижность, время и
* время и примечание — карточка на каждую поставку. Проект/объект сюда не входит: он * примечание — карточка на каждую поставку. Проект/объект сюда не входит: он
* один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) — * один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) —
* от него зависит маршрут согласования, поэтому поле не дублируется по позициям. * от него зависит маршрут согласования, поэтому поле не дублируется по позициям.
* *
* НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным * НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным
* FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных. * FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных.
*/ */
export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange, recipePicker }) => { export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) => {
const readOnly = useFormReadOnly(); const readOnly = useFormReadOnly();
const setRow = (index: number, patch: Partial<IConcreteItem>): void => { const setRow = (index: number, patch: Partial<IConcreteItem>): void => {
@@ -46,8 +43,13 @@ export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange, reci
)} )}
</div> </div>
<FormGrid> <FormGrid>
<FormField label="Класс смеси (рецепт)" htmlFor={`mixClass-${i}`} required full> <FormField label="Класс смеси и хар-ка" htmlFor={`mixClass-${i}`} required full>
<MixClassField id={`mixClass-${i}`} row={row} picker={recipePicker} onChange={(patch) => setRow(i, patch)} /> <TextInput
id={`mixClass-${i}`}
value={row.mixClass}
onChange={(v) => setRow(i, { mixClass: v })}
placeholder="C20/25 F75 W6"
/>
</FormField> </FormField>
<FormField label="Количество, м³" htmlFor={`volume-${i}`} required> <FormField label="Количество, м³" htmlFor={`volume-${i}`} required>
<TextInput id={`volume-${i}`} value={row.volume} onChange={(v) => setRow(i, { volume: v })} placeholder="0" /> <TextInput id={`volume-${i}`} value={row.volume} onChange={(v) => setRow(i, { volume: v })} placeholder="0" />
@@ -1,35 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import styles from './ItemsEditor.module.scss'; import { CollapseSection } from '../../../shared/ui';
import { Alert, CollapseSection } from '../../../shared/ui';
import { ItemsEditor } from './ItemsEditor'; import { ItemsEditor } from './ItemsEditor';
import { IRecipePicker } from './MixClassField';
import { ISectionProps } from '../model'; import { ISectionProps } from '../model';
import { IBsuCatalog } from '../bsu/useBsuCatalog';
/** /** Табличная часть заявки на бетон: класс смеси, объём, подвижность, время, примечание. */
* Рецепты из справочника — только когда API БСУ отвечает. Нет справочника export const ItemsSection: React.FC<ISectionProps> = ({ data, set }) => (
* (просмотр) или API упал — undefined: класс смеси вводится текстом, заявку
* подать можно.
*/
function toRecipePicker(bsuId: string, catalog?: IBsuCatalog): IRecipePicker | undefined {
if (!catalog || catalog.bsu.error || catalog.formulas.error) return undefined;
return { formulas: catalog.formulas.items, loading: catalog.formulas.loading, bsuChosen: !!bsuId };
}
/** Табличная часть заявки на бетон: класс смеси (рецепт БСУ), объём, подвижность, время, примечание. */
export const ItemsSection: React.FC<ISectionProps> = ({ data, set, bsu }) => (
<CollapseSection title="Позиции поставки" icon="clipboard"> <CollapseSection title="Позиции поставки" icon="clipboard">
{bsu?.formulas.error && ( <ItemsEditor value={data.Items} onChange={(rows) => set('Items', rows)} />
<div className={styles.full}>
<Alert variant="warn">
Не удалось загрузить рецепты БСУ ({bsu.formulas.error}). Укажите класс смеси вручную.
</Alert>
</div>
)}
<ItemsEditor
value={data.Items}
onChange={(rows) => set('Items', rows)}
recipePicker={toRecipePicker(data.BsuId, bsu)}
/>
</CollapseSection> </CollapseSection>
); );
@@ -1,64 +0,0 @@
import * as React from 'react';
import { Select, TextInput } from '../../../shared/ui';
import { RecipeIngredients } from './RecipeIngredients';
import { IConcreteItem } from '../model';
import { IBsuFormula } from '../bsu/IBsu';
import { formulaLabel } from '../bsu/bsu.service';
/** Рецепты выбранного БСУ для выпадающего списка. */
export interface IRecipePicker {
formulas: IBsuFormula[];
loading: boolean;
/** БСУ ещё не выбран — рецептов взять неоткуда. */
bsuChosen: boolean;
}
interface Props {
id: string;
row: IConcreteItem;
/** Не передан — ручной ввод (API БСУ недоступен) или просмотр сохранённой заявки. */
picker?: IRecipePicker;
onChange: (patch: Partial<IConcreteItem>) => void;
}
function pickerPlaceholder(picker: IRecipePicker): string {
if (!picker.bsuChosen) return 'Сначала выберите БСУ';
if (picker.loading) return 'Загрузка рецептов…';
if (picker.formulas.length === 0) return 'У этого БСУ нет рецептов';
return 'Выберите рецепт';
}
/** Класс смеси: рецепт из справочника БСУ, а без справочника — свободный текст. Под полем — состав рецепта. */
export const MixClassField: React.FC<Props> = ({ id, row, picker, onChange }) => {
const handlePick = (value: string): void => {
const formula = picker?.formulas.find((f) => String(f.id) === value);
onChange(
formula
? { formulaId: formula.id, mixClass: formulaLabel(formula), ingredients: formula.ingredients }
: { formulaId: undefined, mixClass: '', ingredients: '' }
);
};
return (
<>
{picker ? (
<Select
id={id}
value={row.formulaId === undefined ? '' : String(row.formulaId)}
onChange={handlePick}
options={picker.formulas.map((f) => ({ value: String(f.id), label: formulaLabel(f) }))}
placeholder={pickerPlaceholder(picker)}
readOnly={!picker.bsuChosen || picker.loading}
/>
) : (
<TextInput
id={id}
value={row.mixClass}
onChange={(v) => onChange({ mixClass: v, formulaId: undefined, ingredients: '' })}
placeholder="C20/25 F75 W6"
/>
)}
{row.ingredients && <RecipeIngredients value={row.ingredients} />}
</>
);
};
@@ -1,47 +0,0 @@
import * as React from 'react';
import styles from './ItemsEditor.module.scss';
interface Props {
/** Состав строкой из API БСУ: «Бункер 1: 670; Цемент 1: 400; Вода: 146». */
value: string;
}
interface IIngredient {
name: string;
amount: string;
}
/** «Бункер 1: 670; Вода: 146» → [{Бункер 1, 670}, {Вода, 146}]. Хоть одна часть не по формату — null. */
function parseIngredients(value: string): IIngredient[] | null {
const parts = value.split(';').map((p) => p.trim()).filter((p) => p.length > 0);
const result: IIngredient[] = [];
for (const part of parts) {
const colon = part.lastIndexOf(':');
if (colon <= 0) return null;
result.push({ name: part.slice(0, colon).trim(), amount: part.slice(colon + 1).trim() });
}
return result.length > 0 ? result : null;
}
/** Состав рецепта плашками «компонент — количество»; формат не распознан — исходная строка. */
export const RecipeIngredients: React.FC<Props> = ({ value }) => {
const ingredients = parseIngredients(value);
return (
<div className={styles.recipe}>
<span className={styles.recipeLabel}>Состав рецепта</span>
{ingredients ? (
<ul className={styles.recipeList}>
{ingredients.map((ing, i) => (
<li key={i} className={styles.recipeChip}>
<span className={styles.recipeName}>{ing.name}</span>
<span className={styles.recipeAmount}>{ing.amount}</span>
</li>
))}
</ul>
) : (
<span className={styles.recipeRaw}>{value}</span>
)}
</div>
);
};
@@ -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={{ </FormField>
id: 'PositionTransferJustification', <FormField label="Обоснование" htmlFor="PositionTransferJustification" full>
value: data.PositionTransferJustification, <TextArea
onChange: (v) => set('PositionTransferJustification', v) 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, buildPrintTitle } 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';
@@ -58,8 +58,6 @@ interface IDismissalViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявление на увольнение';
// Заявление на увольнение только для просмотра (режим согласующего). // Заявление на увольнение только для просмотра (режим согласующего).
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через // Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми. // <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
@@ -72,8 +70,6 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
// ФИО увольняющегося — для имени печатного документа.
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Итог автосоздания обходного листа после согласования (см. onDecided ниже). // Итог автосоздания обходного листа после согласования (см. onDecided ниже).
@@ -111,7 +107,6 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -270,9 +265,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
{printGate === 'allowed' ? ( {printGate === 'allowed' ? (
<PrintButton <PrintButton />
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
) : ( ) : (
<span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению"> <span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению">
Печать станет доступна после согласования обходного листа Печать станет доступна после согласования обходного листа
@@ -282,7 +275,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
<div className="rp-print-root"> <div className="rp-print-root">
<FormCard <FormCard
title={FORM_TITLE} title="Заявление на увольнение"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -292,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 ТК РК.'
];
@@ -2,15 +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 './EmployeeTransferView.module.scss'; import styles from './EmployeeTransferView.module.scss';
import { import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
buildPrintTitle
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -49,8 +41,6 @@ interface IEmployeeTransferViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявка на перемещение сотрудника';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined; const NOOP_SET: SetField = () => undefined;
@@ -66,8 +56,6 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
// ФИО перемещаемого — для имени печатного документа.
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать — // Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
@@ -101,7 +89,6 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
setEmployeeName(loaded.employeeName);
notifyCode(loaded.code); notifyCode(loaded.code);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -149,18 +136,13 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton <PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title={FORM_TITLE} title="Заявка на перемещение сотрудника"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -12,8 +12,7 @@ import {
PrintButton, PrintButton,
FieldChangesProvider, FieldChangesProvider,
FieldChangesBanner, FieldChangesBanner,
issueLabels, issueLabels
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { HIRE_CHANGE_TRACKING } from './changeTracking'; import { HIRE_CHANGE_TRACKING } from './changeTracking';
@@ -88,7 +87,6 @@ const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).'; const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».'; const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.'; const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
const FORM_TITLE = 'Заявка на прием';
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует // Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
// те же секции, что и форма подачи; редактирование выключено через // те же секции, что и форма подачи; редактирование выключено через
@@ -384,11 +382,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<Icon name="copy" size="sm" /> <Icon name="copy" size="sm" />
Организация рабочего места на основе этой Организация рабочего места на основе этой
</Button> </Button>
{isApprovedStatus(status) && ( {isApprovedStatus(status) && <PrintButton />}
<PrintButton
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.HireCandidateName, code, itemId })}
/>
)}
</div> </div>
{hasEditableFields && ( {hasEditableFields && (
@@ -406,7 +400,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
<div className="rp-print-root"> <div className="rp-print-root">
<FormCard <FormCard
title={FORM_TITLE} title="Заявка на прием"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -458,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="Исполнитель"
@@ -10,8 +10,7 @@ import {
FormModeProvider, FormModeProvider,
PrintButton, PrintButton,
FieldChangesProvider, FieldChangesProvider,
FieldChangesBanner, FieldChangesBanner
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges'; import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
import { SELECTION_CHANGE_TRACKING } from './changeTracking'; import { SELECTION_CHANGE_TRACKING } from './changeTracking';
@@ -81,7 +80,6 @@ interface IRecruitingSelectionViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error'; type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.'; const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
const FORM_TITLE = 'Заявка на подбор';
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>, // секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
@@ -368,10 +366,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
{/* Подбор — на вакансию, конкретного человека ещё нет: имя файла без ФИО. */} {isApprovedStatus(status) && <PrintButton />}
{isApprovedStatus(status) && (
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: '', code, itemId })} />
)}
</div> </div>
{fullEditable && ( {fullEditable && (
@@ -389,7 +384,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
<div className="rp-print-root"> <div className="rp-print-root">
<FormCard <FormCard
title={FORM_TITLE} title="Заявка на подбор"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -443,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="Исполнитель"
@@ -207,6 +207,28 @@ export const LANGUAGE_LEVEL_OPTIONS: ISelectOption[] = [
{ value: 'Свободный', label: 'Свободный' } { value: 'Свободный', label: 'Свободный' }
]; ];
/**
* Программы (ПО) — ВРЕМЕННЫЙ список, пока нет справочника в API. Используется в
* мультивыборе «Необходимые знания в программах». Меняется здесь одним местом.
*/
export const SOFTWARE_OPTIONS: ISelectOption[] = [
{ value: '1С:Предприятие', label: '1С:Предприятие' },
{ value: 'MS Word', label: 'MS Word' },
{ value: 'MS Excel', label: 'MS Excel' },
{ value: 'MS PowerPoint', label: 'MS PowerPoint' },
{ value: 'MS Outlook', label: 'MS Outlook' },
{ value: 'MS Project', label: 'MS Project' },
{ value: 'AutoCAD', label: 'AutoCAD' },
{ value: 'Autodesk Forma', label: 'Autodesk Forma' },
{ value: 'BIM', label: 'BIM' },
{ value: 'Revit', label: 'Revit' },
{ value: 'ArchiCAD', label: 'ArchiCAD' },
{ value: 'Компас-3D', label: 'Компас-3D' },
{ value: 'Primavera', label: 'Primavera' },
{ value: 'SAP', label: 'SAP' },
{ value: 'Adobe Photoshop', label: 'Adobe Photoshop' }
];
/** Категории «Обеспечение рабочего места» (+ «Другое» через allowOther). */ /** Категории «Обеспечение рабочего места» (+ «Другое» через allowOther). */
export const WORKPLACE_PROVISION_OPTIONS: ISelectOption[] = [ export const WORKPLACE_PROVISION_OPTIONS: ISelectOption[] = [
{ value: 'Оргтехника', label: 'Оргтехника' }, { value: 'Оргтехника', label: 'Оргтехника' },
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { CollapseSection, FormField, TextInput, TextArea, Select, MultiChoiceField } from '../../../shared/ui'; import { CollapseSection, FormField, TextInput, TextArea, Select, MultiChoiceField } from '../../../shared/ui';
import { PositionPicker, SOFTWARE_OPTIONS } from '../../../shared/dictionary'; import { PositionPicker } from '../../../shared/dictionary';
import { LanguagesField } from './LanguagesField'; import { LanguagesField } from './LanguagesField';
import { import {
ISectionProps, ISectionProps,
@@ -8,6 +8,7 @@ import {
EDUCATION_OPTIONS, EDUCATION_OPTIONS,
WORK_EXPERIENCE_OPTIONS, WORK_EXPERIENCE_OPTIONS,
RESIDENCE_OPTIONS, RESIDENCE_OPTIONS,
SOFTWARE_OPTIONS,
OTHER_VALUE OTHER_VALUE
} from '../model'; } from '../model';
@@ -11,8 +11,7 @@ import {
FormField, FormField,
AttachmentList, AttachmentList,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
@@ -61,8 +60,6 @@ interface IReferralViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Реферальная рекомендация';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined; const NOOP_SET: SetField = () => undefined;
@@ -192,19 +189,14 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
{isApprovedStatus(status) && ( {isApprovedStatus(status) && (
<PrintSplitButton <PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.CandidateName, code, itemId })}
/>
)} )}
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title={FORM_TITLE} title="Реферальная рекомендация"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -242,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="Исполнитель"
@@ -10,8 +10,7 @@ import {
FormModeProvider, FormModeProvider,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
Icon, Icon
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { import {
@@ -58,8 +57,6 @@ interface IServiceRequestViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявка на услуги';
// Заявка «Заявка на услуги» только для просмотра (режим согласующего). // Заявка «Заявка на услуги» только для просмотра (режим согласующего).
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта // onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
@@ -169,18 +166,13 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintSplitButton <PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.RequesterName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title={FORM_TITLE} title="Заявка на услуги"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -2,15 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './SkudExplanationView.module.scss'; import styles from './SkudExplanationView.module.scss';
import { import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
Alert,
FormCard,
InitiatorLine,
FormModeProvider,
PrintSplitButton,
OrgLetterhead,
buildPrintTitle
} from '../../shared/ui';
import { import {
ApproverService, ApproverService,
DecisionPanel, DecisionPanel,
@@ -138,12 +130,7 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton <PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.EmployeeFio, code, itemId })}
/>
</div> </div>
<FormCard <FormCard
@@ -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}
/> />
)} )}
</> </>
@@ -12,8 +12,7 @@ import {
FormField, FormField,
AttachmentList, AttachmentList,
PrintButton, PrintButton,
Icon, Icon
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { import {
@@ -57,8 +56,6 @@ interface ITicket1CViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявка в отдел 1С';
// Заявка в отдел 1С только для просмотра (режим согласующего). // Заявка в отдел 1С только для просмотра (режим согласующего).
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
@@ -188,12 +185,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.FIO, code, itemId })} /> <PrintButton />
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<FormCard <FormCard
title={FORM_TITLE} title="Заявка в отдел 1С"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -227,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="Исполнитель"
@@ -13,8 +13,7 @@ import {
AttachmentList, AttachmentList,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
Icon, Icon
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { import {
@@ -56,8 +55,6 @@ interface ITmcTransferViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Служебная записка на перемещение ТМЦ';
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего). // Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
@@ -172,18 +169,13 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintSplitButton <PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.AuthorName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title={FORM_TITLE} title="Служебная записка на перемещение ТМЦ"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -212,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,9 +9,7 @@ import {
FormModeProvider, FormModeProvider,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
FormNotice, LeaveNotice
PrintSignature,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
@@ -51,8 +49,6 @@ interface IUnpaidLeaveViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявление на отпуск без сохранения заработной платы';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined; const NOOP_SET: SetField = () => undefined;
@@ -68,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) идёт в печать —
@@ -103,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);
@@ -151,18 +144,13 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton <PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title={FORM_TITLE} title="Заявление на отпуск без сохранения заработной платы"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -170,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>
@@ -186,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,9 +10,7 @@ import {
FormModeProvider, FormModeProvider,
PrintSplitButton, PrintSplitButton,
OrgLetterhead, OrgLetterhead,
FormNotice, LeaveNotice
PrintSignature,
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { import {
ApproverService, ApproverService,
@@ -58,8 +56,6 @@ interface IVacationViewProps {
onCodeLoaded?: (code: string) => void; onCodeLoaded?: (code: string) => void;
} }
const FORM_TITLE = 'Заявление на оплачиваемый ежегодный трудовой отпуск';
// В режиме чтения поля не редактируются, поэтому обновление — пустышка. // В режиме чтения поля не редактируются, поэтому обновление — пустышка.
const NOOP_SET: SetField = () => undefined; const NOOP_SET: SetField = () => undefined;
@@ -210,18 +206,13 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
<PrintSplitButton <PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
value={data.Organization}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
/>
</div> </div>
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} /> <OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title={FORM_TITLE} title="Заявление на оплачиваемый ежегодный трудовой отпуск"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine <InitiatorLine
@@ -229,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)
]; ];
@@ -23,32 +23,16 @@
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол - **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
согласовании»/«Выполнение» (`useApproverRights.ts`). согласовании»/«Выполнение» (`useApproverRights.ts`).
- **Поля «Необходимое ПО», «Доступы» и «Мебель» заполняет согласующий-руководитель** - **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём
на своём этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой **первого** шага согласования и это текущий пользователь; сохраняются кнопкой
«Сохранить» и автоматически при «Согласовать» (`updateReviewFields`, «Сохранить» и автоматически при «Согласовать» (`updateReviewFields`,
`DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only. `DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only.
- **Необходимое ПО** — мультивыбор из общего списка `shared/dictionary/software.ts`
(тот же, что в подборе) + своё значение. В SP и в маршрут — по-прежнему ТЕКСТОМ
в колонке `Software` через запятую (`softwareList.ts`): условия «Software содержит
AutoCAD» и старые заявки со свободным текстом работают как раньше.
- **Мебель** — мультивыбор (`FURNITURE_OPTIONS`); при «Другое» появляется поле
своего значения. В SP — `WorkplaceFurnitureJson` (JSON-массив) и
`WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как
`WorkplaceFurnitureJson` (пункты через запятую, `routeFields.ts`) — на условие
шага исполнения «исполнитель по мебели».
- **Добавление исполнителей по правке согласующего** (`routeExecutorsSync.ts`):
маршрут планируется при подаче, поэтому если руководитель/Ербол поменял мебель
или ПО, при сохранении (и перед решением) строятся два плана исполнения — со
старыми и новыми значениями — и разница добавляется в конец исполнения
(«Ждёт очереди», `ApproverService.addMissingExecutors`). Никого не удаляем;
уже стоящих в исполнении (в любом статусе) не дублируем
(`shared/approval/missingExecutors.ts`).
- **Маска телефона** (PhoneInput). - **Маска телефона** (PhoneInput).
- Печать — общий механизм (без «Вложений»). - Печать — общий механизм (без «Вложений»).
## Секции формы ## Секции формы
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection · FurnitureSection HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection
## Точки входа ## Точки входа
- rptype: `it-workplace-setup` - rptype: `it-workplace-setup`
@@ -63,7 +63,6 @@ import { loadWorkplaceRequest } from './load';
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation'; import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
import { EmployeeSection } from './sections/EmployeeSection'; import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection'; import { EquipmentSection } from './sections/EquipmentSection';
import { FurnitureSection } from './sections/FurnitureSection';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
@@ -393,7 +392,6 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
<FormModeProvider readOnly={readOnly}> <FormModeProvider readOnly={readOnly}>
<EmployeeSection {...sectionProps} /> <EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} /> <EquipmentSection {...sectionProps} />
<FurnitureSection {...sectionProps} />
</FormModeProvider> </FormModeProvider>
</FieldChangesProvider> </FieldChangesProvider>
@@ -12,8 +12,7 @@ import {
OrgLetterhead, OrgLetterhead,
Icon, Icon,
FieldChangesProvider, FieldChangesProvider,
FieldChangesBanner, FieldChangesBanner
buildPrintTitle
} from '../../shared/ui'; } from '../../shared/ui';
import { navigate } from '../../shared/routing'; import { navigate } from '../../shared/routing';
import { import {
@@ -48,10 +47,8 @@ import { workplaceApprovalService, updateReviewFields, updateAllWorkplaceFields
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking'; import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation'; import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
import { useApproverRights } from './useApproverRights'; import { useApproverRights } from './useApproverRights';
import { addExecutorsForEditedFields, IExecutorsSyncResult } from './routeExecutorsSync';
import { EmployeeSection } from './sections/EmployeeSection'; import { EmployeeSection } from './sections/EmployeeSection';
import { EquipmentSection } from './sections/EquipmentSection'; import { EquipmentSection } from './sections/EquipmentSection';
import { FurnitureSection } from './sections/FurnitureSection';
import { useStableCallback } from '../../shared/useStableCallback'; import { useStableCallback } from '../../shared/useStableCallback';
interface IWorkplaceSetupViewProps { interface IWorkplaceSetupViewProps {
@@ -67,14 +64,13 @@ interface IWorkplaceSetupViewProps {
type SaveState = 'idle' | 'saving' | 'saved' | 'error'; type SaveState = 'idle' | 'saving' | 'saved' | 'error';
const FORM_TITLE = 'Организация рабочего места'; const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».';
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО», «Доступы» и «Мебель».';
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.'; const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
// Заявка «орг. рабочего места» только для просмотра (режим согласующего). // Заявка «орг. рабочего места» только для просмотра (режим согласующего).
// Исключения (оба — только на своём активном шаге, см. useApproverRights): // Исключения (оба — только на своём активном шаге, см. useApproverRights):
// - первый согласующий шаг (по маршруту — непосредственный руководитель) может // - первый согласующий шаг (по маршруту — непосредственный руководитель) может
// заполнить «Необходимое ПО», «Доступы» и «Мебель» (инициатор их не знает); // заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает);
// - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения. // - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения.
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка // onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
@@ -106,8 +102,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для // Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef). // архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
const dataListRef = listId || dataListTitle(FORM_ID); const dataListRef = listId || dataListTitle(FORM_ID);
// Title списка данных (а не url) — для оповещений, шапки «Инициатор» и чтения инициатора.
const dataListName = listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID);
const approval = useMemo(() => workplaceApprovalService(listId), [listId]); const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
const attachments = useAttachmentEditor(dataListRef, itemId); const attachments = useAttachmentEditor(dataListRef, itemId);
@@ -180,18 +174,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
}; };
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]); }, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
const notify = (newStatus?: string): void => {
if (!requestTypeId) return;
const target = {
requestTypeId,
dataListName,
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
const saveAllFields = async (): Promise<void> => { const saveAllFields = async (): Promise<void> => {
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) }; const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date()); const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date());
@@ -201,36 +183,18 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
await attachments.uploadPending(); await attachments.uploadPending();
}; };
// updateReviewFields пишет только поля руководителя (ReviewFields), остальное из data игнорирует. const saveReviewFields = (): Promise<void> =>
const saveReviewFields = (): Promise<void> => updateReviewFields(itemId, data, listId); updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId);
// Полное право шире: оно включает и поля руководителя. После записи полей — // Полное право шире: оно включает и «ПО»/«Доступы».
// исполнители, которых привела правка мебели/ПО (маршрут планировался при const saveFields = (): Promise<void> => (canEditAll ? saveAllFields() : saveReviewFields());
// подаче и сам их не добавит, см. routeExecutorsSync.ts).
const saveFields = async (): Promise<IExecutorsSyncResult> => {
const before = savedData;
if (canEditAll) {
await saveAllFields();
} else {
await saveReviewFields();
setSavedData(data);
}
return addExecutorsForEditedFields(
{ context, approval, approverService, requestTypeId, itemId, dataListTitle: dataListName },
before,
data
);
};
const handleSave = async (): Promise<void> => { const handleSave = async (): Promise<void> => {
setSaveState('saving'); setSaveState('saving');
setSaveErrorMessage(''); setSaveErrorMessage('');
try { try {
const executorsSync = await saveFields(); await saveFields();
setSaveState('saved'); setSaveState('saved');
// Новые исполнители должны появиться в таблице «Исполнение» — перечитываем маршрут.
if (executorsSync.addedCount > 0) setDecisionSeq((n) => n + 1);
if (executorsSync.activatedRowIds.length > 0) notify();
} catch (e) { } catch (e) {
setSaveErrorMessage(friendlyErrorMessage(e)); setSaveErrorMessage(friendlyErrorMessage(e));
setSaveState('error'); setSaveState('error');
@@ -261,6 +225,18 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
const sectionProps = { data, set, context, printOrgIndex }; const sectionProps = { data, set, context, printOrgIndex };
const canEditAnything = canEditAll || canEditReviewFields; const canEditAnything = canEditAll || canEditReviewFields;
const notify = (newStatus?: string): void => {
if (!requestTypeId) return;
const target = {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
};
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
};
return ( return (
<div> <div>
<div className={`${styles.toolbar} rp-no-print`}> <div className={`${styles.toolbar} rp-no-print`}>
@@ -273,12 +249,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
Редактировать Редактировать
</Button> </Button>
)} )}
<PrintSplitButton <PrintSplitButton value={organizationText(data)} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
value={organizationText(data)}
selectedIndex={printOrgIndex}
onChange={setPrintOrgIndex}
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.FullName, code, itemId })}
/>
</div> </div>
{canEditAnything && ( {canEditAnything && (
@@ -297,10 +268,13 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
<div className="rp-print-root"> <div className="rp-print-root">
<OrgLetterhead value={organizationText(data)} selectedIndex={printOrgIndex} /> <OrgLetterhead value={organizationText(data)} selectedIndex={printOrgIndex} />
<FormCard <FormCard
title={FORM_TITLE} title="Организация рабочего места"
subtitle={`№${code || itemId}`} subtitle={`№${code || itemId}`}
belowSubtitle={ belowSubtitle={
<InitiatorLine listTitle={dataListName} itemId={itemId} /> <InitiatorLine
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
itemId={itemId}
/>
} }
headerRight={status ? <StatusBadge status={status} /> : undefined} headerRight={status ? <StatusBadge status={status} /> : undefined}
> >
@@ -309,7 +283,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
<FormModeProvider readOnly={!canEditAll}> <FormModeProvider readOnly={!canEditAll}>
<EmployeeSection {...sectionProps} /> <EmployeeSection {...sectionProps} />
<EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} /> <EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} />
<FurnitureSection {...sectionProps} approverEditable={canEditReviewFields} />
</FormModeProvider> </FormModeProvider>
</FieldChangesProvider> </FieldChangesProvider>
@@ -323,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="Исполнитель"

Some files were not shown because too many files have changed in this diff Show More