Compare commits
40 Commits
82a9a68fbc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e34d290865 | |||
| b1060433d0 | |||
| cf039f4d08 | |||
| 88ce1d498e | |||
| 3495e681e7 | |||
| 583f400e26 | |||
| 36432c75e6 | |||
| 45fc06a6ac | |||
| 14ac640c50 | |||
| 2434ead754 | |||
| bdd8da9b89 | |||
| f61418dbe2 | |||
| 55659f94cf | |||
| 040ab475b5 | |||
| c4b2cb3c3d | |||
| 87c9f92d48 | |||
| b77261079b | |||
| 0005bf0cbd | |||
| 614a9d7e37 | |||
| efa3fdf65e | |||
| 16699ef2e2 | |||
| 9fd94e0adf | |||
| d3940ce584 | |||
| 49568e00e8 | |||
| f0951cdda2 | |||
| 26ddfcb5ba | |||
| e8b060add3 | |||
| ad1234b84a | |||
| 353a1aa543 | |||
| 7f641b36c6 | |||
| 9cacb6b271 | |||
| 72e330e659 | |||
| 975681105b | |||
| ddf313c951 | |||
| c59b0ad208 | |||
| 6aef9a5ba9 | |||
| 4c018154d3 | |||
| 17f9dec39b | |||
| 4eece061d1 | |||
| 9f97a19cab |
@@ -1 +1 @@
|
||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
||||
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||
"scope": "Reports.Read"
|
||||
},
|
||||
{
|
||||
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||
"scope": "Logs.Write"
|
||||
},
|
||||
{
|
||||
"resource": "Microsoft Flow Service",
|
||||
"scope": "Flows.Read.All"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/webpack-patch.schema.json",
|
||||
"patchFiles": ["./node_modules/@tnsu/monitoring-web/spfx/webpack-patch.cjs"]
|
||||
}
|
||||
@@ -269,7 +269,7 @@ Info "Подключаем @tnsu/ui-kit..."
|
||||
# .npmrc: адрес registry для скоупа @tnsu (НЕ секрет, можно коммитить).
|
||||
# Токен на чтение — у каждого разработчика свой, в ~/.npmrc (в проект не кладём).
|
||||
$npmrc = @'
|
||||
@tnsu:registry=http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/
|
||||
@tnsu:registry=https://git.tnsu.kz/api/packages/tansu/npm/
|
||||
'@
|
||||
$npmrc | Set-Content -Encoding UTF8 ".npmrc"
|
||||
Success ".npmrc создан (адрес registry @tnsu)."
|
||||
@@ -282,7 +282,7 @@ try {
|
||||
} catch {
|
||||
Warn "Не удалось поставить @tnsu/ui-kit. Скорее всего нет токена @tnsu в ~/.npmrc."
|
||||
Warn "Создай токен (Gitea → Настройки → Приложения → package: Read) и пропиши в ~/.npmrc:"
|
||||
Warn " //git.tnsu.kz/api/packages/timur.gorodetskiy/npm/:_authToken=ТВОЙ_ТОКЕН"
|
||||
Warn " //git.tnsu.kz/api/packages/tansu/npm/:_authToken=ТВОЙ_ТОКЕН"
|
||||
Warn "Потом выполни: npm install @tnsu/ui-kit"
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
| [`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-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) |
|
||||
| [`hr-contract-change`](../src/webparts/requestPortal/forms/ContractChange/README.md) | Изменение условий трудового договора | Отдел кадров | да | — | да | сотрудник из `Employee/FullBase` (и без учётки M365), снимок в текстовых колонках; маршрут по сотруднику, без учётки — по заявителю; 10 видов изменения (один, данные под несколько), поля по каждому виду по ТЗ кадров (кроме «Продления срока ТД»); инициатор работник/работодатель; подписанные вложения по «Что приложить» | ⏳ код готов (кроме вида 6), списки/тип/маршрут не заведены |
|
||||
| [`wh-service-request`](../src/webparts/requestPortal/forms/ServiceRequest/README.md) | Заявка на услуги | Склад / Снабжение | да | нет | да | маршрут по проекту (руководитель проекта/ПТО); табличные части «Приложения» и «Смета» (JSON-колонки); сумма по смете считается на лету (qty × цена), итог дублируется числовой колонкой для отчётов | ✅ |
|
||||
|
||||
Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения;
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
||||
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
|
||||
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
|
||||
| Заполнять «Необходимое ПО» и «Доступы» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
|
||||
| Заполнять «Необходимое ПО», «Доступы» и «Мебель» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
|
||||
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
||||
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
|
||||
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
|
||||
|
||||
Generated
+136
-28
@@ -17,7 +17,9 @@
|
||||
"@microsoft/sp-webpart-base": "1.22.1",
|
||||
"@pnp/sp": "^4.20.0",
|
||||
"@pnp/spfx-controls-react": "^3.25.0",
|
||||
"@tnsu/ui-kit": "^0.12.0",
|
||||
"@pnp/telemetry-js": "2.0.0",
|
||||
"@tnsu/monitoring-web": "^0.3.4",
|
||||
"@tnsu/ui-kit": "^0.15.0",
|
||||
"jszip": "~3.8.0",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
@@ -341,7 +343,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -351,7 +353,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
@@ -382,7 +384,7 @@
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -408,7 +410,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
@@ -425,7 +427,7 @@
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -457,7 +459,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
@@ -503,7 +505,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -513,7 +515,7 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.29.7",
|
||||
@@ -3979,7 +3981,7 @@
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -8726,6 +8728,28 @@
|
||||
"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": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz",
|
||||
@@ -9885,10 +9909,32 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"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": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "http://git.tnsu.kz/api/packages/timur.gorodetskiy/npm/%40tnsu%2Fui-kit/-/0.12.0/ui-kit-0.12.0.tgz",
|
||||
"integrity": "sha512-lJYf2DoYaydrktbSotRNY0umeJlQw8Ekhh6fu1FGB0p+/TXTozpBfAs+TxK/mvOXlS9yYenEuGBi85fd/dRF2A==",
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.15.0/ui-kit-0.15.0.tgz",
|
||||
"integrity": "sha512-vs+7NPem7PDaYj00nwaFztkw2K/nOkw6m2bvqgMABZG5rWjLVqfyFyiopKFkPYgqPWcMPwmQuxoFnc9xi7rwjQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@fluentui/react": "^8.106.4",
|
||||
@@ -10022,6 +10068,12 @@
|
||||
"@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": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
|
||||
@@ -10250,7 +10302,7 @@
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
@@ -10279,6 +10331,7 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
@@ -10308,6 +10361,7 @@
|
||||
"version": "17.0.45",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz",
|
||||
"integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -10319,6 +10373,7 @@
|
||||
"version": "17.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.17.tgz",
|
||||
"integrity": "sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "^17"
|
||||
@@ -10341,6 +10396,7 @@
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
@@ -11627,6 +11683,12 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||
@@ -12513,6 +12575,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -12537,7 +12608,7 @@
|
||||
"version": "2.11.7",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz",
|
||||
"integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
@@ -12711,7 +12782,7 @@
|
||||
"version": "4.28.7",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -12920,7 +12991,7 @@
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -13345,7 +13416,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
@@ -14234,7 +14305,7 @@
|
||||
"version": "1.5.398",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz",
|
||||
"integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/embla-carousel": {
|
||||
@@ -14564,7 +14635,7 @@
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -15984,7 +16055,7 @@
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -19133,7 +19204,7 @@
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
@@ -19813,6 +19884,12 @@
|
||||
"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": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
@@ -19935,7 +20012,6 @@
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -20077,7 +20153,7 @@
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -20742,7 +20818,6 @@
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -22018,6 +22093,40 @@
|
||||
"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": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
|
||||
@@ -23127,7 +23236,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -24407,7 +24515,7 @@
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
@@ -24461,7 +24569,7 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
+3
-1
@@ -21,7 +21,9 @@
|
||||
"@microsoft/sp-webpart-base": "1.22.1",
|
||||
"@pnp/sp": "^4.20.0",
|
||||
"@pnp/spfx-controls-react": "^3.25.0",
|
||||
"@tnsu/ui-kit": "^0.12.0",
|
||||
"@pnp/telemetry-js": "2.0.0",
|
||||
"@tnsu/monitoring-web": "^0.3.4",
|
||||
"@tnsu/ui-kit": "^0.15.0",
|
||||
"jszip": "~3.8.0",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Диагностика: печатает строки маршрута одной заявки из списка «— согласование»
|
||||
(StepOrder, статус, фаза, кем переназначено, кто создал/менял) и историю версий
|
||||
строк исполнения — чтобы понять, откуда в маршруте взялся участник и кто
|
||||
перевёл его в «На исполнении». Только чтение, ничего не меняет.
|
||||
|
||||
.EXAMPLE
|
||||
./Inspect-Route.ps1 -SiteUrl "https://tnsukz.sharepoint.com/sites/requests" `
|
||||
-ClientId "7459c965-5ed4-4673-8140-0ab8faf9db64" -Tenant "tnsukz.onmicrosoft.com" `
|
||||
-Thumbprint "224B64C8E0CAB1E09AF373E92B3AF4AC33C1233C" `
|
||||
-ListTitle "Прием на работу 1 — согласование" -RequestId 68
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$SiteUrl,
|
||||
[Parameter(Mandatory = $true)][string]$ClientId,
|
||||
[Parameter(Mandatory = $true)][string]$Tenant,
|
||||
[Parameter(Mandatory = $true)][string]$Thumbprint,
|
||||
[Parameter(Mandatory = $true)][string]$ListTitle,
|
||||
[Parameter(Mandatory = $true)][int]$RequestId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant -Thumbprint $Thumbprint
|
||||
|
||||
$query = "<View><Query><Where><Eq><FieldRef Name='Ticket' LookupId='TRUE'/><Value Type='Lookup'>$RequestId</Value></Eq></Where>" +
|
||||
"<OrderBy><FieldRef Name='StepOrder'/></OrderBy></Query></View>"
|
||||
$items = Get-PnPListItem -List $ListTitle -Query $query
|
||||
|
||||
function UserText($value) {
|
||||
if ($null -eq $value) { return '' }
|
||||
return $value.Email
|
||||
}
|
||||
|
||||
Write-Host "`nМаршрут заявки #$RequestId («$ListTitle»):`n" -ForegroundColor Cyan
|
||||
$items | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
Id = $_.Id
|
||||
StepOrder = $_['StepOrder']
|
||||
Phase = $_['Phase']
|
||||
Status = $_['ApprovalStatus']
|
||||
Approver = UserText $_['TansuSiteApprover']
|
||||
DelegatedBy = UserText $_['DelegatedBy']
|
||||
Reassign = $_['AllowReassignToOtherApprovers']
|
||||
Created = $_['Created']
|
||||
CreatedBy = UserText $_['Author']
|
||||
Modified = $_['Modified']
|
||||
ModifiedBy = UserText $_['Editor']
|
||||
}
|
||||
} | Format-Table -AutoSize
|
||||
|
||||
# История версий — только строки исполнения: там видно, кто и когда менял статус.
|
||||
$items | Where-Object { $_['Phase'] -eq 'Исполнение' } | ForEach-Object {
|
||||
Write-Host "`nВерсии строки Id=$($_.Id) ($(UserText $_['TansuSiteApprover'])):" -ForegroundColor Yellow
|
||||
Get-PnPProperty -ClientObject $_ -Property Versions | Out-Null
|
||||
$_.Versions | ForEach-Object {
|
||||
Get-PnPProperty -ClientObject $_ -Property CreatedBy | Out-Null
|
||||
[pscustomobject]@{
|
||||
Version = $_.VersionLabel
|
||||
Created = $_.Created
|
||||
By = $_.CreatedBy.Email
|
||||
Status = $_['ApprovalStatus']
|
||||
StepOrder = $_['StepOrder']
|
||||
}
|
||||
} | Format-Table -AutoSize
|
||||
}
|
||||
|
||||
Disconnect-PnPOnline
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '@microsoft/sp-property-pane';
|
||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-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 RequestPortal from './components/RequestPortal';
|
||||
@@ -98,7 +100,14 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
}
|
||||
|
||||
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(); // до всего остального — чтобы поймать и ранние отклонения
|
||||
// Телеметрия PnP (её включает @pnp/spfx-controls-react) шлёт fetch на pnptelemetryproxy.azurewebsites.net
|
||||
// без catch: где адрес закрыт прокси или блокировщиком, это необработанный отказ промиса в мониторинге.
|
||||
PnPTelemetry.getInstance().optOut();
|
||||
initSP(this.context); // единый клиент PnPjs для записи в списки
|
||||
|
||||
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они
|
||||
|
||||
@@ -42,6 +42,14 @@ export const REQUEST_TYPES: IRequestType[] = [
|
||||
icon: 'people',
|
||||
comingSoon: true
|
||||
},
|
||||
// Пока только тестировщикам: каркас без полей по видам; тип и маршрут не заведены в админке approval-route.
|
||||
{
|
||||
id: 'hr-contract-change',
|
||||
categoryId: 'hr',
|
||||
title: 'Изменение условий трудового договора',
|
||||
icon: 'document',
|
||||
comingSoon: true
|
||||
},
|
||||
|
||||
// Склад / Снабжение — формы открыты для ознакомления, но приём заявок ещё не
|
||||
// запущен (маршруты/списки не готовы): кнопки сохранения/отправки в самих
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './BusinessTripView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, buildPrintTitle } from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
ApprovalMatrix,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
StatusBadge,
|
||||
ApprovalHistory,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
usePhasedRoute
|
||||
} from '../../shared/approval';
|
||||
@@ -54,6 +55,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [employeeName, setEmployeeName] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -83,6 +85,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
setEmployeeName(loaded.employeeName);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -108,14 +111,17 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
);
|
||||
}
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// Решение уже применено; без requestTypeId просто некого уведомлять — UI не роняем.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context };
|
||||
@@ -124,7 +130,9 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintButton />
|
||||
<PrintButton
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
@@ -152,6 +160,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
printable={false}
|
||||
search={searchUsers}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
@@ -173,7 +182,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ILoadedRequest {
|
||||
status: string;
|
||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||
code: string;
|
||||
/** ФИО командируемого (Employee/Title) — для имени печатного документа. */
|
||||
employeeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,11 +39,12 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
||||
'TripObligationAccepted',
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode',
|
||||
'Employee/EMail'
|
||||
'Employee/EMail',
|
||||
'Employee/Title'
|
||||
)
|
||||
.expand('Employee')()) as Record<string, unknown>;
|
||||
|
||||
const employee = item.Employee as { EMail?: string } | undefined;
|
||||
const employee = item.Employee as { EMail?: string; Title?: string } | undefined;
|
||||
|
||||
const data: IFormData = {
|
||||
...EMPTY_DATA,
|
||||
@@ -61,6 +64,7 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
||||
return {
|
||||
data,
|
||||
status: (item.RequestStatus as string) || '',
|
||||
code: (item.RequestUniqueCode as string) || ''
|
||||
code: (item.RequestUniqueCode as string) || '',
|
||||
employeeName: employee?.Title || ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './ClearanceView.module.scss';
|
||||
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -11,6 +20,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
APPROVAL_PHASE
|
||||
} from '../../shared/approval';
|
||||
@@ -47,6 +57,8 @@ interface IClearanceViewProps {
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
const FORM_TITLE = 'Обходной лист';
|
||||
|
||||
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
|
||||
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
||||
// отдел подбора — парковочная карта (см. approverRights.ts).
|
||||
@@ -59,6 +71,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
// ФИО увольняемого — для имени печатного документа.
|
||||
const [employeeName, setEmployeeName] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
|
||||
@@ -122,6 +136,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
setEmployeeName(loaded.employeeName);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -255,22 +270,30 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
printOrgIndex
|
||||
};
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canEditAnything && (
|
||||
@@ -291,7 +314,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Обходной лист"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
@@ -311,6 +334,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
printable={false}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
@@ -348,7 +372,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -43,7 +43,8 @@ import {
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, withoutFormula } from './model';
|
||||
import { useBsuCatalog } from './bsu/useBsuCatalog';
|
||||
import { saveConcreteRequest, concreteRequestApprovalService } from './submit';
|
||||
import { loadConcreteRequest } from './load';
|
||||
import { HeaderSection } from './sections/HeaderSection';
|
||||
@@ -167,6 +168,19 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
.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) —
|
||||
// planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока
|
||||
@@ -205,10 +219,10 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
});
|
||||
|
||||
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() && hasItems);
|
||||
const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && data.BsuName.trim() && hasItems);
|
||||
const validate = (): string | undefined => {
|
||||
if (!isValid) {
|
||||
return 'Заполните обязательные поля: дата поставки, начальник участка, проект и хотя бы одну позицию (класс смеси + количество).';
|
||||
return 'Заполните обязательные поля: дата поставки, начальник участка, проект, БСУ и хотя бы одну позицию (класс смеси + количество).';
|
||||
}
|
||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||
@@ -303,7 +317,9 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
||||
set,
|
||||
context,
|
||||
onSiteChiefChange: handleSiteChiefChange,
|
||||
onForemanChange: handleForemanChange
|
||||
onForemanChange: handleForemanChange,
|
||||
bsu: readOnly ? undefined : bsu,
|
||||
onBsuChange: handleBsuChange
|
||||
};
|
||||
|
||||
// «Удалить черновик» — только у сохранённой заявки в статусе «Черновик».
|
||||
|
||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './ConcreteRequestView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -12,6 +20,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
useViewExecutors,
|
||||
approvalPhaseRows
|
||||
@@ -41,6 +50,8 @@ interface IConcreteRequestViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявка на бетон';
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
@@ -118,28 +129,36 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.SiteChiefName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Заявка на бетон"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
@@ -175,7 +194,7 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -51,11 +51,20 @@
|
||||
здесь другой, поэтому у формы свой редактор. Карточки (`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`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`.
|
||||
|
||||
## Секции формы
|
||||
HeaderSection (дата поставки · начальник участка · организация (read-only) ·
|
||||
проект · мастер участка) + ItemsEditor (табличная часть поставок)
|
||||
проект · БСУ · мастер участка) + ItemsEditor (табличная часть поставок)
|
||||
|
||||
## Точки входа
|
||||
- rptype: `wh-concrete-request`
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** БСУ (бетонно-смесительный узел) — ответ 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;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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,6 +45,8 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom
|
||||
'Organization',
|
||||
'Project',
|
||||
'ProjectId',
|
||||
'BsuId',
|
||||
'BsuName',
|
||||
'ItemsJson',
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode'
|
||||
@@ -62,6 +64,8 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom
|
||||
Organization: (item.Organization as string) || '',
|
||||
Project: (item.Project as string) || '',
|
||||
ProjectId: (item.ProjectId as string) || '',
|
||||
BsuId: (item.BsuId as string) || '',
|
||||
BsuName: (item.BsuName as string) || '',
|
||||
Items: parseItems(item.ItemsJson)
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { ISelectOption } from '../../shared/ui';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
import { IBsuCatalog } from './bsu/useBsuCatalog';
|
||||
|
||||
/**
|
||||
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||
@@ -18,8 +19,18 @@ export const FORM_ID: RequestTypeId = 'wh-concrete-request';
|
||||
* согласования (ByProject, см. IFormData).
|
||||
*/
|
||||
export interface IConcreteItem {
|
||||
/** Класс/марка смеси и краткая характеристика (напр. «C20/25 F75 W6»). */
|
||||
/**
|
||||
* Класс/марка смеси. Выбран рецепт БСУ — название рецепта; API БСУ недоступен
|
||||
* или заявка старая — свободный текст (напр. «C20/25 F75 W6»).
|
||||
*/
|
||||
mixClass: string;
|
||||
/**
|
||||
* id рецепта БСУ (GET /bsu/{id}/formulas). Нет — рецепт введён вручную или
|
||||
* заявка подана до справочника БСУ, поэтому поле необязательное.
|
||||
*/
|
||||
formulaId?: number;
|
||||
/** Состав рецепта на момент подачи — снимок: на заводе рецепт могут поменять. */
|
||||
ingredients?: string;
|
||||
/** Количество, м³. */
|
||||
volume: string;
|
||||
/** Подвижность смеси — см. WORKABILITY_OPTIONS. */
|
||||
@@ -38,6 +49,12 @@ export const EMPTY_CONCRETE_ITEM: IConcreteItem = {
|
||||
note: ''
|
||||
};
|
||||
|
||||
/** Позиция без рецепта БСУ. Рецепт, введённый вручную (без formulaId), не трогаем. */
|
||||
export function withoutFormula(item: IConcreteItem): IConcreteItem {
|
||||
if (item.formulaId === undefined) return item;
|
||||
return { ...item, formulaId: undefined, mixClass: '', ingredients: '' };
|
||||
}
|
||||
|
||||
/** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */
|
||||
export const WORKABILITY_OPTIONS: ISelectOption[] = [
|
||||
{ value: 'П1', label: 'П1' },
|
||||
@@ -90,6 +107,10 @@ export interface IFormData {
|
||||
Project: string;
|
||||
/** oid проекта из справочника; '' — введён вручную без выбора. */
|
||||
ProjectId: string;
|
||||
/** uuid БСУ (бетонного завода) из API БСУ; '' — введён вручную (API недоступен). */
|
||||
BsuId: string;
|
||||
/** Название БСУ — для просмотра и печати без обращения к API. */
|
||||
BsuName: string;
|
||||
/** Табличная часть — позиции поставки (сериализуется в ItemsJson). */
|
||||
Items: IConcreteItem[];
|
||||
}
|
||||
@@ -108,6 +129,10 @@ export interface ISectionProps {
|
||||
onSiteChiefChange?: (login: string) => void;
|
||||
/** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */
|
||||
onForemanChange?: (login: string) => void;
|
||||
/** Справочник БСУ. Не передан (просмотр) — БСУ и рецепты показываются сохранённым текстом. */
|
||||
bsu?: IBsuCatalog;
|
||||
/** Смена БСУ сбрасывает выбранные в позициях рецепты (они от прежнего завода). */
|
||||
onBsuChange?: (bsuId: string, bsuName: string) => void;
|
||||
}
|
||||
|
||||
export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…';
|
||||
@@ -122,5 +147,7 @@ export const EMPTY_DATA: IFormData = {
|
||||
Organization: '',
|
||||
Project: '',
|
||||
ProjectId: '',
|
||||
BsuId: '',
|
||||
BsuName: '',
|
||||
Items: []
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||
{ "internalName": "Project", "title": "Проект", "type": "Text", "required": true },
|
||||
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
|
||||
{ "internalName": "BsuId", "title": "Id БСУ (API БСУ)", "type": "Text" },
|
||||
{ "internalName": "BsuName", "title": "БСУ", "type": "Text" },
|
||||
|
||||
{ "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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,15 +2,18 @@ import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||
import { ProjectPicker } from '../../../shared/dictionary';
|
||||
import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model';
|
||||
import { BsuField } from './BsuField';
|
||||
|
||||
/** Шапка заявки на бетон: дата поставки, начальник участка (составил), мастер участка. */
|
||||
/** Шапка заявки на бетон: дата поставки, начальник участка (составил), проект, БСУ, мастер участка. */
|
||||
export const HeaderSection: React.FC<ISectionProps> = ({
|
||||
data,
|
||||
set,
|
||||
context,
|
||||
printOrgIndex,
|
||||
onSiteChiefChange,
|
||||
onForemanChange
|
||||
onForemanChange,
|
||||
bsu,
|
||||
onBsuChange
|
||||
}) => (
|
||||
<FormGrid>
|
||||
<FormField label="Дата поставки" htmlFor="RequestDate" required>
|
||||
@@ -37,6 +40,18 @@ export const HeaderSection: React.FC<ISectionProps> = ({
|
||||
placeholder="Начните вводить название или код проекта…"
|
||||
/>
|
||||
</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>
|
||||
<PeopleField
|
||||
context={context}
|
||||
|
||||
@@ -49,6 +49,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Состав рецепта БСУ под полем «Класс смеси» (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 {
|
||||
padding: $sp-3 0;
|
||||
font-family: $font-sans;
|
||||
|
||||
@@ -2,22 +2,25 @@ import * as React from 'react';
|
||||
import styles from './ItemsEditor.module.scss';
|
||||
import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui';
|
||||
import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model';
|
||||
import { IRecipePicker, MixClassField } from './MixClassField';
|
||||
|
||||
export interface IItemsEditorProps {
|
||||
value: IConcreteItem[];
|
||||
onChange: (rows: IConcreteItem[]) => void;
|
||||
/** Рецепты БСУ; не передан — класс смеси вводится текстом (см. MixClassField). */
|
||||
recipePicker?: IRecipePicker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Табличная часть заявки на бетон: класс смеси, объём, подвижность, время и
|
||||
* примечание — карточка на каждую поставку. Проект/объект сюда не входит: он
|
||||
* Табличная часть заявки на бетон: класс смеси (рецепт БСУ), объём, подвижность,
|
||||
* время и примечание — карточка на каждую поставку. Проект/объект сюда не входит: он
|
||||
* один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) —
|
||||
* от него зависит маршрут согласования, поэтому поле не дублируется по позициям.
|
||||
*
|
||||
* НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным
|
||||
* FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных.
|
||||
*/
|
||||
export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) => {
|
||||
export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange, recipePicker }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
const setRow = (index: number, patch: Partial<IConcreteItem>): void => {
|
||||
@@ -43,13 +46,8 @@ export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) =>
|
||||
)}
|
||||
</div>
|
||||
<FormGrid>
|
||||
<FormField label="Класс смеси и хар-ка" htmlFor={`mixClass-${i}`} required full>
|
||||
<TextInput
|
||||
id={`mixClass-${i}`}
|
||||
value={row.mixClass}
|
||||
onChange={(v) => setRow(i, { mixClass: v })}
|
||||
placeholder="C20/25 F75 W6"
|
||||
/>
|
||||
<FormField label="Класс смеси (рецепт)" htmlFor={`mixClass-${i}`} required full>
|
||||
<MixClassField id={`mixClass-${i}`} row={row} picker={recipePicker} onChange={(patch) => setRow(i, patch)} />
|
||||
</FormField>
|
||||
<FormField label="Количество, м³" htmlFor={`volume-${i}`} required>
|
||||
<TextInput id={`volume-${i}`} value={row.volume} onChange={(v) => setRow(i, { volume: v })} placeholder="0" />
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection } from '../../../shared/ui';
|
||||
import styles from './ItemsEditor.module.scss';
|
||||
import { Alert, CollapseSection } from '../../../shared/ui';
|
||||
import { ItemsEditor } from './ItemsEditor';
|
||||
import { IRecipePicker } from './MixClassField';
|
||||
import { ISectionProps } from '../model';
|
||||
import { IBsuCatalog } from '../bsu/useBsuCatalog';
|
||||
|
||||
/** Табличная часть заявки на бетон: класс смеси, объём, подвижность, время, примечание. */
|
||||
export const ItemsSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
/**
|
||||
* Рецепты из справочника — только когда API БСУ отвечает. Нет справочника
|
||||
* (просмотр) или 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">
|
||||
<ItemsEditor value={data.Items} onChange={(rows) => set('Items', rows)} />
|
||||
{bsu?.formulas.error && (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Ссылка «К списку заявок» — как в остальных формах (см. DismissalForm.module.scss).
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
margin-bottom: $sp-4;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
cursor: pointer;
|
||||
transition: color .18s;
|
||||
|
||||
&:hover {
|
||||
color: $gold-600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import styles from './ContractChangeForm.module.scss';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
Icon,
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
getDraftDeleteHandler,
|
||||
useRequestSubmit
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
composeRemoveRowRule,
|
||||
ApprovalMatrix,
|
||||
ExecutorsMatrix,
|
||||
ApprovalHistory,
|
||||
StatusBadge,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
isEditableStatus,
|
||||
unassignedStepMessage,
|
||||
resolveWriteListId,
|
||||
executorsToWrite,
|
||||
initialSubmitStatus
|
||||
} from '../../shared/approval';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { IRequestFormProps } from '../../models/types';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, REQUIRED_FIELDS_MESSAGE, routeEmail } from './model';
|
||||
import { saveContractChangeRequest, contractChangeApprovalService } from './submit';
|
||||
import { loadContractChangeRequest } from './load';
|
||||
import { useContractEmployee } from './useContractEmployee';
|
||||
import { useInitiatorAutofill } from './useInitiatorAutofill';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
import { ChangeTypeSections } from './byType/ChangeTypeSections';
|
||||
import { prepareFilesFor } from './byType/attachmentSlots';
|
||||
import { changeTypeSubmitError } from './byType/submitChecks';
|
||||
import { useContractRoute, matrixToApprovers } from './useContractRoute';
|
||||
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
|
||||
const ContractChangeForm: React.FC<IRequestFormProps> = ({
|
||||
requestType,
|
||||
context,
|
||||
onCancel,
|
||||
onSaved,
|
||||
editId,
|
||||
editListId,
|
||||
onCodeLoaded
|
||||
}) => {
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [requestId, setRequestId] = useState<number | undefined>(editId);
|
||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||
const submit = useRequestSubmit();
|
||||
// Стабильная обёртка над колбэком родителя — иначе заявка перезапрашивалась бы
|
||||
// при каждом рендере родителя (см. DismissalForm).
|
||||
const notifyCode = useStableCallback(onCodeLoaded);
|
||||
const { setError: setSubmitError } = submit;
|
||||
|
||||
const readOnly = !isEditableStatus(requestStatus);
|
||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||
const applicantEmail = effectiveUserEmail(context);
|
||||
|
||||
const approval = useMemo(() => contractChangeApprovalService(editListId), [editListId]);
|
||||
const requestTypeIdState = useRequestTypeId(context, requestType.id);
|
||||
const requestTypeId = requestTypeIdState.id;
|
||||
// Вложения (подписанные слоты по ТЗ «Что приложить», см. byType/attachmentSlots).
|
||||
const dataListRef = editListId || dataListTitle(FORM_ID);
|
||||
const attachments = useAttachmentEditor(dataListRef, requestId);
|
||||
const { setItems: setAttachmentItems } = attachments;
|
||||
|
||||
// Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое, статус и вложения.
|
||||
useEffect(() => {
|
||||
if (!editId) {
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
Promise.all([loadContractChangeRequest(editId, editListId), getAttachments(dataListRef, editId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (!alive) return;
|
||||
setData(loaded.data);
|
||||
setAttachmentItems(files);
|
||||
setRequestStatus(loaded.status);
|
||||
setRequestCode(loaded.code || undefined);
|
||||
if (loaded.code) notifyCode(loaded.code);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) {
|
||||
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [editId, editListId, dataListRef, notifyCode, setSubmitError, setAttachmentItems]);
|
||||
|
||||
// Сотрудник + его организация (как в командировке); новая заявка — по умолчанию на себя.
|
||||
const employeeControl = useContractEmployee(context, applicantEmail, !editId, data.Employee, (record) =>
|
||||
set('Employee', record)
|
||||
);
|
||||
// Пропсы шапки: сотрудник + инициатор, который следует за сотрудником, пока его не выбрали руками.
|
||||
const headerProps = useInitiatorAutofill(!!editId, data, set, applicantEmail, employeeControl);
|
||||
|
||||
// Маршрут — по сотруднику, без учётной записи — по заявителю (см. routeEmail); при
|
||||
// переводе в другое подразделение принимающий руководитель — первым шагом.
|
||||
const { matrix, executors } = useContractRoute({
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
routeUserEmail: routeEmail(employeeControl.person, applicantEmail),
|
||||
editId,
|
||||
requestStatus,
|
||||
readOnly,
|
||||
receivingHeadEmail:
|
||||
!readOnly && data.ChangeTypes.includes('department-transfer') ? data.DepartmentTransferReceivingHead : ''
|
||||
});
|
||||
|
||||
const isValid = !!data.Employee && data.ChangeTypes.length > 0;
|
||||
const validate = (): string | undefined => {
|
||||
if (!isValid) return REQUIRED_FIELDS_MESSAGE;
|
||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const save = (status: string): (() => Promise<void>) => async (): Promise<void> => {
|
||||
// validate() уже гарантировал requestTypeId, но TS этого через замыкание не видит.
|
||||
const rtId = requestTypeId;
|
||||
if (!rtId) {
|
||||
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
|
||||
}
|
||||
// Новая заявка пишется в ТЕКУЩУЮ партицию — спрашиваем её прямо перед записью.
|
||||
const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId);
|
||||
const { id, code } = await saveContractChangeRequest(
|
||||
requestId,
|
||||
rtId,
|
||||
data,
|
||||
matrixToApprovers(matrix.rows),
|
||||
executorsToWrite(executors.rows),
|
||||
status,
|
||||
writeListId
|
||||
);
|
||||
// id фиксируем ДО загрузки файлов: если она упадёт, повторное сохранение
|
||||
// обновит эту же заявку, а не создаст вторую.
|
||||
setRequestId(id);
|
||||
setRequestStatus(status);
|
||||
if (code) setRequestCode(code);
|
||||
// Файлы — после записи элемента (нужен его id), с подписью-префиксом слота.
|
||||
try {
|
||||
await attachments.uploadPending(prepareFilesFor(data), {
|
||||
listRef: writeListId || dataListTitle(FORM_ID),
|
||||
itemId: id
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Заявка сохранена, но файлы не прикрепились: ${friendlyErrorMessage(e)}. Сохраните ещё раз.`);
|
||||
}
|
||||
if (onSaved) onSaved();
|
||||
// Отправка — уведомляем первый активный шаг; списки той партиции, куда записали.
|
||||
if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) {
|
||||
await notifyActiveStep(context, approval, id, {
|
||||
requestTypeId: rtId,
|
||||
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
|
||||
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// На отправку маршрут должен быть укомплектован — шаг без человека молча пропал бы при записи.
|
||||
const validateSubmit = (): string | undefined => {
|
||||
const formError = validate();
|
||||
if (formError) return formError;
|
||||
// Обязательное по виду изменения (документы, принимающий руководитель и т.п.) — только на отправку.
|
||||
const typeError = changeTypeSubmitError(data, attachments);
|
||||
if (typeError) return typeError;
|
||||
if (matrix.error) {
|
||||
return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`;
|
||||
}
|
||||
const unassignedError = unassignedStepMessage(
|
||||
matrix.rows,
|
||||
(r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`
|
||||
);
|
||||
if (unassignedError) return unassignedError;
|
||||
return unassignedStepMessage(
|
||||
executors.rows,
|
||||
(r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.`
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveDraft = (): Promise<void> => submit.run('draft', save(REQUEST_STATUS.draft), validate);
|
||||
const handleSubmit = (): Promise<void> =>
|
||||
submit.run(
|
||||
'submit',
|
||||
save(
|
||||
executors.rows.length > 0
|
||||
? initialSubmitStatus(matrix.rows.length, executors.rows.length)
|
||||
: REQUEST_STATUS.onApproval
|
||||
),
|
||||
validateSubmit
|
||||
);
|
||||
|
||||
const handleDelete = getDraftDeleteHandler({
|
||||
formKey: FORM_ID,
|
||||
requestId,
|
||||
requestStatus,
|
||||
listId: editListId,
|
||||
code: requestCode,
|
||||
submit,
|
||||
onDeleted: onCancel
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" className={styles.back} onClick={onCancel}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
<span>К списку заявок</span>
|
||||
</button>
|
||||
|
||||
<FormCard
|
||||
title={requestType.title}
|
||||
subtitle="Категория: Отдел кадров"
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={editListId ? dataListTitleForListId(FORM_ID, editListId) : dataListTitle(FORM_ID)}
|
||||
itemId={requestId}
|
||||
/>
|
||||
}
|
||||
headerRight={
|
||||
requestId || requestStatus ? (
|
||||
<>
|
||||
{requestStatus && <StatusBadge status={requestStatus} />}
|
||||
{requestCode && <RequestNumber code={requestCode} />}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{submit.savedKind && !submit.error && (
|
||||
<Alert variant="ok">
|
||||
{submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'}
|
||||
</Alert>
|
||||
)}
|
||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<RequestSection data={data} set={set} context={context} {...headerProps} />
|
||||
<ChangeTypeSections data={data} set={set} context={context} attachments={attachments} />
|
||||
</FormModeProvider>
|
||||
|
||||
<ApprovalMatrix
|
||||
editor={matrix}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
canRemoveRow={composeRemoveRowRule(applicantEmail)}
|
||||
/>
|
||||
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* Автору на доработке важно видеть, почему вернули, — история и в форме. */}
|
||||
{editId && (
|
||||
<div className="rp-no-print">
|
||||
<ApprovalHistory
|
||||
approval={approval}
|
||||
dataListRef={editListId || dataListTitle(FORM_ID)}
|
||||
requestId={editId}
|
||||
reloadKey={requestStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubmitActions
|
||||
onDelete={handleDelete}
|
||||
submit={submit}
|
||||
isValid={isValid}
|
||||
readOnly={readOnly}
|
||||
onCancel={onCancel}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Отправить на согласование"
|
||||
/>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractChangeForm;
|
||||
@@ -0,0 +1,9 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
// Заглушка на время загрузки/ошибки — как в остальных просмотрах.
|
||||
.state {
|
||||
padding: $sp-8;
|
||||
color: $gray-500;
|
||||
font-size: $fs-sm;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './ContractChangeView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider } from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
StatusBadge,
|
||||
ApprovalMatrix,
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
useViewExecutors,
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
approvalListTitle,
|
||||
approvalListTitleForListId,
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { loadContractChangeRequest } from './load';
|
||||
import { contractChangeApprovalService } from './submit';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
import { ChangeTypeSections } from './byType/ChangeTypeSections';
|
||||
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||
import { getAttachments } from '../../shared/attachments/attachments.service';
|
||||
import { useStableCallback } from '../../shared/useStableCallback';
|
||||
|
||||
interface IContractChangeViewProps {
|
||||
context: WebPartContext;
|
||||
itemId: number;
|
||||
/** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */
|
||||
formKey: string;
|
||||
/** Партиция, где лежит ЭТА заявка — см. IApprovalViewProps.listId. */
|
||||
listId?: string;
|
||||
/** Уведомление о загруженном уникальном коде заявки — см. IApprovalViewProps.onCodeLoaded. */
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const VIEW_TITLE = 'Изменение условий трудового договора';
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
/**
|
||||
* Заявка только для просмотра (режим согласующего): та же секция, что в форме
|
||||
* подачи, под <FormModeProvider readOnly>, + маршрут, история и панель решения.
|
||||
* Печати пока нет — бланк кадров ещё не согласован.
|
||||
*/
|
||||
const ContractChangeView: React.FC<IContractChangeViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
const notifyCode = useStableCallback(onCodeLoaded);
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const approval = useMemo(() => contractChangeApprovalService(listId), [listId]);
|
||||
const { id: requestTypeId } = useRequestTypeId(context, formKey);
|
||||
// listId (url) для архивной партиции, Title текущей — см. ApproverService.dataListRef.
|
||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||
// Вложения — только для показа (секции рисуют их под FormModeProvider readOnly).
|
||||
const attachments = useAttachmentEditor(dataListRef, itemId);
|
||||
const { setItems: setAttachmentItems } = attachments;
|
||||
|
||||
// decisionSeq в reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
);
|
||||
const executors = useViewExecutors(context, approval, itemId, decisionSeq);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.all([loadContractChangeRequest(itemId, listId), getAttachments(dataListRef, itemId)])
|
||||
.then(([loaded, files]) => {
|
||||
if (!alive) return;
|
||||
setData(loaded.data);
|
||||
setAttachmentItems(files);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
notifyCode(loaded.code);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(`Не удалось загрузить заявку №${itemId}: ${friendlyErrorMessage(e)}`);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.state}>
|
||||
<Alert variant="err">{error}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const notify = (): void => {
|
||||
// Тип ещё не резолвился — решение уже применилось, просто некого уведомлять;
|
||||
// не роняем UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const handleRouteChanged = (): void => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormCard
|
||||
title={VIEW_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
||||
itemId={itemId}
|
||||
/>
|
||||
}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormModeProvider readOnly>
|
||||
<RequestSection data={data} set={NOOP_SET} context={context} />
|
||||
<ChangeTypeSections data={data} set={NOOP_SET} context={context} attachments={attachments} />
|
||||
</FormModeProvider>
|
||||
</FormCard>
|
||||
|
||||
<ApprovalMatrix editor={approvers} search={(q) => approval.searchUsers(q)} />
|
||||
<ExecutorsMatrix editor={executors} search={(q) => approval.searchUsers(q)} />
|
||||
|
||||
{/* История: маршрут пересохраняется патчем (см. writeRoute), прошлые решения — в версиях строк. */}
|
||||
<ApprovalHistory approval={approval} dataListRef={dataListRef} requestId={itemId} reloadKey={decisionSeq} />
|
||||
|
||||
<DecisionPanel
|
||||
service={approverService}
|
||||
requestId={itemId}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
handleRouteChanged();
|
||||
}}
|
||||
onDelegated={handleRouteChanged}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractChangeView;
|
||||
@@ -0,0 +1,127 @@
|
||||
# Изменение условий трудового договора (hr-contract-change)
|
||||
|
||||
**Назначение:** заявка на изменение условий трудового договора сотрудника (ст. 33 ТК РК) —
|
||||
повышение оклада, надбавка, перевод, смена режима работы и т. д. (10 видов из ТЗ кадров).
|
||||
**Категория:** Отдел кадров
|
||||
**Списки SP:** «Изменение условий трудового договора 1 — данные» / «… 1 — согласование»
|
||||
**RequestTypeId (внешний API маршрутов):** не хардкодится — резолвится в рантайме по
|
||||
`pageUrl` («hr-contract-change») через `useRequestTypeId` (см. «Формы больше не хардкодят
|
||||
RequestTypeId» в `docs/forms.md`). Тип и маршрут в админке approval-route ещё не заведены.
|
||||
|
||||
> **Статус (2026-10-29):** поля разобраны по всем видам, кроме 6 «Продление срока ТД»
|
||||
> (это автоматический запрос, не обычная заявка — отдельно). ТЗ кадров — файл
|
||||
> «ТЗ_ст33_маршруты_v2» (листы «Виды изменений ст.33» и «Маршрут»); оно общее, в форму
|
||||
> из него попадает только то, что явно согласовано. Ещё не сделано: передача вида и
|
||||
> инициатора в маршрут (`fieldValues`: фин. блок — виды 1, 2, 3, 5, 9; маршрут А/Б —
|
||||
> по инициатору), печатный бланк, заведение типа и маршрутов в админке.
|
||||
|
||||
## Маршрут
|
||||
- Фазы: согласование (+ исполнение, если его заведут в админке — форма поддерживает).
|
||||
- Маршрут строится по **e-mail сотрудника**, а если его нет (рабочий без учётной
|
||||
записи M365) — по **e-mail заявителя** (`routeEmail` в `model.ts`); на форме под
|
||||
сотрудником тогда пометка об этом.
|
||||
- Матрица редактируема заявителем при новой заявке / черновике / возврате.
|
||||
- От вида изменения маршрут пока не зависит (`fieldValues` не передаются).
|
||||
|
||||
## Особое поведение
|
||||
- **Сотрудник — из `Employee/FullBase`, а не People-picker** (`EmployeeFullBasePicker`,
|
||||
`shared/dictionary`): так находятся и рабочие без учётной записи. Ищется по части
|
||||
Ф.И.О. (от 3 букв), только работающие (`dismissalDate` пустая). Записи FullBase
|
||||
(одна на место работы) собираются в человека по ИИН — в поиске он одной строкой.
|
||||
- **Организация — отдельный список под сотрудником**, как «Командирующая организация»
|
||||
в командировке: места работы человека в виде «Компания — должность — подразделение».
|
||||
Одно место — выбрано само и заблокировано. Условия меняются в ОДНОМ договоре, поэтому
|
||||
в заявку пишется только выбранное место работы. Логика — `useContractEmployee`.
|
||||
⚠️ На 2026-10-28 FullBase отдаёт по человеку одну запись; бэкенд доработают, чтобы
|
||||
возвращались все места работы — тогда у совместителей появится выбор.
|
||||
- **Хранение сотрудника — снимок в текстовых колонках `Employee*`** (Ф.И.О., ИИН,
|
||||
табельный, e-mail, организация, должность, подразделение, id записи), без колонки
|
||||
People. Справочник со временем меняется, а заявка фиксирует данные на момент подачи.
|
||||
- **По умолчанию — на себя** (`useContractEmployee`): Ф.И.О. из `Employee/ByEmail` →
|
||||
поиск в `FullBase` → человек, у которого есть место работы с e-mail текущего
|
||||
пользователя. Совместителю организацию не выбираем молча — выбирает сам.
|
||||
- **Черновик/возврат:** список организаций подтягивается заново (поиск по Ф.И.О.,
|
||||
тот же ИИН); не нашлось — в списке только сохранённая.
|
||||
- **Маршрут строится по человеку** (его e-mail), а не по месту работы — поэтому
|
||||
грузится сразу после выбора сотрудника, ещё до выбора организации.
|
||||
- **Вид изменения** — `ChangeTypes`, MultiChoice в SP, массив в модели. По ТЗ выбор
|
||||
один (`MAX_CHANGE_TYPES`, обычный `Select`); данные уже рассчитаны на несколько,
|
||||
чтобы снять ограничение правкой UI, без миграции. В коде вид — ключ
|
||||
(`ChangeType`), в SP — подпись (`CHANGE_TYPE_LABELS`): подпись = choice колонки,
|
||||
менять только вместе с `provisioning.json`.
|
||||
- **Заголовок элемента** (`Title`): `Изменение условий ТД — {ФИО} — дд.мм.гггг`.
|
||||
|
||||
## Поля по видам
|
||||
Каждый вид — своя секция в `byType/<Вид>/`, подключается строкой в
|
||||
`byType/ChangeTypeSections.tsx`; ключи модели и колонки SP — с префиксом вида.
|
||||
Колонки невыбранного вида при сохранении очищаются (`null`), чтобы в заявке не
|
||||
оставалось скрытых значений после смены вида.
|
||||
|
||||
| Вид | Секция | Поля (все необязательные, без ограничений) |
|
||||
| --- | --- | --- |
|
||||
| Повышение должностного оклада | `SalaryIncrease` | текущий оклад, новый оклад (`MoneyInput`, Number), дата изменения, причина (испытательный срок / расширение функционала / иное), обоснование — при «иное» это и есть текст причины |
|
||||
| Установление надбавки / доплаты | `Allowance` | основание (а–д: надбавка или доплата видно из пункта), дата начала, дата окончания или «бессрочно» (флажок прячет и очищает дату), размер + переключатель «% / ₸» (`UnitSwitch`, по умолчанию %), обоснование — в т.ч. объём доп. работы при совмещении и текст «иное». Письменное согласие работника на совмещение оформляет кадровик после согласования — не в форме. Вложение **«Результаты аттестации»** — только при основании «г», обязательно |
|
||||
| Перевод на другую должность (постоянный) | `PositionTransfer` | новая должность (`PositionPicker`: справочник `Dictionary/Positions` + ручной ввод), дата перевода, причина (карьерный рост / вакантная должность / новый объект, перераспределение функций / иное), обоснование. Работника и работодателя не различаем — причины видны всем, работник пишет текст заявления в обоснование. Вложение **«Должностная инструкция»** — всегда, обязательно |
|
||||
| Перевод в другое подразделение | `DepartmentTransfer` | новое подразделение (`DepartmentPicker`, имя + id), дата перевода, причина (усиление подразделения / перераспределение функций / вакансия / иное), обоснование, **принимающий руководитель** (People, обязателен при отправке) — см. ниже |
|
||||
| Изменение места работы (другая местность) | `WorkplaceChange` | место работы «было → стало» парой **организация + проект**: текущая организация — из «Организации» сотрудника (только чтение, отдельно не хранится — это `EmployeeCompany`), текущий проект (`ProjectPicker`); новая организация (`Dictionary/Companies`, **обязательна при отправке**, не подставляется) и новый проект; адрес нового места, дата, причина (новый объект / переезд подразделения или работодателя / иное), условия переезда и компенсации; переключатель **«Вахта»** → «График работы» (тот же набор, что в подборе и приёме, «Другое» → ручной ввод; без вахты график очищается); обоснование |
|
||||
| Продление срока ТД | — | **пропущен** (2026-10-29): по ТЗ это автоматический запрос ОК за месяц до окончания срочного ТД (маршрут В), а не обычная заявка — решать отдельно |
|
||||
| Изменение режима / графика работы | `ScheduleChange` | по ролям строго по ТЗ. Общее: текущий график + текущее время работы (списки как в подборе/приёме, «Другое» → текст; `OptionWithOtherField`, наборы — `byType/workSchedule.ts`), дата начала, вложение «Новый график» (необязательно). **Работник:** неполный рабочий день (часов) / неполная рабочая неделя (дней) — одно или оба, текст заявления. **Работодатель:** новый режим (вахтовый / сменный / иной + описание), новые график и время работы, причина (специфика объекта / смещение сроков проекта — только из ТЗ), обоснование. Поля другой роли при сохранении очищаются |
|
||||
| Изменение трудовой функции (без смены должности) | `FunctionChange` | ровно по шаблону ТЗ: дата изменения, «В связи с» (свободный текст — списка причин в ТЗ нет), вложение **«Новая должностная инструкция»** — обязательно. Инициатор — только руководитель |
|
||||
| Изменение условий труда по инициативе работодателя | `EmployerInitiative` | причина (из названия вида в ТЗ: экономические / технологические / изменения в организации / сокращение объёма работ + «Другое» со своим текстом); «Что меняется» — флажки а) график работы, б) должностной оклад (можно оба, при отправке — хотя бы один); а) текущий → новый график + дата; б) **один новый оклад + доля постоянной части в %** (по умолчанию 75 из ТЗ, 0–100, можно дробную), переменная доля = остаток до 100 %, суммы обеих частей в ₸ считаются сами (`salaryParts.ts`, превысить 100 % нельзя; в SP пишутся при сохранении) + дата; даты **не раньше +15 дней** (уведомление по ТЗ: блок в календаре + проверка при отправке); обоснование — **текстом ИЛИ документом**, одно из двух обязательно при отправке |
|
||||
| Временный перевод при производственной необходимости / простое | `TemporaryTransfer` | откуда и куда — проект из справочника + работа текстом; дата начала / окончания — **не дольше 3 месяцев** (по ТЗ «до 3 мес. в календарном году»; включительно: с 01.01 по 31.03, `period.ts`; сумму за год проверяет HR-директор); основание (производственная необходимость / простой), обоснование; пометка из шаблона ТЗ «Оплата — не ниже средней заработной платы по прежней работе». Инициатор — только руководитель |
|
||||
|
||||
Обязательное по виду при отправке (инициатор, документы, принимающий руководитель,
|
||||
новая организация) — одна функция `byType/submitChecks.ts`; черновик сохраняется без них.
|
||||
|
||||
**Инициатор (работник / работодатель)** — решение 2026-10-29: разделяем строго по
|
||||
ТЗ, чтобы у кадров не было вопросов. Поле `ChangeInitiator` в шапке — только у
|
||||
видов, которые по ТЗ может начать и работник (3, 4, 5, 7; `hasInitiatorChoice`).
|
||||
По умолчанию при выборе вида: подаёт на себя — «Работник», за другого —
|
||||
«Работодатель» (`defaultInitiatorRole`), под полем подсказка об этом; можно
|
||||
поменять. Пока инициатора не выбирали руками, он следует за сотрудником: сменили
|
||||
сотрудника — пересчитался (`useInitiatorAutofill`); после ручного выбора и у
|
||||
открытого черновика — не трогается. От роли (`byType/InitiatorReasonFields`): работник — только «Текст
|
||||
заявления» (по ТЗ «От работника: заявление (текст)»), работодатель — причина из
|
||||
списка + «Обоснование». Текст пишется в ту же колонку `*Justification`; причина у
|
||||
работника при сохранении очищается. Остальные поля вида — у обеих ролей.
|
||||
|
||||
**Принимающий руководитель — временное решение (2026-10-29).** По ТЗ (лист
|
||||
«Маршрут»: А шаг 2, Б шаг 3 параллельно с непосредственным руководителем) его
|
||||
согласие — шаг маршрута. Approval-route строит маршрут по сотруднику и найти
|
||||
руководителя НОВОГО подразделения не умеет, поэтому шаг вставляет форма:
|
||||
`byType/DepartmentTransfer/receivingHead.ts` (`withReceivingHead`, с тестами) —
|
||||
первым шагом, с должностью-пометкой «Принимающий руководитель» (по ней шаг
|
||||
узнаётся у черновика); вызывается из `useContractRoute`. Смена человека/вида
|
||||
пересобирает маршрут. Ограничения: человек выбирается вручную; шаг всегда первым и
|
||||
последовательным (для маршрута Б параллельность инициатор может включить в матрице);
|
||||
SLA и настроек из админки у шага нет. **Правильное решение** — тип шага в
|
||||
approval-route «руководитель подразделения из заявки» по `departmentId` (его
|
||||
форма уже хранит в `DepartmentTransferNewDepartmentId`). Когда появится — удалить
|
||||
поле, `receivingHead.ts` и `receivingHeadEmail` в `useContractRoute`, передать
|
||||
`departmentId` в резолв.
|
||||
|
||||
**Вложения по колонке ТЗ «Что приложить»** (решение 2026-10-28): фиксированные
|
||||
подписанные слоты по виду (подпись — что за документ); обязательны там, где ТЗ
|
||||
прямо требует документ (вид 2 «г» — аттестация, вид 3 — ДИ, вид 8 — новая ДИ;
|
||||
вид 9 — текст ИЛИ файл), «можно приложить» — необязательно (вид 7).
|
||||
|
||||
Как устроено: вложения элемента списка данных, подпись — префикс в имени файла
|
||||
(`shared/attachments/attachmentCategories`, тот же механизм, что в «Приёме»), поле
|
||||
слота — `AttachmentSlotField`. Какие слоты видны и обязательны — одна функция
|
||||
`byType/attachmentSlots.ts` (`activeAttachmentSlots`); новый слот = строка там +
|
||||
`AttachmentSlotField` в секции вида. Файлы грузятся после записи элемента; файлы
|
||||
скрытого слота (сменили вид/основание) не грузятся. Обязательность проверяется
|
||||
только при отправке — черновик сохраняется и без файла.
|
||||
|
||||
## Открытые вопросы (проверить на тесте)
|
||||
- Выдаёт ли бэкенд (вебхуки) права на элемент по People-колонке `Employee`? Если да —
|
||||
сотрудник, за которого подали заявку, её не увидит (такой колонки здесь нет).
|
||||
- `Employee/FullBase`: ищет ли `Fio` по подстроке; что фильтрует `Status`.
|
||||
|
||||
## Секции формы
|
||||
RequestSection (сотрудник · организация · вид изменения) · ChangeTypeSections (секция выбранного вида, см. «Поля по видам»)
|
||||
|
||||
## Точки входа
|
||||
- rptype: `hr-contract-change`
|
||||
- Режим согласующего (просмотр): есть (`ContractChangeView`), без печати
|
||||
- «Создать на основе»: нет
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"dataListTitle": "Изменение условий трудового договора — согласование",
|
||||
"columns": [
|
||||
{ "internalName": "TansuSiteApprover", "title": "Согласующий", "type": "User", "userSelectionMode": "PeopleOnly" },
|
||||
{ "internalName": "ApprovalStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "Ждёт очереди", "На согласовании", "На исполнении", "Согласовано", "Выполнено", "Отклонено", "Возвращена на доработку"] },
|
||||
{ "internalName": "Ticket", "title": "Заявка", "type": "Lookup", "lookupList": "Изменение условий трудового договора — данные", "lookupShowField": "Title" },
|
||||
{ "internalName": "Date", "title": "Дата", "type": "DateTime" },
|
||||
{ "internalName": "Comment", "title": "Комментарий", "type": "Note" },
|
||||
{ "internalName": "StepOrder", "title": "Порядок", "type": "Number" },
|
||||
{ "internalName": "AllowReassignToOtherApprovers", "title": "Можно перепоручать", "type": "Boolean" },
|
||||
{ "internalName": "Phase", "title": "Фаза", "type": "Choice", "choices": ["Согласование", "Исполнение"] },
|
||||
{ "internalName": "SLAhours", "title": "SLA, часов", "type": "Number" },
|
||||
{ "internalName": "AskOverdueReason", "title": "Опрос причины просрочки", "type": "Boolean" },
|
||||
{ "internalName": "AutoApprove", "title": "Автосогласование", "type": "Boolean" },
|
||||
{ "internalName": "DelegatedBy", "title": "Кем переназначено", "type": "User", "userSelectionMode": "PeopleOnly" }
|
||||
]
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
@import '../../../../../../styles/design-tokens';
|
||||
|
||||
// Сумма + переключатель «% / ₸» в одну строку: поле тянется, переключатель — по содержимому.
|
||||
.amount {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.amountInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, MoneyInput, TextInput, TextArea, Select, Checkbox } from '../../../../shared/ui';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { ALLOWANCE_REASONS, ATTESTATION_ATTACHMENT, requiresAttestation } from './options';
|
||||
import { UnitSwitch } from './UnitSwitch';
|
||||
import { sanitizePercentInput, amountForUnit } from './amount';
|
||||
import styles from './AllowanceSection.module.scss';
|
||||
|
||||
/**
|
||||
* Поля вида «Установление надбавки / доплаты». Состав согласован с Тимуром по ТЗ
|
||||
* кадров: основание (а–д), срок с/по или бессрочно, размер в % или ₸, обоснование
|
||||
* (в т.ч. объём доп. работы при совмещении). Поля необязательные; вложение
|
||||
* «Результаты аттестации» — только при основании «г» и там обязательно (по ТЗ).
|
||||
*
|
||||
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||
*/
|
||||
export const AllowanceSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
return (
|
||||
<FormGrid>
|
||||
<FormField label="Основание" htmlFor="AllowanceReason" full>
|
||||
<Select
|
||||
id="AllowanceReason"
|
||||
value={data.AllowanceReason}
|
||||
onChange={(v) => set('AllowanceReason', v)}
|
||||
options={ALLOWANCE_REASONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата начала" htmlFor="AllowanceStartDate">
|
||||
<TextInput
|
||||
id="AllowanceStartDate"
|
||||
type="date"
|
||||
value={data.AllowanceStartDate}
|
||||
onChange={(v) => set('AllowanceStartDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата окончания" htmlFor="AllowanceEndDate">
|
||||
{/* «Бессрочно» прячет дату окончания — и очищает её, чтобы не сохранить скрытое значение. */}
|
||||
{!data.AllowanceIndefinite && (
|
||||
<TextInput
|
||||
id="AllowanceEndDate"
|
||||
type="date"
|
||||
value={data.AllowanceEndDate}
|
||||
onChange={(v) => set('AllowanceEndDate', v)}
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
checked={data.AllowanceIndefinite}
|
||||
onChange={(checked) => {
|
||||
set('AllowanceIndefinite', checked);
|
||||
if (checked) set('AllowanceEndDate', '');
|
||||
}}
|
||||
label="Бессрочно"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Размер" htmlFor="AllowanceAmount">
|
||||
<div className={styles.amount}>
|
||||
<div className={styles.amountInput}>
|
||||
{/* Проценты бывают дробными (7,5 %), тенге — целые с разделителем тысяч. */}
|
||||
{data.AllowanceUnit === '%' ? (
|
||||
<TextInput
|
||||
id="AllowanceAmount"
|
||||
value={data.AllowanceAmount}
|
||||
onChange={(v) => set('AllowanceAmount', sanitizePercentInput(v))}
|
||||
placeholder="например, 7.5"
|
||||
/>
|
||||
) : (
|
||||
<MoneyInput id="AllowanceAmount" value={data.AllowanceAmount} onChange={(v) => set('AllowanceAmount', v)} />
|
||||
)}
|
||||
</div>
|
||||
<UnitSwitch
|
||||
value={data.AllowanceUnit}
|
||||
onChange={(unit) => {
|
||||
set('AllowanceUnit', unit);
|
||||
set('AllowanceAmount', amountForUnit(data.AllowanceAmount, unit));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Обоснование" htmlFor="AllowanceJustification" full>
|
||||
<TextArea
|
||||
id="AllowanceJustification"
|
||||
value={data.AllowanceJustification}
|
||||
onChange={(v) => set('AllowanceJustification', v)}
|
||||
/>
|
||||
</FormField>
|
||||
{attachments && requiresAttestation(data.AllowanceReason) && (
|
||||
<AttachmentSlotField
|
||||
label={ATTESTATION_ATTACHMENT}
|
||||
editor={attachments}
|
||||
editable={!readOnly}
|
||||
groupKey={ATTESTATION_ATTACHMENT}
|
||||
category={ATTESTATION_ATTACHMENT}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</FormGrid>
|
||||
);
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
@import '../../../../../../styles/design-tokens';
|
||||
|
||||
// Две кнопки впритык, как сегментный контрол; высота — как у полей ввода рядом.
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.option {
|
||||
min-width: 44px;
|
||||
padding: 0 $sp-3;
|
||||
border: 0;
|
||||
background: $white;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-700;
|
||||
cursor: pointer;
|
||||
transition: .15s;
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid $gray-300;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $gray-100;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.active,
|
||||
.active:hover:not(:disabled) {
|
||||
background: $navy-700;
|
||||
color: $white;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { AllowanceUnit, ALLOWANCE_UNITS } from './options';
|
||||
import styles from './UnitSwitch.module.scss';
|
||||
|
||||
interface Props {
|
||||
value: AllowanceUnit;
|
||||
onChange: (unit: AllowanceUnit) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключатель единицы размера «% / ₸» рядом с полем суммы. Сегментного
|
||||
* контрола в ui-kit нет; если понадобится ещё где-то — перенести туда.
|
||||
* В режиме чтения кнопки неактивны, выбранная остаётся подсвеченной.
|
||||
*/
|
||||
export const UnitSwitch: React.FC<Props> = ({ value, onChange }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
return (
|
||||
<div className={styles.switch} role="radiogroup" aria-label="Единица размера">
|
||||
{ALLOWANCE_UNITS.map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={unit === value}
|
||||
className={unit === value ? `${styles.option} ${styles.active}` : styles.option}
|
||||
disabled={readOnly}
|
||||
onClick={() => onChange(unit)}
|
||||
>
|
||||
{unit}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { sanitizePercentInput, amountForUnit } from './amount';
|
||||
|
||||
describe('sanitizePercentInput', () => {
|
||||
it('оставляет целое как есть', () => {
|
||||
expect(sanitizePercentInput('10')).toBe('10');
|
||||
});
|
||||
|
||||
it('запятую превращает в точку', () => {
|
||||
expect(sanitizePercentInput('7,5')).toBe('7.5');
|
||||
});
|
||||
|
||||
it('выбрасывает буквы, пробелы и знак процента', () => {
|
||||
expect(sanitizePercentInput(' 1a2 %')).toBe('12');
|
||||
});
|
||||
|
||||
it('оставляет только первый разделитель и не больше двух знаков дроби', () => {
|
||||
expect(sanitizePercentInput('1.2.345')).toBe('1.23');
|
||||
});
|
||||
|
||||
it('разрешает разделитель в конце — человек ещё вводит дробь', () => {
|
||||
expect(sanitizePercentInput('7,')).toBe('7.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('amountForUnit', () => {
|
||||
it('при переходе в тенге отбрасывает дробную часть', () => {
|
||||
expect(amountForUnit('7.5', '₸')).toBe('7');
|
||||
});
|
||||
|
||||
it('в процентах значение не меняет', () => {
|
||||
expect(amountForUnit('7.5', '%')).toBe('7.5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AllowanceUnit } from './options';
|
||||
|
||||
/** Знаков после разделителя у процента (7,25 %). */
|
||||
const PERCENT_FRACTION_DIGITS = 2;
|
||||
|
||||
/**
|
||||
* Ввод процента → «сырое» число строкой с точкой: «7,5» → «7.5». Пропускаем
|
||||
* только цифры и один разделитель (запятая или точка), дробь — до 2 знаков.
|
||||
* Не type=number: у TextInput нет step, и браузер считал бы 7.5 невалидным при шаге 1.
|
||||
*/
|
||||
export function sanitizePercentInput(text: string): string {
|
||||
const normalized = text.replace(/,/g, '.').replace(/[^\d.]/g, '');
|
||||
const [whole, ...rest] = normalized.split('.');
|
||||
if (rest.length === 0) return whole;
|
||||
return `${whole}.${rest.join('').slice(0, PERCENT_FRACTION_DIGITS)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Значение суммы после смены единицы: в тенге сумма целая (MoneyInput),
|
||||
* поэтому дробную часть процента отбрасываем, а не показываем «75» из «7.5».
|
||||
*/
|
||||
export function amountForUnit(amount: string, unit: AllowanceUnit): string {
|
||||
if (unit === '₸') return amount.split('.')[0];
|
||||
return amount;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
|
||||
/**
|
||||
* Основания надбавки/доплаты — по шаблону текста ТЗ кадров (а–д). Что это —
|
||||
* надбавка или доплата — видно из самого пункта, отдельного поля нет. Значения
|
||||
* совпадают с choices колонки AllowanceReason (provisioning.json).
|
||||
*/
|
||||
export const ALLOWANCE_REASON_MENTORING = 'Надбавка: наставничество и обучение новых работников';
|
||||
export const ALLOWANCE_REASON_TRAVEL = 'Надбавка: разъездной характер работы';
|
||||
export const ALLOWANCE_REASON_COMBINING = 'Доплата: совмещение должностей / расширение зоны обслуживания';
|
||||
export const ALLOWANCE_REASON_HAZARDOUS = 'Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда';
|
||||
export const ALLOWANCE_REASON_OTHER = 'Доплата: иное по решению первого руководителя';
|
||||
|
||||
export const ALLOWANCE_REASONS: ISelectOption[] = [
|
||||
ALLOWANCE_REASON_MENTORING,
|
||||
ALLOWANCE_REASON_TRAVEL,
|
||||
ALLOWANCE_REASON_COMBINING,
|
||||
ALLOWANCE_REASON_HAZARDOUS,
|
||||
ALLOWANCE_REASON_OTHER
|
||||
].map((reason) => ({ value: reason, label: reason }));
|
||||
|
||||
/**
|
||||
* Вложение «Результаты аттестации» — по ТЗ прикладывается документ при доплате
|
||||
* за вредные условия (пункт «г»); там же оно обязательно.
|
||||
*/
|
||||
export const ATTESTATION_ATTACHMENT = 'Результаты аттестации';
|
||||
|
||||
export function requiresAttestation(reason: string): boolean {
|
||||
return reason === ALLOWANCE_REASON_HAZARDOUS;
|
||||
}
|
||||
|
||||
/** Единица размера; значения = choices колонки AllowanceUnit. */
|
||||
export type AllowanceUnit = '%' | '₸';
|
||||
export const ALLOWANCE_UNITS: AllowanceUnit[] = ['%', '₸'];
|
||||
/** По умолчанию — проценты: пример в ТЗ «Надбавка 10%». */
|
||||
export const DEFAULT_ALLOWANCE_UNIT: AllowanceUnit = '%';
|
||||
|
||||
/** Строка из SP → единица; неизвестное/пустое — единица по умолчанию. */
|
||||
export function allowanceUnitFromText(text: string): AllowanceUnit {
|
||||
return ALLOWANCE_UNITS.find((unit) => unit === text) ?? DEFAULT_ALLOWANCE_UNIT;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Поля вида идут сразу под шапкой (сотрудник · вид · инициатор) отдельной сеткой
|
||||
// FormGrid. У сетки ui-kit нет внешнего отступа, а расстояние между строками полей —
|
||||
// 18px (row-gap FormGrid): тот же отступ, чтобы стык двух сеток был не виден.
|
||||
.typeFields {
|
||||
margin-top: 18px;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
import { ChangeType } from '../changeTypes';
|
||||
import { ISectionProps } from '../model';
|
||||
import { SalaryIncreaseSection } from './SalaryIncrease/SalaryIncreaseSection';
|
||||
import { AllowanceSection } from './Allowance/AllowanceSection';
|
||||
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: поля видов добавляются по мере
|
||||
* согласования с кадрами; у вида без секции под выбором просто ничего не появится.
|
||||
* Новый вид = папка byType/<Вид>/ + строка здесь.
|
||||
*/
|
||||
const SECTION_BY_TYPE: Partial<Record<ChangeType, React.FC<ISectionProps>>> = {
|
||||
'salary-increase': SalaryIncreaseSection,
|
||||
allowance: AllowanceSection,
|
||||
'position-transfer': PositionTransferSection,
|
||||
'department-transfer': DepartmentTransferSection,
|
||||
'workplace-change': WorkplaceChangeSection,
|
||||
'schedule-change': ScheduleChangeSection,
|
||||
'function-change': FunctionChangeSection,
|
||||
'employer-initiative': EmployerInitiativeSection,
|
||||
'temporary-transfer': TemporaryTransferSection
|
||||
};
|
||||
|
||||
/**
|
||||
* Секции всех выбранных видов, в порядке выбора (сейчас вид один — см. MAX_CHANGE_TYPES).
|
||||
* Заголовка у секции нет — вид выбран прямо над ней; обёртка даёт только отступ от шапки.
|
||||
*/
|
||||
export const ChangeTypeSections: React.FC<ISectionProps> = (props) => (
|
||||
<>
|
||||
{props.data.ChangeTypes.map((type) => {
|
||||
const Section = SECTION_BY_TYPE[type];
|
||||
return Section ? (
|
||||
<div key={type} className={styles.typeFields}>
|
||||
<Section {...props} />
|
||||
</div>
|
||||
) : undefined;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, PeopleField } from '../../../../shared/ui';
|
||||
import { InitiatorReasonFields } from '../InitiatorReasonFields';
|
||||
import { DepartmentPicker } from '../../../../shared/dictionary';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { DEPARTMENT_TRANSFER_REASONS } from './options';
|
||||
|
||||
/**
|
||||
* Поля вида «Перевод в другое подразделение». Состав согласован с Тимуром по ТЗ
|
||||
* кадров: новое подразделение (справочник), дата, причина (а–г), обоснование и
|
||||
* принимающий руководитель. Его согласие по ТЗ — шаг маршрута: форма сама ставит
|
||||
* его первым шагом согласования (см. receivingHead.ts). Обязателен при отправке.
|
||||
*
|
||||
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||
*/
|
||||
export const DepartmentTransferSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||
<FormGrid>
|
||||
<FormField label="Новое подразделение" htmlFor="DepartmentTransferNewDepartment">
|
||||
<DepartmentPicker
|
||||
context={context}
|
||||
id="DepartmentTransferNewDepartment"
|
||||
value={data.DepartmentTransferNewDepartment}
|
||||
valueId={data.DepartmentTransferNewDepartmentId}
|
||||
onChange={(name, id) => {
|
||||
set('DepartmentTransferNewDepartment', name);
|
||||
set('DepartmentTransferNewDepartmentId', id);
|
||||
}}
|
||||
placeholder="Начните вводить название подразделения…"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата перевода" htmlFor="DepartmentTransferDate">
|
||||
<TextInput
|
||||
id="DepartmentTransferDate"
|
||||
type="date"
|
||||
value={data.DepartmentTransferDate}
|
||||
onChange={(v) => set('DepartmentTransferDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Принимающий руководитель" htmlFor="DepartmentTransferReceivingHead" required full>
|
||||
<PeopleField
|
||||
context={context}
|
||||
value={data.DepartmentTransferReceivingHead}
|
||||
onChange={(v) => set('DepartmentTransferReceivingHead', v)}
|
||||
placeholder="Начните вводить Ф.И.О. руководителя, который принимает работника…"
|
||||
/>
|
||||
</FormField>
|
||||
<InitiatorReasonFields
|
||||
initiator={data.ChangeInitiator}
|
||||
reason={{
|
||||
id: 'DepartmentTransferReason',
|
||||
value: data.DepartmentTransferReason,
|
||||
options: DEPARTMENT_TRANSFER_REASONS,
|
||||
onChange: (v) => set('DepartmentTransferReason', v)
|
||||
}}
|
||||
justification={{
|
||||
id: 'DepartmentTransferJustification',
|
||||
value: data.DepartmentTransferJustification,
|
||||
onChange: (v) => set('DepartmentTransferJustification', v)
|
||||
}}
|
||||
/>
|
||||
</FormGrid>
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
|
||||
/**
|
||||
* Причины перевода в другое подразделение — по шаблону текста ТЗ кадров (а–г).
|
||||
* Работника и работодателя не различаем (как в переводе на должность).
|
||||
* Значения совпадают с choices колонки DepartmentTransferReason (provisioning.json).
|
||||
*/
|
||||
export const DEPARTMENT_TRANSFER_REASONS: ISelectOption[] = [
|
||||
'Усиление подразделения (рост объёма работ / запуск нового объекта)',
|
||||
'Перераспределение функций между подразделениями',
|
||||
'Вакансия, соответствующая квалификации работника',
|
||||
'Иное'
|
||||
].map((reason) => ({ value: reason, label: reason }));
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { IMatrixRow } from '../../../../shared/approval';
|
||||
import { withReceivingHead, RECEIVING_HEAD_POSITION } from './receivingHead';
|
||||
|
||||
const row = (order: number, email: string, position = 'Директор'): IMatrixRow => ({
|
||||
order,
|
||||
who: email,
|
||||
email,
|
||||
position,
|
||||
status: '—',
|
||||
date: '',
|
||||
comment: '',
|
||||
allowReassign: false
|
||||
});
|
||||
|
||||
const head = { email: 'head@tnsu.kz', name: 'Принимающий Иван' };
|
||||
|
||||
describe('withReceivingHead', () => {
|
||||
it('вставляет принимающего первым шагом и сдвигает остальные', () => {
|
||||
const result = withReceivingHead([row(1, 'hrd@tnsu.kz'), row(2, 'dir@tnsu.kz')], head);
|
||||
expect(result.map((r) => [r.order, r.email, r.position])).toEqual([
|
||||
[1, 'head@tnsu.kz', RECEIVING_HEAD_POSITION],
|
||||
[2, 'hrd@tnsu.kz', 'Директор'],
|
||||
[3, 'dir@tnsu.kz', 'Директор']
|
||||
]);
|
||||
});
|
||||
|
||||
it('сохраняет параллельные шаги параллельными', () => {
|
||||
const result = withReceivingHead([row(1, 'a@tnsu.kz'), row(1, 'b@tnsu.kz'), row(2, 'c@tnsu.kz')], head);
|
||||
expect(result.map((r) => r.order)).toEqual([1, 2, 2, 3]);
|
||||
});
|
||||
|
||||
it('без принимающего убирает его шаг и перенумеровывает', () => {
|
||||
const withHead = withReceivingHead([row(1, 'hrd@tnsu.kz')], head);
|
||||
expect(withReceivingHead(withHead, undefined).map((r) => [r.order, r.email])).toEqual([[1, 'hrd@tnsu.kz']]);
|
||||
});
|
||||
|
||||
it('при смене человека заменяет шаг, а не добавляет второй', () => {
|
||||
const withHead = withReceivingHead([row(1, 'hrd@tnsu.kz')], head);
|
||||
const result = withReceivingHead(withHead, { email: 'new@tnsu.kz', name: 'Новый' });
|
||||
expect(result.filter((r) => r.position === RECEIVING_HEAD_POSITION).map((r) => r.email)).toEqual(['new@tnsu.kz']);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('тот же человек — строку не трогает (статус и комментарий доработки сохраняются)', () => {
|
||||
const decided = { ...row(1, 'head@tnsu.kz', RECEIVING_HEAD_POSITION), status: 'Согласовано', comment: 'ок' };
|
||||
const rows = [decided, row(2, 'hrd@tnsu.kz')];
|
||||
expect(withReceivingHead(rows, head)).toBe(rows);
|
||||
});
|
||||
|
||||
it('не дублирует человека, который уже есть в маршруте другой ролью', () => {
|
||||
const result = withReceivingHead([row(1, 'HEAD@tnsu.kz', 'Руководитель')], head);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].position).toBe('Руководитель');
|
||||
});
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { IMatrixRow } from '../../../../shared/approval';
|
||||
|
||||
/**
|
||||
* Принимающий руководитель в маршруте согласования (вид «Перевод в другое
|
||||
* подразделение»). По ТЗ (лист «Маршрут», А шаг 2 / Б шаг 3) это отдельный шаг
|
||||
* согласия. Approval-route строит маршрут по сотруднику и найти руководителя
|
||||
* НОВОГО подразделения не может — поэтому шаг вставляет сама форма.
|
||||
*
|
||||
* Шаг узнаётся по должности-пометке: она сохраняется в строке маршрута (Title
|
||||
* списка согласования) и читается обратно, поэтому у черновика шаг тоже находится.
|
||||
*/
|
||||
export const RECEIVING_HEAD_POSITION = 'Принимающий руководитель';
|
||||
|
||||
export interface IReceivingHead {
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function isReceivingHeadRow(row: IMatrixRow): boolean {
|
||||
return row.position === RECEIVING_HEAD_POSITION;
|
||||
}
|
||||
|
||||
function sameEmail(a: string, b: string): boolean {
|
||||
return !!a && a.toLowerCase() === b.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Перенумеровать шаги подряд с 1, сохранив параллельность: строки с одинаковым
|
||||
* order (параллельный шаг) снова получают одинаковый номер.
|
||||
*/
|
||||
function renumber(rows: IMatrixRow[]): IMatrixRow[] {
|
||||
let order = 0;
|
||||
let prevOrder: number | undefined;
|
||||
return rows.map((row) => {
|
||||
if (row.order !== prevOrder) order += 1;
|
||||
prevOrder = row.order;
|
||||
return { ...row, order };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Маршрут с принимающим руководителем ПЕРВЫМ шагом (сразу после инициатора, как
|
||||
* в маршруте А). head не задан — шаг убирается (сменили вид или очистили поле).
|
||||
*
|
||||
* - Тот же человек уже стоит этим шагом — строку не трогаем: у возвращённой на
|
||||
* доработку заявки в ней может быть статус и комментарий.
|
||||
* - Человек уже есть в маршруте другой ролью (напр. он же непосредственный
|
||||
* руководитель) — второй строкой не добавляем.
|
||||
*/
|
||||
export function withReceivingHead(rows: IMatrixRow[], head: IReceivingHead | undefined): IMatrixRow[] {
|
||||
const existing = rows.find(isReceivingHeadRow);
|
||||
if (head && existing && sameEmail(existing.email, head.email)) return rows;
|
||||
|
||||
const others = rows.filter((row) => !isReceivingHeadRow(row));
|
||||
if (!head || others.some((row) => sameEmail(row.email, head.email))) return renumber(others);
|
||||
|
||||
const headRow: IMatrixRow = {
|
||||
order: 0, // перенумерует renumber
|
||||
who: head.name,
|
||||
email: head.email,
|
||||
position: RECEIVING_HEAD_POSITION,
|
||||
status: others[0]?.status || '—',
|
||||
date: '',
|
||||
comment: '',
|
||||
allowReassign: false
|
||||
};
|
||||
return renumber([headRow, ...others]);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
@import '../../../../../../styles/design-tokens';
|
||||
|
||||
// Флажки «что меняется» — в строку, на узком экране переносятся.
|
||||
.flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $sp-2 $sp-6;
|
||||
}
|
||||
|
||||
// Пояснение под датой (срок уведомления работника по ТЗ).
|
||||
.hint {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-label;
|
||||
color: $gray-500;
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, TextArea, Select, Checkbox, MoneyInput } from '../../../../shared/ui';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { OptionWithOtherField } from '../OptionWithOtherField';
|
||||
import { WORK_SCHEDULE_OPTIONS } from '../workSchedule';
|
||||
import {
|
||||
EMPLOYER_CHANGE_REASONS,
|
||||
EMPLOYER_REASON_OTHER,
|
||||
EMPLOYER_CHANGE_NOTICE_HINT,
|
||||
EMPLOYER_JUSTIFICATION_ATTACHMENT,
|
||||
minEmployerChangeDate
|
||||
} from './options';
|
||||
import { splitSalary, sanitizeFixedPercent } from './salaryParts';
|
||||
import styles from './EmployerInitiativeSection.module.scss';
|
||||
|
||||
/**
|
||||
* Поля вида «Изменение условий труда по инициативе работодателя» — по шаблону ТЗ:
|
||||
* «в связи с ___ (причина) прошу: а) изменить график работы с ___ на ___ с …;
|
||||
* б) изменить должностной оклад: постоянная часть 75 % — ___, переменная 25 % — ___, с …».
|
||||
* Что меняется — флажки а/б (можно оба); оклад вводится один, доля постоянной части
|
||||
* задаётся (по умолчанию 75 %), переменная — остаток до 100 %, суммы считаются сами. Даты — не раньше чем через 15 дней (уведомление работника по ТЗ).
|
||||
* Обоснование — текстом или документом (одно из двух обязательно при отправке).
|
||||
*
|
||||
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||
*/
|
||||
export const EmployerInitiativeSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
const minDate = minEmployerChangeDate();
|
||||
const parts = splitSalary(data.EmployerChangeNewSalary, data.EmployerChangeFixedPercent);
|
||||
|
||||
return (
|
||||
<FormGrid>
|
||||
<FormField label="Причина" htmlFor="EmployerChangeReason" full>
|
||||
<Select
|
||||
id="EmployerChangeReason"
|
||||
value={data.EmployerChangeReason}
|
||||
onChange={(v) => {
|
||||
set('EmployerChangeReason', v);
|
||||
if (v !== EMPLOYER_REASON_OTHER) set('EmployerChangeReasonOther', '');
|
||||
}}
|
||||
options={EMPLOYER_CHANGE_REASONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
{data.EmployerChangeReason === EMPLOYER_REASON_OTHER && (
|
||||
<FormField label="Причина (другое)" htmlFor="EmployerChangeReasonOther" full>
|
||||
<TextInput
|
||||
id="EmployerChangeReasonOther"
|
||||
value={data.EmployerChangeReasonOther}
|
||||
onChange={(v) => set('EmployerChangeReasonOther', v)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label="Что меняется" htmlFor="EmployerChangeSchedule" full>
|
||||
<div className={styles.flags}>
|
||||
<Checkbox
|
||||
checked={data.EmployerChangeSchedule}
|
||||
onChange={(checked) => set('EmployerChangeSchedule', checked)}
|
||||
label="График работы"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={data.EmployerChangeSalary}
|
||||
onChange={(checked) => set('EmployerChangeSalary', checked)}
|
||||
label="Должностной оклад"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{data.EmployerChangeSchedule && (
|
||||
<>
|
||||
<OptionWithOtherField
|
||||
label="Текущий график работы"
|
||||
id="EmployerChangeCurrentSchedule"
|
||||
value={data.EmployerChangeCurrentSchedule}
|
||||
otherValue={data.EmployerChangeCurrentScheduleOther}
|
||||
options={WORK_SCHEDULE_OPTIONS}
|
||||
onChange={(v) => set('EmployerChangeCurrentSchedule', v)}
|
||||
onOtherChange={(v) => set('EmployerChangeCurrentScheduleOther', v)}
|
||||
/>
|
||||
<OptionWithOtherField
|
||||
label="Новый график работы"
|
||||
id="EmployerChangeNewSchedule"
|
||||
value={data.EmployerChangeNewSchedule}
|
||||
otherValue={data.EmployerChangeNewScheduleOther}
|
||||
options={WORK_SCHEDULE_OPTIONS}
|
||||
onChange={(v) => set('EmployerChangeNewSchedule', v)}
|
||||
onOtherChange={(v) => set('EmployerChangeNewScheduleOther', v)}
|
||||
/>
|
||||
<FormField label="Дата изменения графика" htmlFor="EmployerChangeScheduleDate" full>
|
||||
<TextInput
|
||||
id="EmployerChangeScheduleDate"
|
||||
type="date"
|
||||
value={data.EmployerChangeScheduleDate}
|
||||
onChange={(v) => set('EmployerChangeScheduleDate', v)}
|
||||
min={minDate}
|
||||
/>
|
||||
{!readOnly && <div className={styles.hint}>{EMPLOYER_CHANGE_NOTICE_HINT}</div>}
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.EmployerChangeSalary && (
|
||||
<>
|
||||
<FormField label="Новый должностной оклад, ₸" htmlFor="EmployerChangeNewSalary" full>
|
||||
<MoneyInput
|
||||
id="EmployerChangeNewSalary"
|
||||
value={data.EmployerChangeNewSalary}
|
||||
onChange={(v) => set('EmployerChangeNewSalary', v)}
|
||||
/>
|
||||
</FormField>
|
||||
{/* Задаётся только доля постоянной части; переменная — остаток до 100 %,
|
||||
суммы считаются из оклада (см. salaryParts.ts) — превысить 100 % нельзя. */}
|
||||
<FormField label="Постоянная (повременная) часть, %" htmlFor="EmployerChangeFixedPercent">
|
||||
<TextInput
|
||||
id="EmployerChangeFixedPercent"
|
||||
value={data.EmployerChangeFixedPercent}
|
||||
onChange={(v) => set('EmployerChangeFixedPercent', sanitizeFixedPercent(v))}
|
||||
placeholder="например, 75"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Переменная часть, %" htmlFor="EmployerChangeVariablePercent">
|
||||
<TextInput
|
||||
id="EmployerChangeVariablePercent"
|
||||
value={parts ? String(parts.variablePercent) : ''}
|
||||
onChange={() => undefined}
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Постоянная часть, ₸" htmlFor="EmployerChangeFixedPart">
|
||||
<MoneyInput
|
||||
id="EmployerChangeFixedPart"
|
||||
value={parts ? String(parts.fixed) : ''}
|
||||
onChange={() => undefined}
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Переменная часть, ₸" htmlFor="EmployerChangeVariablePart">
|
||||
<MoneyInput
|
||||
id="EmployerChangeVariablePart"
|
||||
value={parts ? String(parts.variable) : ''}
|
||||
onChange={() => undefined}
|
||||
readOnly
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата изменения оклада" htmlFor="EmployerChangeSalaryDate" full>
|
||||
<TextInput
|
||||
id="EmployerChangeSalaryDate"
|
||||
type="date"
|
||||
value={data.EmployerChangeSalaryDate}
|
||||
onChange={(v) => set('EmployerChangeSalaryDate', v)}
|
||||
min={minDate}
|
||||
/>
|
||||
{!readOnly && <div className={styles.hint}>{EMPLOYER_CHANGE_NOTICE_HINT}</div>}
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField label="Обоснование" htmlFor="EmployerChangeJustification" full>
|
||||
<TextArea
|
||||
id="EmployerChangeJustification"
|
||||
value={data.EmployerChangeJustification}
|
||||
onChange={(v) => set('EmployerChangeJustification', v)}
|
||||
/>
|
||||
</FormField>
|
||||
{attachments && (
|
||||
<AttachmentSlotField
|
||||
label={`${EMPLOYER_JUSTIFICATION_ATTACHMENT} (приказ о реорганизации, сокращении объёма и т. п.)`}
|
||||
editor={attachments}
|
||||
editable={!readOnly}
|
||||
groupKey={EMPLOYER_JUSTIFICATION_ATTACHMENT}
|
||||
category={EMPLOYER_JUSTIFICATION_ATTACHMENT}
|
||||
/>
|
||||
)}
|
||||
</FormGrid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
import { isoDatePlusDays } from '../../../../shared/dateUtils';
|
||||
|
||||
/**
|
||||
* Вид «Изменение условий труда по инициативе работодателя». Причины — из названия
|
||||
* вида в ТЗ (экономические, технологические, изменения в организации, сокращение
|
||||
* объёма работ) + «Другое» со своим текстом (решение Тимура 2026-10-29).
|
||||
* Значения = choices колонки EmployerChangeReason.
|
||||
*/
|
||||
export const EMPLOYER_REASON_OTHER = 'Другое';
|
||||
export const EMPLOYER_CHANGE_REASONS: ISelectOption[] = [
|
||||
'Экономические причины',
|
||||
'Технологические причины',
|
||||
'Изменения в организации',
|
||||
'Сокращение объёма работ',
|
||||
EMPLOYER_REASON_OTHER
|
||||
].map((reason) => ({ value: reason, label: reason }));
|
||||
|
||||
/**
|
||||
* По ТЗ работника уведомляют не позднее чем за столько календарных дней до даты
|
||||
* изменений — поэтому раньше дату не даём выбрать (как срок подачи в отпуске).
|
||||
*/
|
||||
export const EMPLOYER_CHANGE_NOTICE_DAYS = 15;
|
||||
export const EMPLOYER_CHANGE_NOTICE_HINT = `Не раньше чем через ${EMPLOYER_CHANGE_NOTICE_DAYS} календарных дней: работника уведомляют за ${EMPLOYER_CHANGE_NOTICE_DAYS} дней до изменений.`;
|
||||
|
||||
/** Самая ранняя допустимая дата изменений, YYYY-MM-DD. */
|
||||
export function minEmployerChangeDate(): string {
|
||||
return isoDatePlusDays(EMPLOYER_CHANGE_NOTICE_DAYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Вложение-обоснование (приказ о реорганизации, сокращении объёма и т.п.). По ТЗ
|
||||
* «ТЕКСТ ИЛИ ПРИЛОЖИТЬ ДОКУМЕНТ» — сам по себе необязателен, но при отправке
|
||||
* нужен текст обоснования или этот файл (см. submitChecks).
|
||||
*/
|
||||
export const EMPLOYER_JUSTIFICATION_ATTACHMENT = 'Документ-обоснование';
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { splitSalary, sanitizeFixedPercent } from './salaryParts';
|
||||
|
||||
describe('splitSalary', () => {
|
||||
it('по умолчанию из ТЗ — 75 / 25', () => {
|
||||
expect(splitSalary('400000', '75')).toEqual({ fixedPercent: 75, variablePercent: 25, fixed: 300000, variable: 100000 });
|
||||
});
|
||||
|
||||
it('своя доля: переменная часть — остаток до 100 %', () => {
|
||||
expect(splitSalary('400000', '70')).toEqual({ fixedPercent: 70, variablePercent: 30, fixed: 280000, variable: 120000 });
|
||||
});
|
||||
|
||||
it('при округлении сумма частей всё равно равна окладу', () => {
|
||||
const parts = splitSalary('333333', '75');
|
||||
expect(parts?.fixed).toBe(250000);
|
||||
expect((parts?.fixed ?? 0) + (parts?.variable ?? 0)).toBe(333333);
|
||||
});
|
||||
|
||||
it('дробная доля', () => {
|
||||
expect(splitSalary('200000', '62.5')).toEqual({
|
||||
fixedPercent: 62.5,
|
||||
variablePercent: 37.5,
|
||||
fixed: 125000,
|
||||
variable: 75000
|
||||
});
|
||||
});
|
||||
|
||||
it('нет оклада или доли — частей нет', () => {
|
||||
expect(splitSalary('', '75')).toBeUndefined();
|
||||
expect(splitSalary('0', '75')).toBeUndefined();
|
||||
expect(splitSalary('400000', '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFixedPercent', () => {
|
||||
it('больше 100 не даёт', () => {
|
||||
expect(sanitizeFixedPercent('150')).toBe('100');
|
||||
});
|
||||
|
||||
it('пропускает обычные и дробные доли', () => {
|
||||
expect(sanitizeFixedPercent('75')).toBe('75');
|
||||
expect(sanitizeFixedPercent('62,5')).toBe('62.5');
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { sanitizePercentInput } from '../Allowance/amount';
|
||||
|
||||
/**
|
||||
* Новый оклад делится на постоянную (повременную) и переменную части. В шаблоне ТЗ
|
||||
* — 75 % / 25 %; по решению Тимура (2026-10-29) долю постоянной части можно задать
|
||||
* самому, а переменная — всегда остаток до 100 %. Так сумма частей ровно 100 % и
|
||||
* превысить её нельзя в принципе.
|
||||
*/
|
||||
export const DEFAULT_FIXED_PERCENT = '75';
|
||||
const MAX_PERCENT = 100;
|
||||
|
||||
export interface ISalaryParts {
|
||||
/** Доли в процентах; variablePercent = 100 − fixedPercent. */
|
||||
fixedPercent: number;
|
||||
variablePercent: number;
|
||||
/** Суммы в тенге; variable — остаток, чтобы fixed + variable = оклад. */
|
||||
fixed: number;
|
||||
variable: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ввод доли постоянной части → «сырое» число строкой: только цифры и один
|
||||
* разделитель (как ввод процента надбавки), не больше 100.
|
||||
*/
|
||||
export function sanitizeFixedPercent(text: string): string {
|
||||
const percent = sanitizePercentInput(text);
|
||||
return Number(percent) > MAX_PERCENT ? String(MAX_PERCENT) : percent;
|
||||
}
|
||||
|
||||
/** Доля постоянной части числом; пусто/некорректно/вне 0–100 — undefined. */
|
||||
function parsePercent(raw: string): number | undefined {
|
||||
const percent = Number(raw);
|
||||
if (raw.trim() === '' || !Number.isFinite(percent) || percent < 0 || percent > MAX_PERCENT) return undefined;
|
||||
return percent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Оклад и доля постоянной части → проценты и суммы обеих частей. Постоянная сумма
|
||||
* округляется до тенге, переменная — остаток (без расхождений от округления).
|
||||
* Нет оклада или доли — undefined.
|
||||
*/
|
||||
export function splitSalary(salaryRaw: string, fixedPercentRaw: string): ISalaryParts | undefined {
|
||||
const salary = Number(salaryRaw);
|
||||
const fixedPercent = parsePercent(fixedPercentRaw);
|
||||
if (salaryRaw.trim() === '' || !Number.isFinite(salary) || salary <= 0 || fixedPercent === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const fixed = Math.round((salary * fixedPercent) / MAX_PERCENT);
|
||||
return { fixedPercent, variablePercent: MAX_PERCENT - fixedPercent, fixed, variable: salary - fixed };
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, TextArea } from '../../../../shared/ui';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { NEW_JOB_DESCRIPTION_ATTACHMENT } from './options';
|
||||
|
||||
/**
|
||||
* Поля вида «Изменение трудовой функции (без смены должности)». Ровно по шаблону
|
||||
* ТЗ: «прошу внести изменения в трудовую функцию работника согласно ДИ во
|
||||
* вложении с __.__.20__ в связи с ___ (текст)» — дата, «в связи с» (свободный
|
||||
* текст, списка причин в ТЗ нет) и обязательная новая ДИ. Инициатор — только
|
||||
* руководитель.
|
||||
*
|
||||
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||
*/
|
||||
export const FunctionChangeSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
return (
|
||||
<FormGrid>
|
||||
<FormField label="Дата изменения" htmlFor="FunctionChangeDate">
|
||||
<TextInput
|
||||
id="FunctionChangeDate"
|
||||
type="date"
|
||||
value={data.FunctionChangeDate}
|
||||
onChange={(v) => set('FunctionChangeDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="В связи с" htmlFor="FunctionChangeReason" full>
|
||||
<TextArea
|
||||
id="FunctionChangeReason"
|
||||
value={data.FunctionChangeReason}
|
||||
onChange={(v) => set('FunctionChangeReason', v)}
|
||||
/>
|
||||
</FormField>
|
||||
{attachments && (
|
||||
<AttachmentSlotField
|
||||
label={NEW_JOB_DESCRIPTION_ATTACHMENT}
|
||||
editor={attachments}
|
||||
editable={!readOnly}
|
||||
groupKey={NEW_JOB_DESCRIPTION_ATTACHMENT}
|
||||
category={NEW_JOB_DESCRIPTION_ATTACHMENT}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</FormGrid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Вложение «Новая должностная инструкция» — по ТЗ «приложить документ», обязательно.
|
||||
* Отдельная подпись от «Должностной инструкции» перевода на должность: это разные слоты.
|
||||
*/
|
||||
export const NEW_JOB_DESCRIPTION_ATTACHMENT = 'Новая должностная инструкция';
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { FormField, Select, TextArea, ISelectOption } from '../../../shared/ui';
|
||||
import { InitiatorRole, INITIATOR_EMPLOYEE } from '../changeTypes';
|
||||
import { EMPLOYEE_STATEMENT_LABEL } from '../model';
|
||||
|
||||
interface Props {
|
||||
initiator: InitiatorRole | '';
|
||||
/** Причина из списка — только у работодателя. */
|
||||
reason: { id: string; value: string; options: ISelectOption[]; onChange: (value: string) => void };
|
||||
/** Обоснование работодателя / текст заявления работника — одна и та же колонка вида. */
|
||||
justification: { id: string; value: string; onChange: (value: string) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Окончание секции вида, где по ТЗ шаблон зависит от инициатора:
|
||||
* - работник — «заявление (текст)»: только «Текст заявления», без списка причин;
|
||||
* - работодатель (и пока инициатор не выбран) — «в связи с: а) б) в)»: причина + обоснование.
|
||||
* Поля кладутся прямо в сетку FormGrid секции (без обёртки) — как и остальные поля вида.
|
||||
*/
|
||||
export const InitiatorReasonFields: React.FC<Props> = ({ initiator, reason, justification }) => {
|
||||
const isEmployee = initiator === INITIATOR_EMPLOYEE;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isEmployee && (
|
||||
<FormField label="Причина" htmlFor={reason.id} full>
|
||||
<Select
|
||||
id={reason.id}
|
||||
value={reason.value}
|
||||
onChange={reason.onChange}
|
||||
options={reason.options}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label={isEmployee ? EMPLOYEE_STATEMENT_LABEL : 'Обоснование'} htmlFor={justification.id} full>
|
||||
<TextArea id={justification.id} value={justification.value} onChange={justification.onChange} />
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { FormField, Select, TextInput, ISelectOption } from '../../../shared/ui';
|
||||
import { OPTION_OTHER } from './workSchedule';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
id: string;
|
||||
value: string;
|
||||
/** Текст при «Другое». */
|
||||
otherValue: string;
|
||||
options: ISelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
onOtherChange: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор из списка + ручной ввод при «Другое» (как график/время работы в подборе
|
||||
* и приёме). Выбрали не «Другое» — ручной текст очищается, чтобы не сохранить
|
||||
* скрытое значение. Поля кладутся прямо в сетку FormGrid секции.
|
||||
*/
|
||||
export const OptionWithOtherField: React.FC<Props> = ({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
otherValue,
|
||||
options,
|
||||
onChange,
|
||||
onOtherChange
|
||||
}) => (
|
||||
<>
|
||||
<FormField label={label} htmlFor={id}>
|
||||
<Select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
onChange(v);
|
||||
if (v !== OPTION_OTHER) onOtherChange('');
|
||||
}}
|
||||
options={options}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
{value === OPTION_OTHER && (
|
||||
<FormField label={`${label} (другое)`} htmlFor={`${id}Other`}>
|
||||
<TextInput id={`${id}Other`} value={otherValue} onChange={onOtherChange} />
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput } from '../../../../shared/ui';
|
||||
import { InitiatorReasonFields } from '../InitiatorReasonFields';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { PositionPicker } from '../../../../shared/dictionary';
|
||||
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { POSITION_TRANSFER_REASONS, JOB_DESCRIPTION_ATTACHMENT } from './options';
|
||||
|
||||
/**
|
||||
* Поля вида «Перевод на другую должность (постоянный)». Состав согласован с
|
||||
* Тимуром по ТЗ кадров: новая должность (справочник с ручным вводом), дата,
|
||||
* причина (а–г), обоснование и обязательная должностная инструкция. Текущая
|
||||
* должность уже видна в «Организации» — отдельного «было» нет.
|
||||
*
|
||||
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||
*/
|
||||
export const PositionTransferSection: React.FC<ISectionProps> = ({ data, set, context, attachments }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
|
||||
return (
|
||||
<FormGrid>
|
||||
<FormField label="Новая должность" htmlFor="PositionTransferNewPosition">
|
||||
<PositionPicker
|
||||
context={context}
|
||||
id="PositionTransferNewPosition"
|
||||
value={data.PositionTransferNewPosition}
|
||||
onChange={(v) => set('PositionTransferNewPosition', v)}
|
||||
placeholder="Начните вводить название должности…"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата перевода" htmlFor="PositionTransferDate">
|
||||
<TextInput
|
||||
id="PositionTransferDate"
|
||||
type="date"
|
||||
value={data.PositionTransferDate}
|
||||
onChange={(v) => set('PositionTransferDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<InitiatorReasonFields
|
||||
initiator={data.ChangeInitiator}
|
||||
reason={{
|
||||
id: 'PositionTransferReason',
|
||||
value: data.PositionTransferReason,
|
||||
options: POSITION_TRANSFER_REASONS,
|
||||
onChange: (v) => set('PositionTransferReason', v)
|
||||
}}
|
||||
justification={{
|
||||
id: 'PositionTransferJustification',
|
||||
value: data.PositionTransferJustification,
|
||||
onChange: (v) => set('PositionTransferJustification', v)
|
||||
}}
|
||||
/>
|
||||
{attachments && (
|
||||
<AttachmentSlotField
|
||||
label={JOB_DESCRIPTION_ATTACHMENT}
|
||||
editor={attachments}
|
||||
editable={!readOnly}
|
||||
groupKey={JOB_DESCRIPTION_ATTACHMENT}
|
||||
category={JOB_DESCRIPTION_ATTACHMENT}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</FormGrid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
|
||||
/**
|
||||
* Причины перевода на другую должность — по шаблону текста ТЗ кадров (а–г).
|
||||
* Работника и работодателя не различаем (решение 2026-10-28): причины видны
|
||||
* всем, работник при необходимости выбирает «Иное» и пишет текст в обоснование.
|
||||
* Значения совпадают с choices колонки PositionTransferReason (provisioning.json).
|
||||
*/
|
||||
export const POSITION_TRANSFER_REASONS: ISelectOption[] = [
|
||||
'Карьерный рост (успешное выполнение обязанностей, высокие результаты)',
|
||||
'Вакантная должность, соответствующая квалификации работника',
|
||||
'Открытие нового объекта / проекта, перераспределение функций',
|
||||
'Иное'
|
||||
].map((reason) => ({ value: reason, label: reason }));
|
||||
|
||||
/** Вложение «Должностная инструкция» — по ТЗ ПРИКЛАДЫВАЕТСЯ всегда, обязательно. */
|
||||
export const JOB_DESCRIPTION_ATTACHMENT = 'Должностная инструкция';
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, MoneyInput, TextInput, TextArea, Select } from '../../../../shared/ui';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { SALARY_INCREASE_REASONS } from './reasons';
|
||||
|
||||
/**
|
||||
* Поля вида «Повышение должностного оклада». Состав согласован с Тимуром по ТЗ
|
||||
* кадров: было/стало, дата, причина (а/б/в) и обоснование. Все необязательные,
|
||||
* без ограничений — по решению от 2026-10-28.
|
||||
*
|
||||
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||
*/
|
||||
export const SalaryIncreaseSection: React.FC<ISectionProps> = ({ data, set }) => (
|
||||
<FormGrid>
|
||||
<FormField label="Текущий оклад, ₸" htmlFor="SalaryIncreaseCurrent">
|
||||
<MoneyInput
|
||||
id="SalaryIncreaseCurrent"
|
||||
value={data.SalaryIncreaseCurrent}
|
||||
onChange={(v) => set('SalaryIncreaseCurrent', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Новый оклад, ₸" htmlFor="SalaryIncreaseNew">
|
||||
<MoneyInput
|
||||
id="SalaryIncreaseNew"
|
||||
value={data.SalaryIncreaseNew}
|
||||
onChange={(v) => set('SalaryIncreaseNew', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата изменения" htmlFor="SalaryIncreaseDate">
|
||||
<TextInput
|
||||
id="SalaryIncreaseDate"
|
||||
type="date"
|
||||
value={data.SalaryIncreaseDate}
|
||||
onChange={(v) => set('SalaryIncreaseDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Причина" htmlFor="SalaryIncreaseReason">
|
||||
<Select
|
||||
id="SalaryIncreaseReason"
|
||||
value={data.SalaryIncreaseReason}
|
||||
onChange={(v) => set('SalaryIncreaseReason', v)}
|
||||
options={SALARY_INCREASE_REASONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Обоснование" htmlFor="SalaryIncreaseJustification" full>
|
||||
<TextArea
|
||||
id="SalaryIncreaseJustification"
|
||||
value={data.SalaryIncreaseJustification}
|
||||
onChange={(v) => set('SalaryIncreaseJustification', v)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
|
||||
/**
|
||||
* Причины повышения оклада — по шаблону текста ТЗ кадров (а/б/в). Значения
|
||||
* совпадают с choices колонки SalaryIncreaseReason (provisioning.json).
|
||||
* «Иное» отдельного текста не имеет — его раскрывает поле «Обоснование».
|
||||
*/
|
||||
export const SALARY_REASON_PROBATION = 'Успешное прохождение испытательного срока';
|
||||
export const SALARY_REASON_RESPONSIBILITY = 'Расширение функционала / зоны ответственности';
|
||||
export const SALARY_REASON_OTHER = 'Иное';
|
||||
|
||||
export const SALARY_INCREASE_REASONS: ISelectOption[] = [
|
||||
{ value: SALARY_REASON_PROBATION, label: SALARY_REASON_PROBATION },
|
||||
{ value: SALARY_REASON_RESPONSIBILITY, label: SALARY_REASON_RESPONSIBILITY },
|
||||
{ value: SALARY_REASON_OTHER, label: SALARY_REASON_OTHER }
|
||||
];
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, Select } from '../../../../shared/ui';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import { AttachmentSlotField } from '../../../../shared/attachments/AttachmentSlotField';
|
||||
import { INITIATOR_EMPLOYEE } from '../../changeTypes';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { InitiatorReasonFields } from '../InitiatorReasonFields';
|
||||
import { OptionWithOtherField } from '../OptionWithOtherField';
|
||||
import { WORK_SCHEDULE_OPTIONS, WORK_HOURS_OPTIONS } from '../workSchedule';
|
||||
import {
|
||||
MODE_OTHER,
|
||||
NEW_SCHEDULE_ATTACHMENT,
|
||||
SCHEDULE_CHANGE_NEW_MODES,
|
||||
SCHEDULE_CHANGE_REASONS
|
||||
} from './options';
|
||||
|
||||
/**
|
||||
* Поля вида «Изменение режима / графика работы» — по ролям, строго по шаблонам ТЗ
|
||||
* (см. options.ts). Общее: текущий режим (график + время работы), дата начала,
|
||||
* необязательный документ «Новый график». Работник — неполное рабочее время
|
||||
* (часов в день / дней в неделю) и текст заявления; работодатель — новый режим,
|
||||
* новые график и время работы, причина и обоснование. Все поля необязательные.
|
||||
*
|
||||
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||
*/
|
||||
export const ScheduleChangeSection: React.FC<ISectionProps> = ({ data, set, attachments }) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
const isEmployee = data.ChangeInitiator === INITIATOR_EMPLOYEE;
|
||||
|
||||
return (
|
||||
<FormGrid>
|
||||
<OptionWithOtherField
|
||||
label="Текущий график работы"
|
||||
id="ScheduleChangeCurrentSchedule"
|
||||
value={data.ScheduleChangeCurrentSchedule}
|
||||
otherValue={data.ScheduleChangeCurrentScheduleOther}
|
||||
options={WORK_SCHEDULE_OPTIONS}
|
||||
onChange={(v) => set('ScheduleChangeCurrentSchedule', v)}
|
||||
onOtherChange={(v) => set('ScheduleChangeCurrentScheduleOther', v)}
|
||||
/>
|
||||
<OptionWithOtherField
|
||||
label="Текущее время работы"
|
||||
id="ScheduleChangeCurrentHours"
|
||||
value={data.ScheduleChangeCurrentHours}
|
||||
otherValue={data.ScheduleChangeCurrentHoursOther}
|
||||
options={WORK_HOURS_OPTIONS}
|
||||
onChange={(v) => set('ScheduleChangeCurrentHours', v)}
|
||||
onOtherChange={(v) => set('ScheduleChangeCurrentHoursOther', v)}
|
||||
/>
|
||||
<FormField label="Дата начала" htmlFor="ScheduleChangeDate" full>
|
||||
<TextInput
|
||||
id="ScheduleChangeDate"
|
||||
type="date"
|
||||
value={data.ScheduleChangeDate}
|
||||
onChange={(v) => set('ScheduleChangeDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{isEmployee && (
|
||||
<>
|
||||
{/* Термины ТК: неполный рабочий день / неполная рабочая неделя — без них
|
||||
«часов в день» читалось непонятно к чему. Заполнить можно одно или оба. */}
|
||||
<FormField label="Неполный рабочий день, часов" htmlFor="ScheduleChangeHoursPerDay">
|
||||
<TextInput
|
||||
id="ScheduleChangeHoursPerDay"
|
||||
type="number"
|
||||
value={data.ScheduleChangeHoursPerDay}
|
||||
onChange={(v) => set('ScheduleChangeHoursPerDay', v)}
|
||||
placeholder="например, 6"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Неполная рабочая неделя, дней" htmlFor="ScheduleChangeDaysPerWeek">
|
||||
<TextInput
|
||||
id="ScheduleChangeDaysPerWeek"
|
||||
type="number"
|
||||
value={data.ScheduleChangeDaysPerWeek}
|
||||
onChange={(v) => set('ScheduleChangeDaysPerWeek', v)}
|
||||
placeholder="например, 4"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isEmployee && (
|
||||
<>
|
||||
<FormField label="Новый режим" htmlFor="ScheduleChangeNewMode" full>
|
||||
<Select
|
||||
id="ScheduleChangeNewMode"
|
||||
value={data.ScheduleChangeNewMode}
|
||||
onChange={(v) => {
|
||||
set('ScheduleChangeNewMode', v);
|
||||
if (v !== MODE_OTHER) set('ScheduleChangeNewModeOther', '');
|
||||
}}
|
||||
options={SCHEDULE_CHANGE_NEW_MODES}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
{data.ScheduleChangeNewMode === MODE_OTHER && (
|
||||
<FormField label="Иной режим (описание)" htmlFor="ScheduleChangeNewModeOther" full>
|
||||
<TextInput
|
||||
id="ScheduleChangeNewModeOther"
|
||||
value={data.ScheduleChangeNewModeOther}
|
||||
onChange={(v) => set('ScheduleChangeNewModeOther', v)}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<OptionWithOtherField
|
||||
label="Новый график работы"
|
||||
id="ScheduleChangeNewSchedule"
|
||||
value={data.ScheduleChangeNewSchedule}
|
||||
otherValue={data.ScheduleChangeNewScheduleOther}
|
||||
options={WORK_SCHEDULE_OPTIONS}
|
||||
onChange={(v) => set('ScheduleChangeNewSchedule', v)}
|
||||
onOtherChange={(v) => set('ScheduleChangeNewScheduleOther', v)}
|
||||
/>
|
||||
<OptionWithOtherField
|
||||
label="Новое время работы"
|
||||
id="ScheduleChangeNewHours"
|
||||
value={data.ScheduleChangeNewHours}
|
||||
otherValue={data.ScheduleChangeNewHoursOther}
|
||||
options={WORK_HOURS_OPTIONS}
|
||||
onChange={(v) => set('ScheduleChangeNewHours', v)}
|
||||
onOtherChange={(v) => set('ScheduleChangeNewHoursOther', v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<InitiatorReasonFields
|
||||
initiator={data.ChangeInitiator}
|
||||
reason={{
|
||||
id: 'ScheduleChangeReason',
|
||||
value: data.ScheduleChangeReason,
|
||||
options: SCHEDULE_CHANGE_REASONS,
|
||||
onChange: (v) => set('ScheduleChangeReason', v)
|
||||
}}
|
||||
justification={{
|
||||
id: 'ScheduleChangeJustification',
|
||||
value: data.ScheduleChangeJustification,
|
||||
onChange: (v) => set('ScheduleChangeJustification', v)
|
||||
}}
|
||||
/>
|
||||
{attachments && (
|
||||
<AttachmentSlotField
|
||||
label={NEW_SCHEDULE_ATTACHMENT}
|
||||
editor={attachments}
|
||||
editable={!readOnly}
|
||||
groupKey={NEW_SCHEDULE_ATTACHMENT}
|
||||
category={NEW_SCHEDULE_ATTACHMENT}
|
||||
/>
|
||||
)}
|
||||
</FormGrid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
|
||||
/**
|
||||
* Вид «Изменение режима / графика работы». По ТЗ шаблон зависит от инициатора:
|
||||
* - работник: «прошу установить неполное рабочее время (___ ч в день / ___ дней
|
||||
* в неделю) с __.__.20__ (текст)»;
|
||||
* - работодатель: «прошу перевести работника на а) вахтовый метод; б) сменный
|
||||
* график; в) иной режим (текст) с __.__.20__ в связи со спецификой объекта /
|
||||
* смещением сроков проекта».
|
||||
* Значения = choices колонок ScheduleChangeNewMode / ScheduleChangeReason.
|
||||
*/
|
||||
export const MODE_OTHER = 'Иной режим';
|
||||
export const SCHEDULE_CHANGE_NEW_MODES: ISelectOption[] = ['Вахтовый метод', 'Сменный график', MODE_OTHER].map(
|
||||
(mode) => ({ value: mode, label: mode })
|
||||
);
|
||||
|
||||
/** Причины работодателя — ровно те, что в шаблоне ТЗ («Иное» в ТЗ нет — не добавляем). */
|
||||
export const SCHEDULE_CHANGE_REASONS: ISelectOption[] = ['Специфика объекта', 'Смещение сроков проекта'].map(
|
||||
(reason) => ({ value: reason, label: reason })
|
||||
);
|
||||
|
||||
/** Вложение «Новый график» — по ТЗ «можно приложить документ», необязательно. */
|
||||
export const NEW_SCHEDULE_ATTACHMENT = 'Новый график';
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
@import '../../../../../../styles/design-tokens';
|
||||
|
||||
// Пояснение под полем (ограничение периода).
|
||||
.hint {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-label;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
// Гарантия по оплате из шаблона ТЗ — на всю ширину сетки, приглушённо, как пометка.
|
||||
.payNote {
|
||||
grid-column: 1 / -1;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, TextArea, Select } from '../../../../shared/ui';
|
||||
import { ProjectPicker } from '../../../../shared/dictionary';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { TEMPORARY_TRANSFER_REASONS, TEMPORARY_TRANSFER_PAY_NOTE, TEMPORARY_TRANSFER_PERIOD_HINT } from './options';
|
||||
import { maxTransferEndDate } from './period';
|
||||
import styles from './TemporaryTransferSection.module.scss';
|
||||
|
||||
const PROJECT_PLACEHOLDER = 'Начните вводить название или код проекта…';
|
||||
|
||||
/**
|
||||
* Поля вида «Временный перевод при производственной необходимости / простое» — по
|
||||
* шаблону ТЗ: «прошу временно перевести работника с ___ (объект / работа) на ___
|
||||
* с … по … в связи с ___ (производственная необходимость / простой). Оплата — не
|
||||
* ниже средней заработной платы по прежней работе». Откуда/куда — проект из
|
||||
* справочника + работа текстом; период не длиннее 3 месяцев (period.ts).
|
||||
* Инициатор — только руководитель.
|
||||
*
|
||||
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||
*/
|
||||
export const TemporaryTransferSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||
<FormGrid>
|
||||
<FormField label="Откуда: проект / объект" htmlFor="TemporaryTransferFromProject">
|
||||
<ProjectPicker
|
||||
context={context}
|
||||
id="TemporaryTransferFromProject"
|
||||
value={data.TemporaryTransferFromProject}
|
||||
onChange={(name, id) => {
|
||||
set('TemporaryTransferFromProject', name);
|
||||
set('TemporaryTransferFromProjectId', id);
|
||||
}}
|
||||
placeholder={PROJECT_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Откуда: работа" htmlFor="TemporaryTransferFromWork">
|
||||
<TextInput
|
||||
id="TemporaryTransferFromWork"
|
||||
value={data.TemporaryTransferFromWork}
|
||||
onChange={(v) => set('TemporaryTransferFromWork', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Куда: проект / объект" htmlFor="TemporaryTransferToProject">
|
||||
<ProjectPicker
|
||||
context={context}
|
||||
id="TemporaryTransferToProject"
|
||||
value={data.TemporaryTransferToProject}
|
||||
onChange={(name, id) => {
|
||||
set('TemporaryTransferToProject', name);
|
||||
set('TemporaryTransferToProjectId', id);
|
||||
}}
|
||||
placeholder={PROJECT_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Куда: работа" htmlFor="TemporaryTransferToWork">
|
||||
<TextInput
|
||||
id="TemporaryTransferToWork"
|
||||
value={data.TemporaryTransferToWork}
|
||||
onChange={(v) => set('TemporaryTransferToWork', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата начала" htmlFor="TemporaryTransferStartDate">
|
||||
<TextInput
|
||||
id="TemporaryTransferStartDate"
|
||||
type="date"
|
||||
value={data.TemporaryTransferStartDate}
|
||||
onChange={(v) => set('TemporaryTransferStartDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата окончания" htmlFor="TemporaryTransferEndDate">
|
||||
{/* Не раньше начала и не дольше 3 месяцев — календарь не даст выбрать лишнее. */}
|
||||
<TextInput
|
||||
id="TemporaryTransferEndDate"
|
||||
type="date"
|
||||
value={data.TemporaryTransferEndDate}
|
||||
onChange={(v) => set('TemporaryTransferEndDate', v)}
|
||||
min={data.TemporaryTransferStartDate || undefined}
|
||||
max={maxTransferEndDate(data.TemporaryTransferStartDate)}
|
||||
/>
|
||||
<div className={styles.hint}>{TEMPORARY_TRANSFER_PERIOD_HINT}</div>
|
||||
</FormField>
|
||||
<FormField label="Основание" htmlFor="TemporaryTransferReason" full>
|
||||
<Select
|
||||
id="TemporaryTransferReason"
|
||||
value={data.TemporaryTransferReason}
|
||||
onChange={(v) => set('TemporaryTransferReason', v)}
|
||||
options={TEMPORARY_TRANSFER_REASONS}
|
||||
placeholder="— выберите —"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Обоснование" htmlFor="TemporaryTransferJustification" full>
|
||||
<TextArea
|
||||
id="TemporaryTransferJustification"
|
||||
value={data.TemporaryTransferJustification}
|
||||
onChange={(v) => set('TemporaryTransferJustification', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className={styles.payNote}>{TEMPORARY_TRANSFER_PAY_NOTE}</div>
|
||||
</FormGrid>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
import { MAX_TRANSFER_MONTHS } from './period';
|
||||
|
||||
/**
|
||||
* Основания временного перевода — из шаблона ТЗ («в связи с ___ (производственная
|
||||
* необходимость / простой)»). Значения = choices колонки TemporaryTransferReason.
|
||||
*/
|
||||
export const TEMPORARY_TRANSFER_REASONS: ISelectOption[] = ['Производственная необходимость', 'Простой'].map(
|
||||
(reason) => ({ value: reason, label: reason })
|
||||
);
|
||||
|
||||
/** Гарантия работнику из шаблона ТЗ — показывается на форме пометкой. */
|
||||
export const TEMPORARY_TRANSFER_PAY_NOTE = 'Оплата — не ниже средней заработной платы по прежней работе.';
|
||||
|
||||
/** Пояснение под датой окончания. */
|
||||
export const TEMPORARY_TRANSFER_PERIOD_HINT = `Не дольше ${MAX_TRANSFER_MONTHS} месяцев.`;
|
||||
export const TEMPORARY_TRANSFER_PERIOD_ERROR = `Временный перевод — не дольше ${MAX_TRANSFER_MONTHS} месяцев, и дата окончания не раньше даты начала.`;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { maxTransferEndDate } from './period';
|
||||
|
||||
describe('maxTransferEndDate', () => {
|
||||
it('3 месяца включительно: с 01.01 — по 31.03', () => {
|
||||
expect(maxTransferEndDate('2026-01-01')).toBe('2026-03-31');
|
||||
});
|
||||
|
||||
it('с середины месяца', () => {
|
||||
expect(maxTransferEndDate('2026-02-15')).toBe('2026-05-14');
|
||||
});
|
||||
|
||||
it('через новый год', () => {
|
||||
expect(maxTransferEndDate('2026-11-01')).toBe('2027-01-31');
|
||||
});
|
||||
|
||||
it('конец месяца не уезжает в следующий (30.11 + 3 мес. = 28.02)', () => {
|
||||
expect(maxTransferEndDate('2026-11-30')).toBe('2027-02-27');
|
||||
});
|
||||
|
||||
it('пустая дата — нет ограничения', () => {
|
||||
expect(maxTransferEndDate('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Временный перевод — не дольше 3 месяцев (ТЗ: «До 3 месяцев в течение
|
||||
* календарного года»; сумму за год форма не видит — её проверяет HR-директор).
|
||||
*/
|
||||
export const MAX_TRANSFER_MONTHS = 3;
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Последний допустимый день периода, начатого start (YYYY-MM-DD): start + 3 месяца
|
||||
* − 1 день, оба дня включительно (с 01.01 — по 31.03). К концу месяца прижимаем:
|
||||
* 30.11 + 3 мес. — это 28.02, а не 02.03. Некорректная дата — undefined.
|
||||
*/
|
||||
export function maxTransferEndDate(startIso: string): string | undefined {
|
||||
const [year, month, day] = startIso.split('-').map(Number);
|
||||
if (!year || !month || !day) return undefined;
|
||||
|
||||
const targetMonthIndex = month - 1 + MAX_TRANSFER_MONTHS;
|
||||
const lastDayOfTargetMonth = new Date(year, targetMonthIndex + 1, 0).getDate();
|
||||
const end = new Date(year, targetMonthIndex, Math.min(day, lastDayOfTargetMonth));
|
||||
end.setDate(end.getDate() - 1);
|
||||
return `${end.getFullYear()}-${pad2(end.getMonth() + 1)}-${pad2(end.getDate())}`;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
@import '../../../../../../styles/design-tokens';
|
||||
|
||||
// Ошибка загрузки справочника под полем (не прячем за пустым списком).
|
||||
.fieldError {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-label;
|
||||
color: $red-500;
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, TextInput, TextArea, Select, Toggle } from '../../../../shared/ui';
|
||||
import { ProjectPicker } from '../../../../shared/dictionary';
|
||||
import { ISectionProps } from '../../model';
|
||||
import { WORKPLACE_CHANGE_REASONS } from './options';
|
||||
import { WORK_SCHEDULE_OPTIONS } from '../workSchedule';
|
||||
import { OptionWithOtherField } from '../OptionWithOtherField';
|
||||
import { useCompanyOptions } from './useCompanyOptions';
|
||||
import { InitiatorReasonFields } from '../InitiatorReasonFields';
|
||||
import styles from './WorkplaceChangeSection.module.scss';
|
||||
|
||||
const PROJECT_PLACEHOLDER = 'Начните вводить название или код проекта…';
|
||||
|
||||
/**
|
||||
* Поля вида «Изменение места работы (другая местность)». Состав согласован с
|
||||
* Тимуром по ТЗ кадров: место работы «было → стало» парой «организация + проект»
|
||||
* (текущая организация — из выбранной в шапке, новая обязательна), адрес, дата,
|
||||
* причина (а–в), условия переезда, вахта с графиком работы, обоснование.
|
||||
*
|
||||
* Поля — в FormGrid без заголовка: вид уже выбран выше, заголовок его только дублировал.
|
||||
*/
|
||||
export const WorkplaceChangeSection: React.FC<ISectionProps> = ({ data, set, context }) => {
|
||||
const companies = useCompanyOptions(context);
|
||||
|
||||
return (
|
||||
<FormGrid>
|
||||
<FormField label="Текущая организация" htmlFor="WorkplaceChangeCurrentCompany">
|
||||
{/* Та же, что выбрана в «Организации» сотрудника, — второй раз не вводим. */}
|
||||
<TextInput id="WorkplaceChangeCurrentCompany" value={data.Employee?.company || ''} onChange={() => undefined} readOnly />
|
||||
</FormField>
|
||||
<FormField label="Текущий проект" htmlFor="WorkplaceChangeCurrentProject">
|
||||
<ProjectPicker
|
||||
context={context}
|
||||
id="WorkplaceChangeCurrentProject"
|
||||
value={data.WorkplaceChangeCurrentProject}
|
||||
onChange={(name, id) => {
|
||||
set('WorkplaceChangeCurrentProject', name);
|
||||
set('WorkplaceChangeCurrentProjectId', id);
|
||||
}}
|
||||
placeholder={PROJECT_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Новая организация" htmlFor="WorkplaceChangeNewCompanyId" required>
|
||||
<Select
|
||||
id="WorkplaceChangeNewCompanyId"
|
||||
value={data.WorkplaceChangeNewCompanyId}
|
||||
onChange={(id) => {
|
||||
set('WorkplaceChangeNewCompanyId', id);
|
||||
set('WorkplaceChangeNewCompany', companies.byId.get(id)?.Name || '');
|
||||
}}
|
||||
options={companies.options}
|
||||
placeholder={companies.loading ? 'Загрузка…' : '— выберите —'}
|
||||
/>
|
||||
{companies.error && <div className={styles.fieldError}>{companies.error}</div>}
|
||||
</FormField>
|
||||
<FormField label="Новый проект" htmlFor="WorkplaceChangeNewProject">
|
||||
<ProjectPicker
|
||||
context={context}
|
||||
id="WorkplaceChangeNewProject"
|
||||
value={data.WorkplaceChangeNewProject}
|
||||
onChange={(name, id) => {
|
||||
set('WorkplaceChangeNewProject', name);
|
||||
set('WorkplaceChangeNewProjectId', id);
|
||||
}}
|
||||
placeholder={PROJECT_PLACEHOLDER}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Адрес нового места работы" htmlFor="WorkplaceChangeAddress" full>
|
||||
<TextInput
|
||||
id="WorkplaceChangeAddress"
|
||||
value={data.WorkplaceChangeAddress}
|
||||
onChange={(v) => set('WorkplaceChangeAddress', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Дата изменения" htmlFor="WorkplaceChangeDate">
|
||||
<TextInput
|
||||
id="WorkplaceChangeDate"
|
||||
type="date"
|
||||
value={data.WorkplaceChangeDate}
|
||||
onChange={(v) => set('WorkplaceChangeDate', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Условия переезда и компенсации" htmlFor="WorkplaceChangeRelocation" full>
|
||||
<TextArea
|
||||
id="WorkplaceChangeRelocation"
|
||||
value={data.WorkplaceChangeRelocation}
|
||||
onChange={(v) => set('WorkplaceChangeRelocation', v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Вахта" htmlFor="WorkplaceChangeRotation" full>
|
||||
<Toggle
|
||||
id="WorkplaceChangeRotation"
|
||||
checked={data.WorkplaceChangeRotation}
|
||||
onChange={(checked) => {
|
||||
set('WorkplaceChangeRotation', checked);
|
||||
// Без вахты график не нужен — очищаем, чтобы не сохранить скрытое значение.
|
||||
if (!checked) {
|
||||
set('WorkplaceChangeSchedule', '');
|
||||
set('WorkplaceChangeScheduleOther', '');
|
||||
}
|
||||
}}
|
||||
onLabel="Да"
|
||||
/>
|
||||
</FormField>
|
||||
{data.WorkplaceChangeRotation && (
|
||||
<OptionWithOtherField
|
||||
label="График работы"
|
||||
id="WorkplaceChangeSchedule"
|
||||
value={data.WorkplaceChangeSchedule}
|
||||
otherValue={data.WorkplaceChangeScheduleOther}
|
||||
options={WORK_SCHEDULE_OPTIONS}
|
||||
onChange={(v) => set('WorkplaceChangeSchedule', v)}
|
||||
onOtherChange={(v) => set('WorkplaceChangeScheduleOther', v)}
|
||||
/>
|
||||
)}
|
||||
<InitiatorReasonFields
|
||||
initiator={data.ChangeInitiator}
|
||||
reason={{
|
||||
id: 'WorkplaceChangeReason',
|
||||
value: data.WorkplaceChangeReason,
|
||||
options: WORKPLACE_CHANGE_REASONS,
|
||||
onChange: (v) => set('WorkplaceChangeReason', v)
|
||||
}}
|
||||
justification={{
|
||||
id: 'WorkplaceChangeJustification',
|
||||
value: data.WorkplaceChangeJustification,
|
||||
onChange: (v) => set('WorkplaceChangeJustification', v)
|
||||
}}
|
||||
/>
|
||||
</FormGrid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
|
||||
/**
|
||||
* Причины изменения места работы — по шаблону текста ТЗ кадров (а–в).
|
||||
* Работника и работодателя не различаем (как в видах 3–4).
|
||||
* Значения совпадают с choices колонки WorkplaceChangeReason (provisioning.json).
|
||||
*/
|
||||
export const WORKPLACE_CHANGE_REASONS: ISelectOption[] = [
|
||||
'Открытие нового объекта',
|
||||
'Переезд подразделения / работодателя в другую местность',
|
||||
'Иное'
|
||||
].map((reason) => ({ value: reason, label: reason }));
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { ISelectOption } from '../../../../shared/ui';
|
||||
import { ICompany, getCompanies } from '../../../../shared/dictionary';
|
||||
|
||||
export interface ICompanyOptions {
|
||||
options: ISelectOption[];
|
||||
/** Компании по Id — чтобы по выбранному значению Select получить название. */
|
||||
byId: Map<string, ICompany>;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Справочник компаний (GET /Dictionary/Companies) как варианты Select: value — Id,
|
||||
* label — название. Список кешируется в самом сервисе (localStorage, 4 часа).
|
||||
* Не загрузился — ошибка видна под полем, не прячем её за пустым списком.
|
||||
*/
|
||||
export function useCompanyOptions(context: WebPartContext): ICompanyOptions {
|
||||
const [companies, setCompanies] = useState<ICompany[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getCompanies(context)
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setCompanies(list);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!alive) return;
|
||||
setError('Не удалось загрузить список организаций. Обновите страницу.');
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
return {
|
||||
options: companies.map((c) => ({ value: c.Id, label: c.Name })),
|
||||
byId: new Map(companies.map((c) => [c.Id, c])),
|
||||
loading,
|
||||
error
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { PrepareFiles, IAttachmentEditor } from '../../../shared/attachments/useAttachmentEditor';
|
||||
import { attachmentsOfCategory, prefixFiles } from '../../../shared/attachments/attachmentCategories';
|
||||
import { IFormData } from '../model';
|
||||
import { ATTESTATION_ATTACHMENT, requiresAttestation } from './Allowance/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)
|
||||
* одновременно и префикс в имени файла, и ключ группы в editor.pending.
|
||||
*/
|
||||
export interface IAttachmentSlot {
|
||||
category: string;
|
||||
/** Обязателен там, где ТЗ прямо требует документ. */
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** Слоты, видимые при текущих данных формы. Новый слот вида = строка здесь. */
|
||||
export function activeAttachmentSlots(data: IFormData): IAttachmentSlot[] {
|
||||
const slots: IAttachmentSlot[] = [];
|
||||
if (data.ChangeTypes.includes('allowance') && requiresAttestation(data.AllowanceReason)) {
|
||||
slots.push({ category: ATTESTATION_ATTACHMENT, required: true });
|
||||
}
|
||||
if (data.ChangeTypes.includes('position-transfer')) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготовка выбранных файлов к загрузке: префикс-подпись в имени. Файлы слота,
|
||||
* который сейчас скрыт (сменили вид или основание), не загружаем — их не видно.
|
||||
*/
|
||||
export function prepareFilesFor(data: IFormData): PrepareFiles {
|
||||
const visible = activeAttachmentSlots(data).map((slot) => slot.category);
|
||||
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[] {
|
||||
return activeAttachmentSlots(data)
|
||||
.filter((slot) => slot.required && !hasAttachment(editor, slot.category))
|
||||
.map((slot) => slot.category);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { IAttachmentEditor } from '../../../shared/attachments/useAttachmentEditor';
|
||||
import { formatIsoDate } from '../../../shared/dateUtils';
|
||||
import {
|
||||
IFormData,
|
||||
INITIATOR_REQUIRED_MESSAGE,
|
||||
NEW_COMPANY_REQUIRED_MESSAGE,
|
||||
RECEIVING_HEAD_REQUIRED_MESSAGE,
|
||||
EMPLOYER_CHANGE_NOTHING_SELECTED_MESSAGE,
|
||||
EMPLOYER_JUSTIFICATION_REQUIRED_MESSAGE
|
||||
} from '../model';
|
||||
import { hasInitiatorChoice } from '../changeTypes';
|
||||
import { hasAttachment, missingRequiredAttachments } from './attachmentSlots';
|
||||
import {
|
||||
EMPLOYER_CHANGE_NOTICE_DAYS,
|
||||
EMPLOYER_JUSTIFICATION_ATTACHMENT,
|
||||
minEmployerChangeDate
|
||||
} from './EmployerInitiative/options';
|
||||
import { TEMPORARY_TRANSFER_PERIOD_ERROR } from './TemporaryTransfer/options';
|
||||
import { maxTransferEndDate } from './TemporaryTransfer/period';
|
||||
|
||||
/**
|
||||
* Что обязательно по выбранному виду изменения — проверяется только при ОТПРАВКЕ:
|
||||
* черновик сохраняется и без этого. Первая найденная проблема — текстом; ничего — undefined.
|
||||
* Новое обязательное поле вида = строка здесь.
|
||||
*/
|
||||
export function changeTypeSubmitError(data: IFormData, attachments: IAttachmentEditor): string | undefined {
|
||||
if (hasInitiatorChoice(data.ChangeTypes) && !data.ChangeInitiator) return INITIATOR_REQUIRED_MESSAGE;
|
||||
|
||||
const missingFiles = missingRequiredAttachments(data, attachments);
|
||||
if (missingFiles.length > 0) return `Приложите обязательные документы: ${missingFiles.join(', ')}.`;
|
||||
|
||||
if (data.ChangeTypes.includes('department-transfer') && !data.DepartmentTransferReceivingHead) {
|
||||
return RECEIVING_HEAD_REQUIRED_MESSAGE;
|
||||
}
|
||||
if (data.ChangeTypes.includes('workplace-change') && !data.WorkplaceChangeNewCompanyId) {
|
||||
return NEW_COMPANY_REQUIRED_MESSAGE;
|
||||
}
|
||||
if (data.ChangeTypes.includes('employer-initiative')) {
|
||||
return employerInitiativeError(data, attachments);
|
||||
}
|
||||
if (data.ChangeTypes.includes('temporary-transfer')) {
|
||||
return temporaryTransferError(data);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Временный перевод: если обе даты заданы — окончание не раньше начала и не дольше
|
||||
* 3 месяцев. Календарь это уже ограничивает; здесь — на случай, когда дату начала
|
||||
* сдвинули после выбора окончания.
|
||||
*/
|
||||
function temporaryTransferError(data: IFormData): string | undefined {
|
||||
const start = data.TemporaryTransferStartDate;
|
||||
const end = data.TemporaryTransferEndDate;
|
||||
if (!start || !end) return undefined;
|
||||
const maxEnd = maxTransferEndDate(start);
|
||||
if (end < start || (maxEnd && end > maxEnd)) return TEMPORARY_TRANSFER_PERIOD_ERROR;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициатива работодателя: отмечено, что меняется; обоснование текстом ИЛИ
|
||||
* документом (ТЗ: «ТЕКСТ ИЛИ ПРИЛОЖИТЬ ДОКУМЕНТ»); даты — не раньше срока
|
||||
* уведомления. Даты проверяем и здесь, а не только min в календаре: черновик мог
|
||||
* пролежать, и прежняя дата стала слишком близкой.
|
||||
*/
|
||||
function employerInitiativeError(data: IFormData, attachments: IAttachmentEditor): string | undefined {
|
||||
if (!data.EmployerChangeSchedule && !data.EmployerChangeSalary) return EMPLOYER_CHANGE_NOTHING_SELECTED_MESSAGE;
|
||||
|
||||
const hasJustification =
|
||||
!!data.EmployerChangeJustification.trim() || hasAttachment(attachments, EMPLOYER_JUSTIFICATION_ATTACHMENT);
|
||||
if (!hasJustification) return EMPLOYER_JUSTIFICATION_REQUIRED_MESSAGE;
|
||||
|
||||
const minDate = minEmployerChangeDate();
|
||||
const dates = [
|
||||
data.EmployerChangeSchedule ? data.EmployerChangeScheduleDate : '',
|
||||
data.EmployerChangeSalary ? data.EmployerChangeSalaryDate : ''
|
||||
];
|
||||
if (dates.some((date) => date && date < minDate)) {
|
||||
return `Дата изменений — не раньше ${formatIsoDate(minDate)}: работника уведомляют за ${EMPLOYER_CHANGE_NOTICE_DAYS} календарных дней.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ISelectOption } from '../../../shared/ui';
|
||||
|
||||
/**
|
||||
* Режим работы: «График работы» и «Время работы» — те же наборы, что в «Заявке
|
||||
* на подбор» и «Заявке на приём» (WORK_SCHEDULE_OPTIONS / WORK_HOURS_OPTIONS там):
|
||||
* формы независимы, значения продублированы намеренно, как и между ними.
|
||||
* Общие для видов этой формы (место работы — вахта, режим/график работы).
|
||||
*/
|
||||
|
||||
/** «Другое» в списке — открывает ручной ввод (см. OptionWithOtherField). */
|
||||
export const OPTION_OTHER = 'Другое';
|
||||
|
||||
function toOptions(values: string[]): ISelectOption[] {
|
||||
return values.map((value) => ({ value, label: value }));
|
||||
}
|
||||
|
||||
export const WORK_SCHEDULE_OPTIONS: ISelectOption[] = toOptions([
|
||||
'5/2',
|
||||
'Вахтовый 30/30',
|
||||
'Вахтовый 20/10',
|
||||
'Вахтовый 30/15',
|
||||
'Вахтовый 22/8',
|
||||
'Вахтовый 15/15',
|
||||
'6/1',
|
||||
'1/2',
|
||||
'2/2',
|
||||
OPTION_OTHER
|
||||
]);
|
||||
|
||||
/** 24-часовой формат, как в подборе и приёме. */
|
||||
export const WORK_HOURS_OPTIONS: ISelectOption[] = toOptions([
|
||||
'08:00-17:00',
|
||||
'09:00-18:00',
|
||||
'07:00-19:00',
|
||||
'08:00-20:00',
|
||||
'09:00-21:00',
|
||||
OPTION_OTHER
|
||||
]);
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ISelectOption } from '../../shared/ui';
|
||||
|
||||
/**
|
||||
* Виды изменения условий трудового договора (ст. 33 ТК РК) — по ТЗ кадров.
|
||||
*
|
||||
* В коде вид — стабильный ключ (union ниже), в SharePoint — его подпись: колонка
|
||||
* ChangeTypes (MultiChoice) хранит текст, чтобы кадры видели в списке и отчётах
|
||||
* понятные значения. Подпись = choice колонки, поэтому менять её можно только
|
||||
* вместе с choices в provisioning.json (иначе старые заявки перестанут узнаваться).
|
||||
*/
|
||||
export type ChangeType =
|
||||
| 'salary-increase'
|
||||
| 'allowance'
|
||||
| 'position-transfer'
|
||||
| 'department-transfer'
|
||||
| 'workplace-change'
|
||||
| 'contract-extension'
|
||||
| 'schedule-change'
|
||||
| 'function-change'
|
||||
| 'employer-initiative'
|
||||
| 'temporary-transfer';
|
||||
|
||||
/** Порядок — как в ТЗ (в нём же показываются в выпадающем списке). */
|
||||
export const CHANGE_TYPE_LABELS: Record<ChangeType, string> = {
|
||||
'salary-increase': 'Повышение должностного оклада',
|
||||
allowance: 'Установление надбавки / доплаты',
|
||||
'position-transfer': 'Перевод на другую должность (постоянный)',
|
||||
'department-transfer': 'Перевод в другое подразделение',
|
||||
'workplace-change': 'Изменение места работы (другая местность)',
|
||||
'contract-extension': 'Продление срока трудового договора',
|
||||
// В ТЗ ещё «; неполное рабочее время по просьбе работника» — сокращено по просьбе Тимура
|
||||
// (неполное время — поля работника внутри вида).
|
||||
'schedule-change': 'Изменение режима / графика работы по соглашению сторон',
|
||||
'function-change': 'Изменение трудовой функции (без смены должности)',
|
||||
// В ТЗ — длинная формулировка с перечнем причин; сокращено по просьбе Тимура (причины — в поле «Причина» вида).
|
||||
'employer-initiative': 'Изменение условий труда по инициативе работодателя',
|
||||
'temporary-transfer': 'Временный перевод при производственной необходимости / простое'
|
||||
};
|
||||
|
||||
/**
|
||||
* Сколько видов можно выбрать в одной заявке. По ТЗ — один; данные (массив
|
||||
* ChangeTypes + MultiChoice-колонка) уже рассчитаны на несколько, чтобы снять
|
||||
* ограничение правкой UI, а не миграцией.
|
||||
*/
|
||||
export const MAX_CHANGE_TYPES = 1;
|
||||
|
||||
export const CHANGE_TYPE_OPTIONS: ISelectOption[] = (Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).map(
|
||||
(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. */
|
||||
export function changeTypeFromLabel(label: string): ChangeType | undefined {
|
||||
const normalized = label.trim();
|
||||
return (Object.keys(CHANGE_TYPE_LABELS) as ChangeType[]).find(
|
||||
(type) => CHANGE_TYPE_LABELS[type] === normalized
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { getSP } from '../../shared/pnp/pnpjs';
|
||||
import { dataListTitle } from '../../shared/listTitles';
|
||||
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||
import { ChangeType, changeTypeFromLabel, initiatorRoleFromText } from './changeTypes';
|
||||
import { allowanceUnitFromText } from './byType/Allowance/options';
|
||||
import { DEFAULT_FIXED_PERCENT } from './byType/EmployerInitiative/salaryParts';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA } from './model';
|
||||
|
||||
export interface ILoadedRequest {
|
||||
data: IFormData;
|
||||
status: string;
|
||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||
code: string;
|
||||
}
|
||||
|
||||
function text(raw: unknown): string {
|
||||
return typeof raw === 'string' ? raw : '';
|
||||
}
|
||||
|
||||
/** Number-колонка → «сырое» число строкой (для MoneyInput / ввода процента); пусто → ''. */
|
||||
function numberText(raw: unknown): string {
|
||||
return typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Колонки Employee* → запись сотрудника (см. employeeColumns в submit.ts).
|
||||
* Нет ни id, ни Ф.И.О. — сотрудник не был выбран (черновик) → undefined.
|
||||
* Даты приёма/увольнения не хранятся — ''; e-mail пустой — у сотрудника нет учётки.
|
||||
*/
|
||||
function employeeFromItem(item: Record<string, unknown>): IEmployeeFullBaseRecord | undefined {
|
||||
const id = text(item.EmployeeRecordId);
|
||||
const fio = text(item.EmployeeFio);
|
||||
if (!id && !fio) return undefined;
|
||||
return {
|
||||
id,
|
||||
fio,
|
||||
iin: text(item.EmployeeIin),
|
||||
code: text(item.EmployeeCode),
|
||||
company: text(item.EmployeeCompany),
|
||||
position: text(item.EmployeePosition),
|
||||
department: text(item.EmployeeDepartment),
|
||||
admissionDate: '',
|
||||
dismissalDate: '',
|
||||
email: text(item.EmployeeEmail)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MultiChoice → ключи видов. Подпись, которой нет в CHANGE_TYPE_LABELS (choice
|
||||
* переименовали в SP, а код — нет), пропускаем с предупреждением в консоли:
|
||||
* форма покажет «вид не выбран», и при отправке валидация попросит выбрать заново.
|
||||
*/
|
||||
function changeTypesFromItem(raw: unknown): ChangeType[] {
|
||||
const labels = Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
|
||||
const types: ChangeType[] = [];
|
||||
labels.forEach((label) => {
|
||||
const type = changeTypeFromLabel(label);
|
||||
if (type) {
|
||||
types.push(type);
|
||||
} else {
|
||||
console.warn(`[ContractChange] Неизвестный вид изменения «${label}» — нет в CHANGE_TYPE_LABELS`);
|
||||
}
|
||||
});
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Прочитать заявку по id: модель формы + статус (правка / просмотр).
|
||||
*
|
||||
* listId — партиция, где лежит ИМЕННО эта заявка (см. shared/approval/
|
||||
* listPartitions.ts). Не задан — читаем из ТЕКУЩЕЙ партиции; задан — идём напрямую
|
||||
* по url списка.
|
||||
*/
|
||||
export async function loadContractChangeRequest(itemId: number, listId?: string): Promise<ILoadedRequest> {
|
||||
const list = listId ? getSP().web.getList(listId) : getSP().web.lists.getByTitle(dataListTitle(FORM_ID));
|
||||
const item = (await list.items
|
||||
.getById(itemId)
|
||||
.select(
|
||||
'Id',
|
||||
'RequestStatus',
|
||||
'RequestUniqueCode',
|
||||
'EmployeeRecordId',
|
||||
'EmployeeFio',
|
||||
'EmployeeIin',
|
||||
'EmployeeCode',
|
||||
'EmployeeEmail',
|
||||
'EmployeeCompany',
|
||||
'EmployeePosition',
|
||||
'EmployeeDepartment',
|
||||
'ChangeTypes',
|
||||
'ChangeInitiator',
|
||||
'SalaryIncreaseCurrent',
|
||||
'SalaryIncreaseNew',
|
||||
'SalaryIncreaseDate',
|
||||
'SalaryIncreaseReason',
|
||||
'SalaryIncreaseJustification',
|
||||
'AllowanceReason',
|
||||
'AllowanceStartDate',
|
||||
'AllowanceEndDate',
|
||||
'AllowanceIndefinite',
|
||||
'AllowanceAmount',
|
||||
'AllowanceUnit',
|
||||
'AllowanceJustification',
|
||||
'PositionTransferNewPosition',
|
||||
'PositionTransferDate',
|
||||
'PositionTransferReason',
|
||||
'PositionTransferJustification',
|
||||
'DepartmentTransferNewDepartment',
|
||||
'DepartmentTransferNewDepartmentId',
|
||||
'DepartmentTransferDate',
|
||||
'DepartmentTransferReason',
|
||||
'DepartmentTransferJustification',
|
||||
'DepartmentTransferReceivingHead/EMail',
|
||||
'WorkplaceChangeCurrentProject',
|
||||
'WorkplaceChangeCurrentProjectId',
|
||||
'WorkplaceChangeNewCompany',
|
||||
'WorkplaceChangeNewCompanyId',
|
||||
'WorkplaceChangeNewProject',
|
||||
'WorkplaceChangeNewProjectId',
|
||||
'WorkplaceChangeAddress',
|
||||
'WorkplaceChangeDate',
|
||||
'WorkplaceChangeReason',
|
||||
'WorkplaceChangeRelocation',
|
||||
'WorkplaceChangeRotation',
|
||||
'WorkplaceChangeSchedule',
|
||||
'WorkplaceChangeScheduleOther',
|
||||
'WorkplaceChangeJustification',
|
||||
'ScheduleChangeCurrentSchedule',
|
||||
'ScheduleChangeCurrentScheduleOther',
|
||||
'ScheduleChangeCurrentHours',
|
||||
'ScheduleChangeCurrentHoursOther',
|
||||
'ScheduleChangeDate',
|
||||
'ScheduleChangeHoursPerDay',
|
||||
'ScheduleChangeDaysPerWeek',
|
||||
'ScheduleChangeNewMode',
|
||||
'ScheduleChangeNewModeOther',
|
||||
'ScheduleChangeNewSchedule',
|
||||
'ScheduleChangeNewScheduleOther',
|
||||
'ScheduleChangeNewHours',
|
||||
'ScheduleChangeNewHoursOther',
|
||||
'ScheduleChangeReason',
|
||||
'ScheduleChangeJustification',
|
||||
'FunctionChangeDate',
|
||||
'FunctionChangeReason',
|
||||
'EmployerChangeReason',
|
||||
'EmployerChangeReasonOther',
|
||||
'EmployerChangeSchedule',
|
||||
'EmployerChangeCurrentSchedule',
|
||||
'EmployerChangeCurrentScheduleOther',
|
||||
'EmployerChangeNewSchedule',
|
||||
'EmployerChangeNewScheduleOther',
|
||||
'EmployerChangeScheduleDate',
|
||||
'EmployerChangeSalary',
|
||||
'EmployerChangeNewSalary',
|
||||
'EmployerChangeFixedPercent',
|
||||
'EmployerChangeSalaryDate',
|
||||
'EmployerChangeJustification',
|
||||
'TemporaryTransferFromProject',
|
||||
'TemporaryTransferFromProjectId',
|
||||
'TemporaryTransferFromWork',
|
||||
'TemporaryTransferToProject',
|
||||
'TemporaryTransferToProjectId',
|
||||
'TemporaryTransferToWork',
|
||||
'TemporaryTransferStartDate',
|
||||
'TemporaryTransferEndDate',
|
||||
'TemporaryTransferReason',
|
||||
'TemporaryTransferJustification'
|
||||
)
|
||||
.expand('DepartmentTransferReceivingHead')()) as Record<string, unknown>;
|
||||
const receivingHead = item.DepartmentTransferReceivingHead as { EMail?: string } | undefined;
|
||||
|
||||
const data: IFormData = {
|
||||
...EMPTY_DATA,
|
||||
Employee: employeeFromItem(item),
|
||||
ChangeTypes: changeTypesFromItem(item.ChangeTypes),
|
||||
ChangeInitiator: initiatorRoleFromText(text(item.ChangeInitiator)),
|
||||
SalaryIncreaseCurrent: numberText(item.SalaryIncreaseCurrent),
|
||||
SalaryIncreaseNew: numberText(item.SalaryIncreaseNew),
|
||||
// Дату только через spDateOnlyToIso — иначе из-за часового пояса уезжает на день назад.
|
||||
SalaryIncreaseDate: spDateOnlyToIso(text(item.SalaryIncreaseDate)),
|
||||
SalaryIncreaseReason: text(item.SalaryIncreaseReason),
|
||||
SalaryIncreaseJustification: text(item.SalaryIncreaseJustification),
|
||||
AllowanceReason: text(item.AllowanceReason),
|
||||
AllowanceStartDate: spDateOnlyToIso(text(item.AllowanceStartDate)),
|
||||
AllowanceEndDate: spDateOnlyToIso(text(item.AllowanceEndDate)),
|
||||
AllowanceIndefinite: item.AllowanceIndefinite === true,
|
||||
AllowanceAmount: numberText(item.AllowanceAmount),
|
||||
AllowanceUnit: allowanceUnitFromText(text(item.AllowanceUnit)),
|
||||
AllowanceJustification: text(item.AllowanceJustification),
|
||||
PositionTransferNewPosition: text(item.PositionTransferNewPosition),
|
||||
PositionTransferDate: spDateOnlyToIso(text(item.PositionTransferDate)),
|
||||
PositionTransferReason: text(item.PositionTransferReason),
|
||||
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 {
|
||||
data,
|
||||
status: text(item.RequestStatus),
|
||||
code: text(item.RequestUniqueCode)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { RequestTypeId } from '../../models/types';
|
||||
import { IEmployeeFullBasePerson, IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||
import { ChangeType, InitiatorRole, INITIATOR_EMPLOYEE, INITIATOR_EMPLOYER } from './changeTypes';
|
||||
import { AllowanceUnit, DEFAULT_ALLOWANCE_UNIT } from './byType/Allowance/options';
|
||||
import { DEFAULT_FIXED_PERCENT } from './byType/EmployerInitiative/salaryParts';
|
||||
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||
|
||||
/**
|
||||
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||
* SharePoint выводятся из него, см. shared/listTitles.ts.
|
||||
*/
|
||||
export const FORM_ID: RequestTypeId = 'hr-contract-change';
|
||||
|
||||
/**
|
||||
* Данные формы «Изменение условий трудового договора» (ст. 33 ТК РК).
|
||||
*
|
||||
* Сотрудник — запись из Employee/FullBase, а не People: заявку подают и за
|
||||
* рабочих без учётной записи M365. В списке SP запись лежит снимком в текстовых
|
||||
* колонках Employee* (разборка/сборка — submit.ts/load.ts); даты приёма и
|
||||
* увольнения не храним — после загрузки они ''.
|
||||
*
|
||||
* Поля под конкретные виды изменения добавляются по мере согласования с кадрами
|
||||
* — каждый вид своим блоком с префиксом имён (см. changeTypes.ts).
|
||||
*/
|
||||
export interface IFormData {
|
||||
/**
|
||||
* Место работы сотрудника, где меняются условия ТД (человек + организация —
|
||||
* см. useContractEmployee). undefined — не выбран человек или ещё не выбрана организация.
|
||||
*/
|
||||
Employee: IEmployeeFullBaseRecord | undefined;
|
||||
/**
|
||||
* Выбранные виды изменения. По ТЗ ровно один (MAX_CHANGE_TYPES), массив —
|
||||
* задел под несколько, см. changeTypes.ts. [] — не выбран.
|
||||
*/
|
||||
ChangeTypes: ChangeType[];
|
||||
/**
|
||||
* Инициатор — только у видов, где по ТЗ им может быть и работник
|
||||
* (hasInitiatorChoice). От роли зависят поля: у работника — текст заявления,
|
||||
* у работодателя — причины + обоснование. '' — не выбран / у вида выбора нет.
|
||||
*/
|
||||
ChangeInitiator: InitiatorRole | '';
|
||||
|
||||
// ── Вид «Повышение должностного оклада» (byType/SalaryIncrease). Все необязательные.
|
||||
/** Текущий оклад — «сырые» цифры без пробелов (так отдаёт MoneyInput); '' — не заполнен. */
|
||||
SalaryIncreaseCurrent: string;
|
||||
/** Новый оклад — как SalaryIncreaseCurrent. */
|
||||
SalaryIncreaseNew: string;
|
||||
/** С какой даты, ISO YYYY-MM-DD. */
|
||||
SalaryIncreaseDate: string;
|
||||
/** Причина — одно из SALARY_INCREASE_REASONS (Choice-колонка); '' — не выбрана. */
|
||||
SalaryIncreaseReason: string;
|
||||
/** Обоснование; при причине «Иное» — её текст. */
|
||||
SalaryIncreaseJustification: string;
|
||||
|
||||
// ── Вид «Установление надбавки / доплаты» (byType/Allowance). Все необязательные.
|
||||
/** Основание — одно из ALLOWANCE_REASONS (Choice-колонка); '' — не выбрано. */
|
||||
AllowanceReason: string;
|
||||
/** С какой даты, ISO YYYY-MM-DD. */
|
||||
AllowanceStartDate: string;
|
||||
/** По какую дату, ISO; при AllowanceIndefinite всегда ''. */
|
||||
AllowanceEndDate: string;
|
||||
/** Бессрочно — тогда даты окончания нет. */
|
||||
AllowanceIndefinite: boolean;
|
||||
/**
|
||||
* Размер — «сырое» число строкой: в ₸ целое (MoneyInput), в % может быть дробным
|
||||
* с точкой, «7.5» (см. byType/Allowance/amount.ts); единица — в AllowanceUnit.
|
||||
*/
|
||||
AllowanceAmount: string;
|
||||
AllowanceUnit: AllowanceUnit;
|
||||
/** Обоснование; при совмещении — в т.ч. объём доп. работы, при «иное» — его текст. */
|
||||
AllowanceJustification: string;
|
||||
|
||||
// ── Вид «Перевод на другую должность (постоянный)» (byType/PositionTransfer).
|
||||
// Поля необязательные; вложение «Должностная инструкция» — обязательно (attachmentSlots).
|
||||
/** Новая должность — из справочника или вручную (PositionPicker). */
|
||||
PositionTransferNewPosition: string;
|
||||
/** С какой даты, ISO YYYY-MM-DD. */
|
||||
PositionTransferDate: string;
|
||||
/** Причина — одно из POSITION_TRANSFER_REASONS (Choice-колонка); '' — не выбрана. */
|
||||
PositionTransferReason: string;
|
||||
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
|
||||
PositionTransferJustification: string;
|
||||
|
||||
// ── Вид «Перевод в другое подразделение» (byType/DepartmentTransfer).
|
||||
// Поля необязательные, кроме принимающего руководителя (при отправке).
|
||||
/** Новое подразделение (справочник Dictionary/Departments, DepartmentPicker). */
|
||||
DepartmentTransferNewDepartment: string;
|
||||
/** Id подразделения из справочника; '' — введено вручную. */
|
||||
DepartmentTransferNewDepartmentId: string;
|
||||
/** С какой даты, ISO YYYY-MM-DD. */
|
||||
DepartmentTransferDate: string;
|
||||
/** Причина — одно из DEPARTMENT_TRANSFER_REASONS (Choice-колонка); '' — не выбрана. */
|
||||
DepartmentTransferReason: string;
|
||||
/** Обоснование; при «Иное» — его текст, у работника — текст заявления. */
|
||||
DepartmentTransferJustification: string;
|
||||
/**
|
||||
* E-mail принимающего руководителя (People). Форма ставит его первым шагом
|
||||
* маршрута согласования (byType/DepartmentTransfer/receivingHead.ts). '' — не выбран.
|
||||
*/
|
||||
DepartmentTransferReceivingHead: string;
|
||||
|
||||
// ── Вид «Изменение места работы (другая местность)» (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;
|
||||
}
|
||||
|
||||
/** Обновление одного поля формы с сохранением типа значения. */
|
||||
export type SetField = <K extends keyof IFormData>(key: K, value: IFormData[K]) => void;
|
||||
|
||||
/** Пропсы секции формы: срез данных + колбэк обновления + контекст (запросы к справочникам). */
|
||||
export interface ISectionProps {
|
||||
data: IFormData;
|
||||
set: SetField;
|
||||
context: WebPartContext;
|
||||
/** Вложения заявки — для подписанных слотов в секциях видов (см. byType/attachmentSlots). */
|
||||
attachments?: IAttachmentEditor;
|
||||
}
|
||||
|
||||
export const EMPTY_DATA: IFormData = {
|
||||
Employee: undefined,
|
||||
ChangeTypes: [],
|
||||
ChangeInitiator: '',
|
||||
SalaryIncreaseCurrent: '',
|
||||
SalaryIncreaseNew: '',
|
||||
SalaryIncreaseDate: '',
|
||||
SalaryIncreaseReason: '',
|
||||
SalaryIncreaseJustification: '',
|
||||
AllowanceReason: '',
|
||||
AllowanceStartDate: '',
|
||||
AllowanceEndDate: '',
|
||||
AllowanceIndefinite: false,
|
||||
AllowanceAmount: '',
|
||||
AllowanceUnit: DEFAULT_ALLOWANCE_UNIT,
|
||||
AllowanceJustification: '',
|
||||
PositionTransferNewPosition: '',
|
||||
PositionTransferDate: '',
|
||||
PositionTransferReason: '',
|
||||
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: ''
|
||||
};
|
||||
|
||||
/** Плейсхолдер для поля выбора сотрудника. */
|
||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||
/** Плейсхолдер для выбора организации (места работы) сотрудника. */
|
||||
export const WORKPLACE_PLACEHOLDER = 'Выберите организацию, в которой меняются условия';
|
||||
/** Плейсхолдер для выбора вида изменения. */
|
||||
export const CHANGE_TYPE_PLACEHOLDER = '— выберите вид изменения —';
|
||||
/**
|
||||
* Пояснение под сотрудником без учётной записи: approval-route резолвит маршрут
|
||||
* по e-mail, а его нет — маршрут строится по заявителю (см. routeEmail в форме).
|
||||
*/
|
||||
export const ROUTE_BY_APPLICANT_NOTE =
|
||||
'У сотрудника нет учётной записи — маршрут согласования построен по заявителю.';
|
||||
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, по которому строится маршрут: сотрудника, а если у него нет учётной
|
||||
* записи (рабочие на объектах) — заявителя. Берётся от человека, а не от места
|
||||
* работы: маршрут от организации не зависит и грузится, пока её ещё выбирают.
|
||||
*/
|
||||
/**
|
||||
* Инициатор по умолчанию (см. 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 {
|
||||
if (!person) return '';
|
||||
return person.email || applicantEmail;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"dataListTitle": "Изменение условий трудового договора — данные",
|
||||
"columns": [
|
||||
{ "internalName": "RequestUniqueCode", "title": "Уникальный код заявки", "type": "Text" },
|
||||
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||
|
||||
{ "internalName": "EmployeeRecordId", "title": "Сотрудник: id записи кадровой базы", "type": "Text" },
|
||||
{ "internalName": "EmployeeFio", "title": "Сотрудник: Ф.И.О.", "type": "Text" },
|
||||
{ "internalName": "EmployeeIin", "title": "Сотрудник: ИИН", "type": "Text" },
|
||||
{ "internalName": "EmployeeCode", "title": "Сотрудник: табельный номер", "type": "Text" },
|
||||
{ "internalName": "EmployeeEmail", "title": "Сотрудник: e-mail", "type": "Text" },
|
||||
{ "internalName": "EmployeeCompany", "title": "Сотрудник: организация", "type": "Text" },
|
||||
{ "internalName": "EmployeePosition", "title": "Сотрудник: должность", "type": "Text" },
|
||||
{ "internalName": "EmployeeDepartment", "title": "Сотрудник: подразделение", "type": "Text" },
|
||||
|
||||
{ "internalName": "ChangeTypes", "title": "Вид изменения", "type": "Choice", "allowMultiple": true, "choices": [
|
||||
"Повышение должностного оклада",
|
||||
"Установление надбавки / доплаты",
|
||||
"Перевод на другую должность (постоянный)",
|
||||
"Перевод в другое подразделение",
|
||||
"Изменение места работы (другая местность)",
|
||||
"Продление срока трудового договора",
|
||||
"Изменение режима / графика работы по соглашению сторон",
|
||||
"Изменение трудовой функции (без смены должности)",
|
||||
"Изменение условий труда по инициативе работодателя",
|
||||
"Временный перевод при производственной необходимости / простое"
|
||||
] },
|
||||
{ "internalName": "ChangeInitiator", "title": "Инициатор изменения", "type": "Choice", "choices": ["Работник", "Работодатель"] },
|
||||
|
||||
{ "internalName": "SalaryIncreaseCurrent", "title": "Повышение оклада: текущий оклад", "type": "Number", "decimals": 0 },
|
||||
{ "internalName": "SalaryIncreaseNew", "title": "Повышение оклада: новый оклад", "type": "Number", "decimals": 0 },
|
||||
{ "internalName": "SalaryIncreaseDate", "title": "Повышение оклада: дата изменения", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "SalaryIncreaseReason", "title": "Повышение оклада: причина", "type": "Choice", "choices": [
|
||||
"Успешное прохождение испытательного срока",
|
||||
"Расширение функционала / зоны ответственности",
|
||||
"Иное"
|
||||
] },
|
||||
{ "internalName": "SalaryIncreaseJustification", "title": "Повышение оклада: обоснование", "type": "Note" },
|
||||
|
||||
{ "internalName": "AllowanceReason", "title": "Надбавка/доплата: основание", "type": "Choice", "choices": [
|
||||
"Надбавка: наставничество и обучение новых работников",
|
||||
"Надбавка: разъездной характер работы",
|
||||
"Доплата: совмещение должностей / расширение зоны обслуживания",
|
||||
"Доплата: работа в тяжёлых, вредных и (или) опасных условиях труда",
|
||||
"Доплата: иное по решению первого руководителя"
|
||||
] },
|
||||
{ "internalName": "AllowanceStartDate", "title": "Надбавка/доплата: дата начала", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "AllowanceEndDate", "title": "Надбавка/доплата: дата окончания", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "AllowanceIndefinite", "title": "Надбавка/доплата: бессрочно", "type": "Boolean" },
|
||||
{ "internalName": "AllowanceAmount", "title": "Надбавка/доплата: размер", "type": "Number", "decimals": 2 },
|
||||
{ "internalName": "AllowanceUnit", "title": "Надбавка/доплата: единица размера", "type": "Choice", "choices": ["%", "₸"] },
|
||||
{ "internalName": "AllowanceJustification", "title": "Надбавка/доплата: обоснование", "type": "Note" },
|
||||
|
||||
{ "internalName": "PositionTransferNewPosition", "title": "Перевод на должность: новая должность", "type": "Text" },
|
||||
{ "internalName": "PositionTransferDate", "title": "Перевод на должность: дата перевода", "type": "DateTime", "dateOnly": true },
|
||||
{ "internalName": "PositionTransferReason", "title": "Перевод на должность: причина", "type": "Choice", "choices": [
|
||||
"Карьерный рост (успешное выполнение обязанностей, высокие результаты)",
|
||||
"Вакантная должность, соответствующая квалификации работника",
|
||||
"Открытие нового объекта / проекта, перераспределение функций",
|
||||
"Иное"
|
||||
] },
|
||||
{ "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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// Пояснение под полем «Сотрудник» (маршрут по заявителю — см. ROUTE_BY_APPLICANT_NOTE).
|
||||
.note {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-label;
|
||||
color: $gray-500;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as React from 'react';
|
||||
import { FormGrid, FormField, Select, ISelectOption } from '../../../shared/ui';
|
||||
import { useFormReadOnly } from '@tnsu/ui-kit';
|
||||
import {
|
||||
EmployeeFullBasePicker,
|
||||
formatOrganizationLine,
|
||||
personFromWorkplace,
|
||||
workplaceKey
|
||||
} from '../../../shared/dictionary';
|
||||
import {
|
||||
ChangeType,
|
||||
CHANGE_TYPE_OPTIONS,
|
||||
INITIATOR_OPTIONS,
|
||||
InitiatorRole,
|
||||
hasInitiatorChoice,
|
||||
initiatorRoleFromText
|
||||
} from '../changeTypes';
|
||||
import {
|
||||
ISectionProps,
|
||||
EMPLOYEE_PLACEHOLDER,
|
||||
WORKPLACE_PLACEHOLDER,
|
||||
CHANGE_TYPE_PLACEHOLDER,
|
||||
ROUTE_BY_APPLICANT_NOTE,
|
||||
INITIATOR_HINT
|
||||
} from '../model';
|
||||
import { IContractEmployee } from '../useContractEmployee';
|
||||
import styles from './RequestSection.module.scss';
|
||||
|
||||
interface IRequestSectionProps extends ISectionProps {
|
||||
/** Выбор сотрудника/организации — только на форме подачи; в просмотре не передаётся. */
|
||||
employeeControl?: IContractEmployee;
|
||||
/**
|
||||
* Инициатор по умолчанию при выборе вида с выбором инициатора: подаёт на себя —
|
||||
* работник, за другого — работодатель (см. ContractChangeForm). В просмотре не нужен.
|
||||
*/
|
||||
defaultInitiator?: InitiatorRole;
|
||||
/** Ручной выбор инициатора (см. useInitiatorAutofill); не задан — просто set. */
|
||||
onInitiatorChange?: (role: InitiatorRole | '') => void;
|
||||
}
|
||||
|
||||
const NOOP = (): void => undefined;
|
||||
|
||||
/**
|
||||
* Общая часть заявки, не зависящая от вида изменения: чьи условия ТД меняются,
|
||||
* в какой организации (у совместителя их несколько) и что именно меняется.
|
||||
* Поля конкретных видов — отдельными секциями (byType/).
|
||||
*/
|
||||
export const RequestSection: React.FC<IRequestSectionProps> = ({
|
||||
data,
|
||||
set,
|
||||
context,
|
||||
employeeControl,
|
||||
defaultInitiator,
|
||||
onInitiatorChange
|
||||
}) => {
|
||||
const readOnly = useFormReadOnly();
|
||||
// В просмотре человека нет в состоянии — собираем из сохранённого места работы.
|
||||
const person = employeeControl?.person ?? (data.Employee ? personFromWorkplace(data.Employee) : undefined);
|
||||
const workplaceOptions: ISelectOption[] = (person?.workplaces ?? []).map((w) => ({
|
||||
value: workplaceKey(w),
|
||||
label: formatOrganizationLine(w.company, w.position, w.department)
|
||||
}));
|
||||
const hasNoAccount = !!person && !person.email;
|
||||
|
||||
// По ТЗ вид один (MAX_CHANGE_TYPES), поэтому обычный Select: значение — первый
|
||||
// элемент массива, выбор заменяет массив целиком. Под несколько видов здесь
|
||||
// встанет MultiChoiceField, модель и SP уже готовы.
|
||||
const selectedType = data.ChangeTypes[0] || '';
|
||||
const handleTypeChange = (value: string): void => {
|
||||
const types: ChangeType[] = value ? [value as ChangeType] : [];
|
||||
set('ChangeTypes', types);
|
||||
// Инициатор есть только у видов с выбором: у остальных очищаем, у этих —
|
||||
// подставляем по умолчанию, если ещё не выбран.
|
||||
if (!hasInitiatorChoice(types)) set('ChangeInitiator', '');
|
||||
else if (!data.ChangeInitiator && defaultInitiator) set('ChangeInitiator', defaultInitiator);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormGrid>
|
||||
<FormField label="Сотрудник" htmlFor="Employee" required full>
|
||||
<EmployeeFullBasePicker
|
||||
context={context}
|
||||
id="Employee"
|
||||
value={person}
|
||||
onChange={employeeControl ? employeeControl.onPersonChange : NOOP}
|
||||
placeholder={EMPLOYEE_PLACEHOLDER}
|
||||
/>
|
||||
{!readOnly && hasNoAccount && <div className={styles.note}>{ROUTE_BY_APPLICANT_NOTE}</div>}
|
||||
</FormField>
|
||||
{workplaceOptions.length > 0 && (
|
||||
<FormField label="Организация" htmlFor="EmployeeWorkplace" required full>
|
||||
<Select
|
||||
id="EmployeeWorkplace"
|
||||
value={data.Employee ? workplaceKey(data.Employee) : ''}
|
||||
onChange={employeeControl ? employeeControl.onWorkplaceChange : NOOP}
|
||||
options={workplaceOptions}
|
||||
placeholder={WORKPLACE_PLACEHOLDER}
|
||||
// Единственное место работы выбрано автоматически — выбирать не из чего (как в командировке).
|
||||
readOnly={workplaceOptions.length === 1 && !!data.Employee}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Вид изменения" htmlFor="ChangeTypes" required full>
|
||||
<Select
|
||||
id="ChangeTypes"
|
||||
value={selectedType}
|
||||
onChange={handleTypeChange}
|
||||
options={CHANGE_TYPE_OPTIONS}
|
||||
placeholder={CHANGE_TYPE_PLACEHOLDER}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,325 @@
|
||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||
import { IEmployeeFullBaseRecord } from '../../shared/dictionary';
|
||||
import { ensureEmployee } from '../../shared/employee';
|
||||
import { CHANGE_TYPE_LABELS, INITIATOR_EMPLOYEE, hasInitiatorChoice } from './changeTypes';
|
||||
import { FORM_ID, IFormData } from './model';
|
||||
import { splitSalary } from './byType/EmployerInitiative/salaryParts';
|
||||
|
||||
/**
|
||||
* Экземпляр сервиса списка согласования этой формы — тонкая обёртка над
|
||||
* approvalServiceFor (см. shared/approval/approvalServiceFactory.ts).
|
||||
*/
|
||||
export function contractChangeApprovalService(listId?: string): ApprovalListService {
|
||||
return approvalServiceFor(FORM_ID, listId);
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
|
||||
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||
function buildTitle(employeeFio: string): string {
|
||||
const now = new Date();
|
||||
const date = `${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`;
|
||||
return `Изменение условий ТД — ${employeeFio || 'сотрудник не выбран'} — ${date}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Снимок сотрудника → текстовые колонки Employee* (см. provisioning.json).
|
||||
* Не выбран (черновик) — все колонки очищаются null (так SharePoint REST очищает
|
||||
* поле), а не остаются от прошлого выбора.
|
||||
*/
|
||||
function employeeColumns(employee: IEmployeeFullBaseRecord | undefined): Record<string, unknown> {
|
||||
return {
|
||||
EmployeeRecordId: employee?.id || null,
|
||||
EmployeeFio: employee?.fio || null,
|
||||
EmployeeIin: employee?.iin || null,
|
||||
EmployeeCode: employee?.code || null,
|
||||
EmployeeEmail: employee?.email || null,
|
||||
EmployeeCompany: employee?.company || null,
|
||||
EmployeePosition: employee?.position || null,
|
||||
EmployeeDepartment: employee?.department || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Причина из списка — только у работодателя: у работника по ТЗ вместо причин
|
||||
* текст заявления, и списка причин на форме нет. Иначе в заявке осталась бы
|
||||
* причина, выбранная до смены инициатора, которую никто не видит.
|
||||
*/
|
||||
function employerReason(data: IFormData, reason: string): string | undefined {
|
||||
if (data.ChangeInitiator === INITIATOR_EMPLOYEE) return undefined;
|
||||
return reason || undefined;
|
||||
}
|
||||
|
||||
/** «Сырое» число строкой (сумма или процент) → число для Number-колонки; '' или мусор → undefined. */
|
||||
function numberColumn(raw: string): number | undefined {
|
||||
const amount = Number(raw);
|
||||
return raw.trim() !== '' && Number.isFinite(amount) ? amount : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Колонки вида «Повышение оклада». Вид не выбран — колонки очищаются: иначе
|
||||
* после смены вида в заявке остались бы скрытые поля прежнего, которых никто не видит.
|
||||
*/
|
||||
function salaryIncreaseColumns(data: IFormData): Record<string, unknown> {
|
||||
if (!data.ChangeTypes.includes('salary-increase')) {
|
||||
return {
|
||||
SalaryIncreaseCurrent: null,
|
||||
SalaryIncreaseNew: null,
|
||||
SalaryIncreaseDate: null,
|
||||
SalaryIncreaseReason: null,
|
||||
SalaryIncreaseJustification: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
SalaryIncreaseCurrent: numberColumn(data.SalaryIncreaseCurrent) ?? null,
|
||||
SalaryIncreaseNew: numberColumn(data.SalaryIncreaseNew) ?? null,
|
||||
SalaryIncreaseDate: data.SalaryIncreaseDate || null,
|
||||
SalaryIncreaseReason: data.SalaryIncreaseReason || null,
|
||||
SalaryIncreaseJustification: data.SalaryIncreaseJustification || null
|
||||
};
|
||||
}
|
||||
|
||||
/** Колонки вида «Надбавка / доплата»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
|
||||
function allowanceColumns(data: IFormData): Record<string, unknown> {
|
||||
if (!data.ChangeTypes.includes('allowance')) {
|
||||
return {
|
||||
AllowanceReason: null,
|
||||
AllowanceStartDate: null,
|
||||
AllowanceEndDate: null,
|
||||
AllowanceIndefinite: null,
|
||||
AllowanceAmount: null,
|
||||
AllowanceUnit: null,
|
||||
AllowanceJustification: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
AllowanceReason: data.AllowanceReason || null,
|
||||
AllowanceStartDate: data.AllowanceStartDate || null,
|
||||
// Бессрочно — даты окончания нет, даже если она осталась в состоянии формы.
|
||||
AllowanceEndDate: data.AllowanceIndefinite ? null : data.AllowanceEndDate || null,
|
||||
AllowanceIndefinite: data.AllowanceIndefinite,
|
||||
AllowanceAmount: numberColumn(data.AllowanceAmount) ?? null,
|
||||
AllowanceUnit: data.AllowanceUnit,
|
||||
AllowanceJustification: data.AllowanceJustification || null
|
||||
};
|
||||
}
|
||||
|
||||
/** Колонки вида «Перевод на другую должность»; вид не выбран — очищаются (см. salaryIncreaseColumns). */
|
||||
function positionTransferColumns(data: IFormData): Record<string, unknown> {
|
||||
if (!data.ChangeTypes.includes('position-transfer')) {
|
||||
return {
|
||||
PositionTransferNewPosition: null,
|
||||
PositionTransferDate: null,
|
||||
PositionTransferReason: null,
|
||||
PositionTransferJustification: null
|
||||
};
|
||||
}
|
||||
return {
|
||||
PositionTransferNewPosition: data.PositionTransferNewPosition.trim() || null,
|
||||
PositionTransferDate: data.PositionTransferDate || null,
|
||||
PositionTransferReason: employerReason(data, data.PositionTransferReason) ?? 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> {
|
||||
return {
|
||||
Title: buildTitle(data.Employee?.fio || ''),
|
||||
RequestStatus: status,
|
||||
...employeeColumns(data.Employee),
|
||||
// MultiChoice пишется плоским массивом подписей (как «База 1С» в Ticket1C); [] очищает колонку.
|
||||
ChangeTypes: data.ChangeTypes.map((type) => CHANGE_TYPE_LABELS[type]),
|
||||
ChangeInitiator: hasInitiatorChoice(data.ChangeTypes) ? data.ChangeInitiator || null : null,
|
||||
...salaryIncreaseColumns(data),
|
||||
...allowanceColumns(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;
|
||||
|
||||
/**
|
||||
* Сохранить заявку: содержимое + маршрут согласования.
|
||||
* existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую.
|
||||
* executors — фаза «Исполнение» маршрута из админки (пусто, если её нет).
|
||||
*
|
||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadContractChangeRequest); новая
|
||||
* заявка всегда создаётся в текущей партиции.
|
||||
*/
|
||||
export async function saveContractChangeRequest(
|
||||
existingId: number | undefined,
|
||||
requestTypeId: number,
|
||||
data: IFormData,
|
||||
approvers: IApproverToWrite[],
|
||||
executors: IApproverToWrite[],
|
||||
status: string,
|
||||
listId?: string
|
||||
): Promise<ISaveResult> {
|
||||
const item = buildItem(data, status, await receivingHeadUserId(data));
|
||||
const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item);
|
||||
await writeRoute(contractChangeApprovalService(listId), result.id, approvers, executors, status);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
IEmployeeFullBasePerson,
|
||||
IEmployeeFullBaseRecord,
|
||||
getEmployeeOrganizations,
|
||||
groupEmployeesByPerson,
|
||||
personFromWorkplace,
|
||||
personKey,
|
||||
searchActiveEmployees,
|
||||
workplaceKey
|
||||
} from '../../shared/dictionary';
|
||||
|
||||
// Совпадений по Ф.И.О. немного (тёзки × места работы) — запас с лихвой.
|
||||
const MAX_NAMESAKE_RECORDS = 60;
|
||||
|
||||
/** Человек + выбор его места работы — то, что нужно секции «Сотрудник / Организация». */
|
||||
export interface IContractEmployee {
|
||||
person: IEmployeeFullBasePerson | undefined;
|
||||
onPersonChange: (person: IEmployeeFullBasePerson | undefined) => void;
|
||||
/** key — workplaceKey выбранного места работы. */
|
||||
onWorkplaceChange: (key: string) => void;
|
||||
}
|
||||
|
||||
/** Место работы, если выбирать не из чего (оно одно) — иначе выбирает пользователь. */
|
||||
function singleWorkplace(person: IEmployeeFullBasePerson): IEmployeeFullBaseRecord | undefined {
|
||||
return person.workplaces.length === 1 ? person.workplaces[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Себя — по e-mail. Параметра Email у FullBase нет, поэтому: Ф.И.О. из
|
||||
* Employee/ByEmail → поиск по Ф.И.О. → человек, у кого есть место работы с этим e-mail.
|
||||
*/
|
||||
async function findPersonByEmail(context: WebPartContext, email: string): Promise<IEmployeeFullBasePerson | undefined> {
|
||||
const organizations = await getEmployeeOrganizations(context, email);
|
||||
const fio = organizations[0]?.fio || '';
|
||||
if (!fio) return undefined;
|
||||
const people = groupEmployeesByPerson(await searchActiveEmployees(context, fio, MAX_NAMESAKE_RECORDS));
|
||||
const target = email.toLowerCase();
|
||||
return people.find((p) => p.workplaces.some((w) => w.email.toLowerCase() === target));
|
||||
}
|
||||
|
||||
/** Все места работы человека, чьё одно место сохранено в заявке (черновик/возврат). */
|
||||
async function findPersonOfWorkplace(
|
||||
context: WebPartContext,
|
||||
record: IEmployeeFullBaseRecord
|
||||
): Promise<IEmployeeFullBasePerson | undefined> {
|
||||
const people = groupEmployeesByPerson(await searchActiveEmployees(context, record.fio, MAX_NAMESAKE_RECORDS));
|
||||
return people.find((p) => p.key === personKey(record));
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор сотрудника и его организации, как в «Заявке на командировку»: человек —
|
||||
* пикером, место работы — списком; одно место выбирается само.
|
||||
*
|
||||
* В заявке хранится только выбранное место работы (setEmployee); человек со
|
||||
* всеми местами — состояние формы, при открытии черновика подтягивается заново.
|
||||
*/
|
||||
export function useContractEmployee(
|
||||
context: WebPartContext,
|
||||
applicantEmail: string,
|
||||
isNew: boolean,
|
||||
employee: IEmployeeFullBaseRecord | undefined,
|
||||
setEmployee: (record: IEmployeeFullBaseRecord | undefined) => void
|
||||
): IContractEmployee {
|
||||
const [person, setPerson] = useState<IEmployeeFullBasePerson | undefined>(undefined);
|
||||
// Пользователь уже выбрал сам — фоновые поиски его выбор не перетирают.
|
||||
const isTouchedRef = useRef(false);
|
||||
|
||||
const applyPerson = (next: IEmployeeFullBasePerson | undefined): void => {
|
||||
setPerson(next);
|
||||
setEmployee(next ? singleWorkplace(next) : undefined);
|
||||
};
|
||||
|
||||
// Новая заявка — по умолчанию на себя.
|
||||
useEffect(() => {
|
||||
if (!isNew || !applicantEmail) return;
|
||||
let alive = true;
|
||||
findPersonByEmail(context, applicantEmail)
|
||||
.then((found) => {
|
||||
if (alive && found && !isTouchedRef.current) applyPerson(found);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [context, applicantEmail, isNew]);
|
||||
|
||||
// Черновик/возврат: восстановить список организаций; не нашлось — хотя бы сохранённая.
|
||||
const restoreKey = !isNew && employee && !person ? personKey(employee) : '';
|
||||
useEffect(() => {
|
||||
if (!restoreKey || !employee) return;
|
||||
let alive = true;
|
||||
findPersonOfWorkplace(context, employee)
|
||||
.then((found) => {
|
||||
if (alive && !isTouchedRef.current) setPerson(found ?? personFromWorkplace(employee));
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive && !isTouchedRef.current) setPerson(personFromWorkplace(employee));
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [context, restoreKey]);
|
||||
|
||||
return {
|
||||
person,
|
||||
onPersonChange: (next) => {
|
||||
isTouchedRef.current = true;
|
||||
applyPerson(next);
|
||||
},
|
||||
onWorkplaceChange: (key) => {
|
||||
setEmployee(person?.workplaces.find((w) => workplaceKey(w) === key));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback } from 'react';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import {
|
||||
ApprovalListService,
|
||||
IApproverToWrite,
|
||||
IMatrixRow,
|
||||
IUseMatrixEditor,
|
||||
getPlannedMatrix,
|
||||
isOpenedRouteReady,
|
||||
loadRoutePhase,
|
||||
useFormExecutors,
|
||||
useMatrixEditor
|
||||
} from '../../shared/approval';
|
||||
import { withReceivingHead } from './byType/DepartmentTransfer/receivingHead';
|
||||
|
||||
export interface IContractRouteSource {
|
||||
context: WebPartContext;
|
||||
approval: ApprovalListService;
|
||||
requestTypeId: number | undefined;
|
||||
/** По кому строится маршрут: сотрудник, без учётки — заявитель (см. routeEmail). */
|
||||
routeUserEmail: string;
|
||||
editId: number | undefined;
|
||||
requestStatus: string | undefined;
|
||||
readOnly: boolean;
|
||||
/**
|
||||
* Принимающий руководитель (перевод в другое подразделение) — ставится первым
|
||||
* шагом согласования, см. receivingHead.ts. '' — шага нет (другой вид / не выбран).
|
||||
*/
|
||||
receivingHeadEmail: string;
|
||||
}
|
||||
|
||||
export interface IContractRoute {
|
||||
matrix: IUseMatrixEditor;
|
||||
executors: IUseMatrixEditor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Маршрут формы: согласование + исполнение. Новая заявка — планируемый маршрут
|
||||
* из админки, открытая — уже записанные строки (loadRoutePhase). Пока маршрут
|
||||
* правится, в него вставляется принимающий руководитель — approval-route строит
|
||||
* маршрут по сотруднику и найти руководителя нового подразделения не может.
|
||||
*/
|
||||
export function useContractRoute(source: IContractRouteSource): IContractRoute {
|
||||
const { context, approval, requestTypeId, routeUserEmail, editId, requestStatus, readOnly, receivingHeadEmail } =
|
||||
source;
|
||||
|
||||
const loadMatrix = useCallback(async (): Promise<IMatrixRow[]> => {
|
||||
const rows = await loadRoutePhase(approval, editId, requestStatus, 'Approval', () =>
|
||||
getPlannedMatrix(context, requestTypeId as number, routeUserEmail, approval, 'Approval')
|
||||
);
|
||||
if (readOnly) return rows;
|
||||
const head = receivingHeadEmail
|
||||
? { email: receivingHeadEmail, name: await approval.resolveUserName(receivingHeadEmail) }
|
||||
: undefined;
|
||||
return withReceivingHead(rows, head);
|
||||
}, [context, approval, editId, requestStatus, routeUserEmail, requestTypeId, readOnly, receivingHeadEmail]);
|
||||
|
||||
const matrix = useMatrixEditor(
|
||||
loadMatrix,
|
||||
// Смена принимающего руководителя пересобирает маршрут, как и смена сотрудника.
|
||||
`${routeUserEmail}-${editId ?? 'new'}-${receivingHeadEmail}`,
|
||||
!readOnly,
|
||||
isOpenedRouteReady(editId, requestStatus, requestTypeId) && (!!editId || (!!routeUserEmail && !!requestTypeId)),
|
||||
(login) => approval.getJobTitle(login)
|
||||
);
|
||||
|
||||
const executors = useFormExecutors({
|
||||
readOnly,
|
||||
context,
|
||||
approval,
|
||||
requestTypeId,
|
||||
userEmail: routeUserEmail,
|
||||
editId,
|
||||
requestStatus
|
||||
});
|
||||
|
||||
return { matrix, executors };
|
||||
}
|
||||
|
||||
/** Строки матрицы → согласующие для записи в список (SLA и прочее — транзитом из админки). */
|
||||
export function matrixToApprovers(rows: IMatrixRow[]): IApproverToWrite[] {
|
||||
return rows.map((r) => ({
|
||||
email: r.email,
|
||||
position: r.position,
|
||||
allowReassign: r.allowReassign,
|
||||
order: r.order,
|
||||
slaHours: r.slaHours,
|
||||
askOverdueReason: r.askOverdueReason,
|
||||
autoApprove: r.autoApprove
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useRef } from 'react';
|
||||
import { IEmployeeFullBasePerson } from '../../shared/dictionary';
|
||||
import { InitiatorRole, hasInitiatorChoice } from './changeTypes';
|
||||
import { IFormData, SetField, defaultInitiatorRole } from './model';
|
||||
import { IContractEmployee } from './useContractEmployee';
|
||||
|
||||
/** Пропсы шапки (RequestSection), связанные с сотрудником и инициатором. */
|
||||
export interface IInitiatorAutofill {
|
||||
/** Тот же выбор сотрудника, но со сменой инициатора вслед за сотрудником. */
|
||||
employeeControl: IContractEmployee;
|
||||
/** Инициатор по умолчанию при выборе вида — для текущего сотрудника. */
|
||||
defaultInitiator: InitiatorRole;
|
||||
/** Ручной выбор инициатора — после него автоподстановка больше не вмешивается. */
|
||||
onInitiatorChange: (role: InitiatorRole | '') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициатор следует за сотрудником, пока его не выбрали руками: сменили
|
||||
* сотрудника на себя — «Работник», на другого — «Работодатель» (см.
|
||||
* defaultInitiatorRole). Без этого инициатор, подставленный при выборе вида,
|
||||
* оставался бы прежним после смены сотрудника.
|
||||
*
|
||||
* У открытой заявки (isEdit) инициатор уже сохранён — считаем его выбранным
|
||||
* руками и не перебиваем.
|
||||
*/
|
||||
export function useInitiatorAutofill(
|
||||
isEdit: boolean,
|
||||
data: IFormData,
|
||||
set: SetField,
|
||||
applicantEmail: string,
|
||||
employeeControl: IContractEmployee
|
||||
): IInitiatorAutofill {
|
||||
const isChosenByHandRef = useRef(isEdit);
|
||||
|
||||
const onPersonChange = (person: IEmployeeFullBasePerson | undefined): void => {
|
||||
employeeControl.onPersonChange(person);
|
||||
if (!isChosenByHandRef.current && hasInitiatorChoice(data.ChangeTypes)) {
|
||||
set('ChangeInitiator', defaultInitiatorRole(person, applicantEmail));
|
||||
}
|
||||
};
|
||||
|
||||
const onInitiatorChange = (role: InitiatorRole | ''): void => {
|
||||
isChosenByHandRef.current = true;
|
||||
set('ChangeInitiator', role);
|
||||
};
|
||||
|
||||
return {
|
||||
employeeControl: { ...employeeControl, onPersonChange },
|
||||
defaultInitiator: defaultInitiatorRole(employeeControl.person, applicantEmail),
|
||||
onInitiatorChange
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
RequestNumber,
|
||||
SubmitActions,
|
||||
getDraftDeleteHandler,
|
||||
useRequestSubmit
|
||||
useRequestSubmit,
|
||||
FormNotice
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
composeRemoveRowRule,
|
||||
@@ -44,7 +45,16 @@ import {
|
||||
dataListTitle,
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, IOrganizationRecord, primaryPosition } from './model';
|
||||
import {
|
||||
FORM_ID,
|
||||
IFormData,
|
||||
EMPTY_DATA,
|
||||
SetField,
|
||||
IOrganizationRecord,
|
||||
primaryPosition,
|
||||
NOTICE_TITLE,
|
||||
NOTICE_ITEMS
|
||||
} from './model';
|
||||
import { saveDismissalRequest, dismissalApprovalService } from './submit';
|
||||
import { loadDismissalRequest } from './load';
|
||||
import { RequestSection } from './sections/RequestSection';
|
||||
@@ -374,6 +384,8 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||
)}
|
||||
{submit.error && <Alert variant="err">{submit.error}</Alert>}
|
||||
|
||||
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||
|
||||
<FormModeProvider readOnly={readOnly}>
|
||||
<RequestSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './DismissalView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, FormNotice, buildPrintTitle } from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
REQUEST_STATUS,
|
||||
approvalPhaseRows,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
dataListTitleForListId
|
||||
} from '../../shared/listTitles';
|
||||
import { parseRoute } from '../../shared/routing';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField, NOTICE_TITLE, NOTICE_ITEMS } from './model';
|
||||
import { loadDismissalRequest } from './load';
|
||||
import { dismissalApprovalService, updateAssetsAcceptor } from './submit';
|
||||
import { loadClearanceRequest } from '../Clearance/load';
|
||||
@@ -57,6 +58,8 @@ interface IDismissalViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявление на увольнение';
|
||||
|
||||
// Заявление на увольнение только для просмотра (режим согласующего).
|
||||
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
|
||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||
@@ -69,6 +72,8 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
// ФИО увольняющегося — для имени печатного документа.
|
||||
const [employeeName, setEmployeeName] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Итог автосоздания обходного листа после согласования (см. onDecided ниже).
|
||||
@@ -106,6 +111,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
setEmployeeName(loaded.employeeName);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -218,16 +224,19 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
await updateAssetsAcceptor(itemId, data.AssetsAcceptor, listId);
|
||||
};
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Заявление согласовано целиком → сразу заводим обходной лист по нему (данные
|
||||
@@ -261,7 +270,9 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
{printGate === 'allowed' ? (
|
||||
<PrintButton />
|
||||
<PrintButton
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению">
|
||||
Печать станет доступна после согласования обходного листа
|
||||
@@ -271,7 +282,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Заявление на увольнение"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
@@ -281,6 +292,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
}
|
||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||
>
|
||||
<FormNotice title={NOTICE_TITLE} items={NOTICE_ITEMS} />
|
||||
<FormModeProvider readOnly>
|
||||
<RequestSection {...sectionProps} />
|
||||
</FormModeProvider>
|
||||
@@ -311,7 +323,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
// «Согласовано» = маршрут заявления пройден целиком (фазы исполнения у
|
||||
// него нет, см. README формы) — значит увольнение подтверждено и пора
|
||||
// заводить обходной лист. Промис ВОЗВРАЩАЕМ: пока он не разрешится,
|
||||
|
||||
@@ -61,6 +61,8 @@ RequestTypeId» в `docs/forms.md`). Тип заведён в админке app
|
||||
- **Комментарий** (`Comment`) — необязательный свободный текст. Причины увольнения
|
||||
отдельного поля нет: в шаблоне формулировка фиксированная («по собственному желанию»).
|
||||
- **Заголовок элемента** (`Title`): `Заявление на увольнение — {ФИО} — дд.мм.гггг`.
|
||||
- **Примечание** (с 10-29, `FormNotice`, тексты `NOTICE_TITLE`/`NOTICE_ITEMS` в `model.ts`) — плашка
|
||||
вверху формы и просмотра про срок уведомления по ст. 56 ТК РК. Текст от HR, дословно; на печать не идёт.
|
||||
|
||||
## Совместители: организации таблицей, удаление синхронизировано с маршрутом
|
||||
Сотрудник может числиться сразу в НЕСКОЛЬКИХ компаниях (совместительство).
|
||||
|
||||
@@ -149,3 +149,18 @@ export const EMPTY_DATA: IFormData = {
|
||||
export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. сотрудника…';
|
||||
/** Заявление нужно подавать не позднее чем за столько месяцев до последнего рабочего дня. */
|
||||
export const MIN_NOTICE_MONTHS = 1;
|
||||
|
||||
/**
|
||||
* Примечание вверху формы и просмотра (см. shared/ui/FormNotice). Текст — от HR,
|
||||
* дословно. «Не менее чем за один месяц» — это MIN_NOTICE_MONTHS: поменяли срок —
|
||||
* поправьте и текст.
|
||||
*/
|
||||
export const NOTICE_TITLE = 'Примечание';
|
||||
export const NOTICE_ITEMS: string[] = [
|
||||
'Срок уведомления. Согласно п. 1 ст. 56 Трудового кодекса РК, работник вправе по своей инициативе ' +
|
||||
'расторгнуть трудовой договор, уведомив работодателя письменно не менее чем за один месяц, за исключением ' +
|
||||
'случаев, предусмотренных п. 3 этой статьи. Если трудовым договором установлен более длительный срок ' +
|
||||
'уведомления, применяется срок из трудового договора.',
|
||||
'Увольнение до истечения срока уведомления. Дата увольнения раньше чем через месяц с даты подачи заявления ' +
|
||||
'возможна только по согласованию с работодателем, либо в случаях, предусмотренных п. 3 ст. 56 ТК РК.'
|
||||
];
|
||||
|
||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './EmployeeTransferView.module.scss';
|
||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
import {
|
||||
Alert,
|
||||
FormCard,
|
||||
InitiatorLine,
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
DecisionPanel,
|
||||
@@ -14,6 +22,7 @@ import {
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId
|
||||
} from '../../shared/approval';
|
||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
@@ -40,6 +49,8 @@ interface IEmployeeTransferViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявка на перемещение сотрудника';
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
@@ -55,6 +66,8 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
// ФИО перемещаемого — для имени печатного документа.
|
||||
const [employeeName, setEmployeeName] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
||||
@@ -88,6 +101,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
setData(loaded.data);
|
||||
setStatus(loaded.status);
|
||||
setCode(loaded.code);
|
||||
setEmployeeName(loaded.employeeName);
|
||||
notifyCode(loaded.code);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
@@ -117,28 +131,36 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Заявка на перемещение сотрудника"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
@@ -172,7 +194,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { AttachmentList, FileDropzone, FormField } from '../../shared/ui';
|
||||
import { IAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
|
||||
import { attachmentsOfCategory, categoryPrefix, uncategorizedAttachments } from './attachmentCategories';
|
||||
import styles from './HireAttachmentField.module.scss';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
editor: IAttachmentEditor;
|
||||
editable: boolean;
|
||||
/** Группа новых файлов в editor.pending. */
|
||||
groupKey: string;
|
||||
/** Категория в имени файла; не задана — «прочие» вложения без префикса. */
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/** Поле одной категории вложений: прикреплённые файлы + (в правке) выбор новых. */
|
||||
export const HireAttachmentField: React.FC<Props> = ({ label, editor, editable, groupKey, category }) => {
|
||||
const items = category ? attachmentsOfCategory(editor.items, category) : uncategorizedAttachments(editor.items);
|
||||
// В списке имена показаны без префикса категории — для удаления возвращаем его.
|
||||
const removeWithPrefix = (name: string): void => editor.remove(category ? categoryPrefix(category) + name : name);
|
||||
|
||||
return (
|
||||
<FormField label={label} full>
|
||||
{items.length > 0 && <AttachmentList items={items} onRemove={editable ? removeWithPrefix : undefined} />}
|
||||
{items.length === 0 && !editable && <span className={styles.empty}>—</span>}
|
||||
{editable && (
|
||||
<FileDropzone files={editor.pending[groupKey] || []} onChange={(files) => editor.setPending(groupKey, files)} />
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, CollapseSection } from '../../shared/ui';
|
||||
import { IAttachmentEditor, PrepareFiles } from '../../shared/attachments/useAttachmentEditor';
|
||||
import { AttachmentSlotField } from '../../shared/attachments/AttachmentSlotField';
|
||||
import { ATT_CATEGORY_ID, ATT_CATEGORY_RESUME, prefixFiles, uncategorizedAttachments } from './attachmentCategories';
|
||||
import { HireAttachmentField } from './HireAttachmentField';
|
||||
|
||||
/**
|
||||
* Группы новых файлов → категория в имени файла (см. attachmentCategories).
|
||||
@@ -37,9 +37,16 @@ export const HireAttachments: React.FC<Props> = ({ editor, editable }) => {
|
||||
return (
|
||||
<CollapseSection title="Вложения" icon="upload">
|
||||
{editor.error && <Alert variant="err">{editor.error}</Alert>}
|
||||
<HireAttachmentField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
|
||||
<HireAttachmentField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
|
||||
{showOther && <HireAttachmentField label="Прочие вложения" groupKey={GROUP_OTHER} {...fieldProps} />}
|
||||
<AttachmentSlotField label="Удостоверение" groupKey={GROUP_ID} category={ATT_CATEGORY_ID} {...fieldProps} />
|
||||
<AttachmentSlotField label="Резюме" groupKey={GROUP_RESUME} category={ATT_CATEGORY_RESUME} {...fieldProps} />
|
||||
{showOther && (
|
||||
<AttachmentSlotField
|
||||
label="Прочие вложения"
|
||||
groupKey={GROUP_OTHER}
|
||||
knownCategories={[ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]}
|
||||
{...fieldProps}
|
||||
/>
|
||||
)}
|
||||
</CollapseSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
PrintButton,
|
||||
FieldChangesProvider,
|
||||
FieldChangesBanner,
|
||||
issueLabels
|
||||
issueLabels,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||
import { HIRE_CHANGE_TRACKING } from './changeTracking';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
APPROVAL_PHASE,
|
||||
isApprovedStatus,
|
||||
@@ -86,6 +88,7 @@ const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.
|
||||
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
|
||||
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
|
||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||
const FORM_TITLE = 'Заявка на прием';
|
||||
|
||||
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
|
||||
// те же секции, что и форма подачи; редактирование выключено через
|
||||
@@ -283,13 +286,16 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
);
|
||||
|
||||
// Выше ранних return (загрузка/ошибка): зовётся и из колбэков хуков ниже.
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Добавить согласующего/исполнителя в поданную заявку — то же право
|
||||
@@ -378,7 +384,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
<Icon name="copy" size="sm" />
|
||||
Организация рабочего места на основе этой
|
||||
</Button>
|
||||
{isApprovedStatus(status) && <PrintButton />}
|
||||
{isApprovedStatus(status) && (
|
||||
<PrintButton
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.HireCandidateName, code, itemId })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasEditableFields && (
|
||||
@@ -396,7 +406,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Заявка на прием"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
@@ -448,6 +458,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
printable={false}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
@@ -479,7 +490,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
// Очередь сдвинулась — уведомляем следующий активный шаг.
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
// Шаг переназначен: активной стала строка делегата — обновляем
|
||||
|
||||
@@ -1,50 +1,24 @@
|
||||
import { IAttachmentInfo } from '../../shared/attachments/attachments.service';
|
||||
import { attachmentsWithoutCategories } from '../../shared/attachments/attachmentCategories';
|
||||
|
||||
/**
|
||||
* Категории вложений заявки на прием. Вложения SharePoint — плоская коллекция
|
||||
* файлов на элементе списка, «категории» у них нет. Поэтому категорию кодируем
|
||||
* ПРЕФИКСОМ в имени файла: «<Категория> — <исходное имя>». При загрузке имя
|
||||
* файла переписываем, при показе — группируем по префиксу и префикс срезаем.
|
||||
* Категории вложений заявки на прием. Механизм подписи префиксом в имени файла —
|
||||
* общий (shared/attachments/attachmentCategories); здесь только категории этой
|
||||
* формы. Общие функции реэкспортируются, чтобы импорты формы не менялись.
|
||||
*/
|
||||
export const ATT_CATEGORY_ID = 'Удостоверение';
|
||||
export const ATT_CATEGORY_RESUME = 'Резюме';
|
||||
|
||||
const SEP = ' — ';
|
||||
|
||||
/** Префикс имени файла для категории: «Удостоверение — ». */
|
||||
export function categoryPrefix(category: string): string {
|
||||
return `${category}${SEP}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Копии File с префиксом категории в имени — для загрузки. File.name неизменяемо,
|
||||
* поэтому создаём новый File с тем же содержимым и типом, но другим именем.
|
||||
*/
|
||||
export function prefixFiles(files: File[], category: string): File[] {
|
||||
const prefix = categoryPrefix(category);
|
||||
return files.map((f) => new File([f], prefix + f.name, { type: f.type }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Вложения указанной категории, с ОБРЕЗАННЫМ префиксом в имени (для показа).
|
||||
* url сохраняется как есть; чтобы удалить такой файл, префикс надо вернуть
|
||||
* обратно (см. categoryPrefix в обработчике удаления формы).
|
||||
*/
|
||||
export function attachmentsOfCategory(items: IAttachmentInfo[], category: string): IAttachmentInfo[] {
|
||||
const prefix = categoryPrefix(category);
|
||||
return items
|
||||
.filter((a) => a.fileName.indexOf(prefix) === 0)
|
||||
.map((a) => ({ url: a.url, fileName: a.fileName.slice(prefix.length) }));
|
||||
}
|
||||
export {
|
||||
categoryPrefix,
|
||||
prefixFiles,
|
||||
attachmentsOfCategory
|
||||
} from '../../shared/attachments/attachmentCategories';
|
||||
|
||||
/**
|
||||
* Вложения без известной категории (старые заявки, загруженные до разделения).
|
||||
* Показываем как есть, чтобы файлы не «исчезали» с экрана.
|
||||
*/
|
||||
export function uncategorizedAttachments(items: IAttachmentInfo[]): IAttachmentInfo[] {
|
||||
const idPrefix = categoryPrefix(ATT_CATEGORY_ID);
|
||||
const resumePrefix = categoryPrefix(ATT_CATEGORY_RESUME);
|
||||
return items.filter(
|
||||
(a) => a.fileName.indexOf(idPrefix) !== 0 && a.fileName.indexOf(resumePrefix) !== 0
|
||||
);
|
||||
return attachmentsWithoutCategories(items, [ATT_CATEGORY_ID, ATT_CATEGORY_RESUME]);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
FormModeProvider,
|
||||
PrintButton,
|
||||
FieldChangesProvider,
|
||||
FieldChangesBanner
|
||||
FieldChangesBanner,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||
import { SELECTION_CHANGE_TRACKING } from './changeTracking';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
isApprovedStatus,
|
||||
resolveApproverOrgInfo,
|
||||
@@ -79,6 +81,7 @@ interface IRecruitingSelectionViewProps {
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||
const FORM_TITLE = 'Заявка на подбор';
|
||||
|
||||
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
||||
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
|
||||
@@ -204,20 +207,25 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
await saveAllFields();
|
||||
};
|
||||
|
||||
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
||||
const notify = (rowId?: number): void => {
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(
|
||||
context,
|
||||
approval,
|
||||
itemId,
|
||||
{
|
||||
// Куда смотрит поток оповещения; undefined — тип заявки ещё не резолвился.
|
||||
const notifyTarget = requestTypeId
|
||||
? {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
},
|
||||
rowId
|
||||
).catch(() => undefined);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// rowId — оповестить конкретную активную строку (см. notifyActiveStep).
|
||||
const notify = (rowId?: number): void => {
|
||||
if (!notifyTarget) return;
|
||||
notifyActiveStep(context, approval, itemId, notifyTarget, rowId).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
const notifyInitiator = (newStatus: string): void => {
|
||||
if (!notifyTarget) return;
|
||||
notifyInitiatorOfReturn(context, approval, itemId, notifyTarget, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Удалить ещё не решённый шаг маршрута (см. ApproverService.deleteQueuedStep).
|
||||
@@ -360,7 +368,10 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
{isApprovedStatus(status) && <PrintButton />}
|
||||
{/* Подбор — на вакансию, конкретного человека ещё нет: имя файла без ФИО. */}
|
||||
{isApprovedStatus(status) && (
|
||||
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: '', code, itemId })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fullEditable && (
|
||||
@@ -378,7 +389,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
|
||||
<div className="rp-print-root">
|
||||
<FormCard
|
||||
title="Заявка на подбор"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
@@ -432,6 +443,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
printable={false}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
@@ -467,6 +479,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notifyInitiator(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -207,28 +207,6 @@ export const LANGUAGE_LEVEL_OPTIONS: ISelectOption[] = [
|
||||
{ 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). */
|
||||
export const WORKPLACE_PROVISION_OPTIONS: ISelectOption[] = [
|
||||
{ value: 'Оргтехника', label: 'Оргтехника' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { CollapseSection, FormField, TextInput, TextArea, Select, MultiChoiceField } from '../../../shared/ui';
|
||||
import { PositionPicker } from '../../../shared/dictionary';
|
||||
import { PositionPicker, SOFTWARE_OPTIONS } from '../../../shared/dictionary';
|
||||
import { LanguagesField } from './LanguagesField';
|
||||
import {
|
||||
ISectionProps,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
EDUCATION_OPTIONS,
|
||||
WORK_EXPERIENCE_OPTIONS,
|
||||
RESIDENCE_OPTIONS,
|
||||
SOFTWARE_OPTIONS,
|
||||
OTHER_VALUE
|
||||
} from '../model';
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
FormField,
|
||||
AttachmentList,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead
|
||||
OrgLetterhead,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import {
|
||||
ApproverService,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
ApprovalHistory,
|
||||
useMatrixEditor,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
APPROVAL_PHASE,
|
||||
isApprovedStatus,
|
||||
@@ -59,6 +61,8 @@ interface IReferralViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Реферальная рекомендация';
|
||||
|
||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||
const NOOP_SET: SetField = () => undefined;
|
||||
|
||||
@@ -169,30 +173,38 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
||||
|
||||
const sectionProps = { data, set: NOOP_SET, context, printOrgIndex };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
// requestTypeId ещё не резолвился — решение уже применилось (строка списка
|
||||
// обновлена), просто нечего уведомлять на следующем шаге; не проваливаем
|
||||
// весь UI ради необязательного оповещения.
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${styles.toolbar} rp-no-print`}>
|
||||
{isApprovedStatus(status) && (
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.CandidateName, code, itemId })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Реферальная рекомендация"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
@@ -230,6 +242,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
||||
{!executors.loading && executors.rows.length > 0 && (
|
||||
<ApprovalMatrix
|
||||
editor={executors}
|
||||
printable={false}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
title="Исполнение"
|
||||
roleLabel="Исполнитель"
|
||||
@@ -253,7 +266,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
FormModeProvider,
|
||||
PrintSplitButton,
|
||||
OrgLetterhead,
|
||||
Icon
|
||||
Icon,
|
||||
buildPrintTitle
|
||||
} from '../../shared/ui';
|
||||
import { navigate } from '../../shared/routing';
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
ExecutorsMatrix,
|
||||
approvalPhaseRows,
|
||||
notifyActiveStep,
|
||||
notifyInitiatorOfReturn,
|
||||
useRequestTypeId,
|
||||
isEditableStatus
|
||||
} from '../../shared/approval';
|
||||
@@ -56,6 +58,8 @@ interface IServiceRequestViewProps {
|
||||
onCodeLoaded?: (code: string) => void;
|
||||
}
|
||||
|
||||
const FORM_TITLE = 'Заявка на услуги';
|
||||
|
||||
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
|
||||
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
|
||||
@@ -141,13 +145,16 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
|
||||
const sectionProps = { data, set, context, printOrgIndex, attachmentUrls };
|
||||
|
||||
const notify = (): void => {
|
||||
const notify = (newStatus?: string): void => {
|
||||
if (!requestTypeId) return;
|
||||
notifyActiveStep(context, approval, itemId, {
|
||||
const target = {
|
||||
requestTypeId,
|
||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID)
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -162,13 +169,18 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
||||
<PrintSplitButton
|
||||
value={data.Organization}
|
||||
selectedIndex={printOrgIndex}
|
||||
onChange={setPrintOrgIndex}
|
||||
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.RequesterName, code, itemId })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rp-print-root">
|
||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||
<FormCard
|
||||
title="Заявка на услуги"
|
||||
title={FORM_TITLE}
|
||||
subtitle={`№${code || itemId}`}
|
||||
belowSubtitle={
|
||||
<InitiatorLine
|
||||
@@ -205,7 +217,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
notify(newStatus);
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user