Compare commits
8 Commits
14ac640c50
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e34d290865 | |||
| b1060433d0 | |||
| cf039f4d08 | |||
| 88ce1d498e | |||
| 3495e681e7 | |||
| 583f400e26 | |||
| 36432c75e6 | |||
| 45fc06a6ac |
@@ -30,6 +30,10 @@
|
|||||||
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
|
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
"scope": "Reports.Read"
|
"scope": "Reports.Read"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"resource": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"scope": "Logs.Write"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"resource": "Microsoft Flow Service",
|
"resource": "Microsoft Flow Service",
|
||||||
"scope": "Flows.Read.All"
|
"scope": "Flows.Read.All"
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
| Править **все поля и вложения** заявки (добавлять и удалять файлы) на своём активном шаге согласования | Алтынай Бултурукова | email в коде (список по каждой форме) | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` |
|
||||||
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
|
| То же: **все поля и вложения**, но **не только на своём шаге**: достаточно быть в маршруте (согласование или исполнение), пока заявка «На согласовании» или «Выполнение» | Ербол Шаймерденов | email в коде (список по каждой форме) + участие в маршруте | **Только** Организация рабочего места | `shared/approval/fullEditRights.ts` → `canEditAllFields` + `forms/WorkplaceSetup/useApproverRights.ts` |
|
||||||
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
|
| Заменять человека на шаге согласования (не удалять шаг) **до отправки**: новая заявка, черновик, доработка | Любой инициатор | право по статусу заявки (форма редактируема) | **Только** Организация рабочего места | `forms/WorkplaceSetup/WorkplaceSetupForm.tsx` (`canReplaceRow`) + `useMatrixEditor.replace` |
|
||||||
| Заполнять «Необходимое ПО» и «Доступы» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
|
| Заполнять «Необходимое ПО», «Доступы» и «Мебель» на своём шаге согласования | Первый шаг маршрута согласования (по маршруту — непосредственный руководитель) | порядок шага в маршруте | Организация рабочего места | `forms/WorkplaceSetup/useApproverRights.ts` (`canEditReviewFields`) |
|
||||||
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
| Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` |
|
||||||
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
|
| Ставить дату приёма (2.2) | Активный исполнитель | роль в маршруте (шаг «Исполнение»), без проверки должности | Заявка на приём | `forms/RecruitingHire/RecruitingHireView.tsx` (`hireDateEditable`) |
|
||||||
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
|
| Править карту СКУД и сумму за обучение | Начальник отдела кадрового делопроизводства | должность шага: «начальник/руководитель» + «кадров» + «делопроизводств» | Обходной лист | `forms/Clearance/approverRights.ts` → `canEditHrRecordsFields` |
|
||||||
|
|||||||
Generated
+136
-28
@@ -17,7 +17,9 @@
|
|||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.14.0",
|
"@pnp/telemetry-js": "2.0.0",
|
||||||
|
"@tnsu/monitoring-web": "^0.3.4",
|
||||||
|
"@tnsu/ui-kit": "^0.15.0",
|
||||||
"jszip": "~3.8.0",
|
"jszip": "~3.8.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
@@ -341,7 +343,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -351,7 +353,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
@@ -382,7 +384,7 @@
|
|||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -408,7 +410,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.29.7",
|
"@babel/compat-data": "^7.29.7",
|
||||||
@@ -425,7 +427,7 @@
|
|||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -457,7 +459,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.29.7",
|
"@babel/helper-module-imports": "^7.29.7",
|
||||||
@@ -503,7 +505,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -513,7 +515,7 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.29.7",
|
"@babel/template": "^7.29.7",
|
||||||
@@ -3979,7 +3981,7 @@
|
|||||||
"version": "2.3.5",
|
"version": "2.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
@@ -8726,6 +8728,28 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rrweb/rrweb-plugin-console-record": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rrweb/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-2c6LEsoH3+1NFcHckcwuErg7bkpFadt9++uKJh2x9BtWUDxoVKzLp+qLCq6f90MLTjSIABDkAiCswVskENZT3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@rrweb/utils": "^2.1.1",
|
||||||
|
"rrweb": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rrweb/types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-2gTA/pRpIbuET123oSdX5ZUDZfWCOIMYBvpSai3fr5pk9LcbccPqqaXflX+roP4oKenLjNtJPGpFK3BfYeBKnA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@rrweb/utils": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-e8FEp8wLswqgjDHpQNUUVfEXeDsrRMOVouvqIGKvnPxKYonjLlh0mU3O/17QNvCixFG5eUFR1B2jgTdiLlZxeQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rushstack/debug-certificate-manager": {
|
"node_modules/@rushstack/debug-certificate-manager": {
|
||||||
"version": "1.6.2",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz",
|
||||||
@@ -9885,10 +9909,32 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tnsu/monitoring-web": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fmonitoring-web/-/0.3.4/monitoring-web-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-tQe0ZM1tBVO3XtbNFliRaaooqvCX3gb/Q/j2lV6+rYCXprodBZByQ6PLrCu/P0me/rW8KdQ02hu7avyLXXfkoQ==",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"dependencies": {
|
||||||
|
"@rrweb/rrweb-plugin-console-record": "^2.2.0",
|
||||||
|
"rrweb": "^2.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vue": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tnsu/ui-kit": {
|
"node_modules/@tnsu/ui-kit": {
|
||||||
"version": "0.14.0",
|
"version": "0.15.0",
|
||||||
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.14.0/ui-kit-0.14.0.tgz",
|
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.15.0/ui-kit-0.15.0.tgz",
|
||||||
"integrity": "sha512-Vun9AUM9jL8rttbsbKo5Yh6TUz1srkNqxiO6mmzebrSo1JOASNJqNjJPMTS+8Jt3MTrJPklvh0TbeqGQzw7qxQ==",
|
"integrity": "sha512-vs+7NPem7PDaYj00nwaFztkw2K/nOkw6m2bvqgMABZG5rWjLVqfyFyiopKFkPYgqPWcMPwmQuxoFnc9xi7rwjQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@fluentui/react": "^8.106.4",
|
"@fluentui/react": "^8.106.4",
|
||||||
@@ -10022,6 +10068,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/css-font-loading-module": {
|
||||||
|
"version": "0.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
|
||||||
|
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ejs": {
|
"node_modules/@types/ejs": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
|
||||||
@@ -10250,7 +10302,7 @@
|
|||||||
"version": "26.1.2",
|
"version": "26.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~8.3.0"
|
"undici-types": "~8.3.0"
|
||||||
@@ -10279,6 +10331,7 @@
|
|||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
@@ -10308,6 +10361,7 @@
|
|||||||
"version": "17.0.45",
|
"version": "17.0.45",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.45.tgz",
|
||||||
"integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==",
|
"integrity": "sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -10319,6 +10373,7 @@
|
|||||||
"version": "17.0.17",
|
"version": "17.0.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.17.tgz",
|
||||||
"integrity": "sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg==",
|
"integrity": "sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react": "^17"
|
"@types/react": "^17"
|
||||||
@@ -10341,6 +10396,7 @@
|
|||||||
"version": "0.26.0",
|
"version": "0.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
|
||||||
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
|
"integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/send": {
|
"node_modules/@types/send": {
|
||||||
@@ -11627,6 +11683,12 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xstate/fsm": {
|
||||||
|
"version": "1.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
|
||||||
|
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@xtuc/ieee754": {
|
"node_modules/@xtuc/ieee754": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||||
@@ -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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -12537,7 +12608,7 @@
|
|||||||
"version": "2.11.7",
|
"version": "2.11.7",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz",
|
||||||
"integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==",
|
"integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.cjs"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
@@ -12711,7 +12782,7 @@
|
|||||||
"version": "4.28.7",
|
"version": "4.28.7",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||||
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
"integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -12920,7 +12991,7 @@
|
|||||||
"version": "1.0.30001806",
|
"version": "1.0.30001806",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -13345,7 +13416,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
@@ -14234,7 +14305,7 @@
|
|||||||
"version": "1.5.398",
|
"version": "1.5.398",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz",
|
||||||
"integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==",
|
"integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/embla-carousel": {
|
"node_modules/embla-carousel": {
|
||||||
@@ -14564,7 +14635,7 @@
|
|||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -15984,7 +16055,7 @@
|
|||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -19133,7 +19204,7 @@
|
|||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"json5": "lib/cli.js"
|
"json5": "lib/cli.js"
|
||||||
@@ -19813,6 +19884,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mitt": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mkdirp": {
|
"node_modules/mkdirp": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||||
@@ -19935,7 +20012,6 @@
|
|||||||
"version": "3.3.16",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -20077,7 +20153,7 @@
|
|||||||
"version": "2.0.51",
|
"version": "2.0.51",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -20742,7 +20818,6 @@
|
|||||||
"version": "8.5.25",
|
"version": "8.5.25",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -22018,6 +22093,40 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rrdom": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gxcBJWVtS/Xz+Ys8xcjPoWqr+C7wz/MS8NgmKw136I47Xygy17FYOGZqoGwEyvBn724jRDK1ljS+8w9FsZStOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"rrweb-snapshot": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rrweb": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-ofVzPq11/yIQ7WhVb++rJGI2LoPPrxdqu0QAvRA4KGeXJ7LH52jQ0VlRH4+VXn5PA/7pz85/rRkQyBUqT1LraQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rrweb/types": "^2.2.0",
|
||||||
|
"@rrweb/utils": "^2.2.0",
|
||||||
|
"@types/css-font-loading-module": "0.0.7",
|
||||||
|
"@xstate/fsm": "^1.4.0",
|
||||||
|
"base64-arraybuffer": "^1.0.1",
|
||||||
|
"mitt": "^3.0.0",
|
||||||
|
"rrdom": "^2.2.0",
|
||||||
|
"rrweb-snapshot": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rrweb-snapshot": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-Tg/PiCLy+zV9C0Jfb4IAGABtt5l/UOvwW5xjFk41ZQhBfUjeqPP1rZ9HMDrFPc7cawCUEenWw54+g/4vluO5lg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postcss": "^8.4.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rtl-css-js": {
|
"node_modules/rtl-css-js": {
|
||||||
"version": "1.16.1",
|
"version": "1.16.1",
|
||||||
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
|
||||||
@@ -23127,7 +23236,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -24407,7 +24515,7 @@
|
|||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicorn-magic": {
|
"node_modules/unicorn-magic": {
|
||||||
@@ -24461,7 +24569,7 @@
|
|||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|||||||
+3
-1
@@ -21,7 +21,9 @@
|
|||||||
"@microsoft/sp-webpart-base": "1.22.1",
|
"@microsoft/sp-webpart-base": "1.22.1",
|
||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.14.0",
|
"@pnp/telemetry-js": "2.0.0",
|
||||||
|
"@tnsu/monitoring-web": "^0.3.4",
|
||||||
|
"@tnsu/ui-kit": "^0.15.0",
|
||||||
"jszip": "~3.8.0",
|
"jszip": "~3.8.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from '@microsoft/sp-property-pane';
|
} from '@microsoft/sp-property-pane';
|
||||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||||
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
||||||
|
import PnPTelemetry from '@pnp/telemetry-js';
|
||||||
|
import { initMonitoring, spfxTokenProvider } from '@tnsu/monitoring-web';
|
||||||
|
|
||||||
import * as strings from 'RequestPortalWebPartStrings';
|
import * as strings from 'RequestPortalWebPartStrings';
|
||||||
import RequestPortal from './components/RequestPortal';
|
import RequestPortal from './components/RequestPortal';
|
||||||
@@ -98,7 +100,14 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected onInit(): Promise<void> {
|
protected onInit(): Promise<void> {
|
||||||
|
// Логи, ошибки, console.error и запись экрана в мониторинг (monitoring.tnsu.kz). Токен — молча через
|
||||||
|
// AAD веб-части (разрешение Logs.Write в package-solution.json); при отладке в workbench окружение — development.
|
||||||
|
// Один на страницу: повторный вызов (несколько веб-частей) библиотека игнорирует сама.
|
||||||
|
initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) });
|
||||||
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
|
installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения
|
||||||
|
// Телеметрия PnP (её включает @pnp/spfx-controls-react) шлёт fetch на pnptelemetryproxy.azurewebsites.net
|
||||||
|
// без catch: где адрес закрыт прокси или блокировщиком, это необработанный отказ промиса в мониторинге.
|
||||||
|
PnPTelemetry.getInstance().optOut();
|
||||||
initSP(this.context); // единый клиент PnPjs для записи в списки
|
initSP(this.context); // единый клиент PnPjs для записи в списки
|
||||||
|
|
||||||
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они
|
// Адрес approval-route должен быть выставлен ДО загрузки партиций — они
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './BusinessTripView.module.scss';
|
import styles from './BusinessTripView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui';
|
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, buildPrintTitle } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
ApprovalMatrix,
|
ApprovalMatrix,
|
||||||
@@ -55,6 +55,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -128,7 +130,9 @@ const BusinessTripView: React.FC<IBusinessTripViewProps> = ({ context, itemId, f
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintButton />
|
<PrintButton
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface ILoadedRequest {
|
|||||||
status: string;
|
status: string;
|
||||||
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
/** Уникальный код заявки; '' — если запись создана до появления поля. */
|
||||||
code: string;
|
code: string;
|
||||||
|
/** ФИО командируемого (Employee/Title) — для имени печатного документа. */
|
||||||
|
employeeName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,11 +39,12 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
|||||||
'TripObligationAccepted',
|
'TripObligationAccepted',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode',
|
'RequestUniqueCode',
|
||||||
'Employee/EMail'
|
'Employee/EMail',
|
||||||
|
'Employee/Title'
|
||||||
)
|
)
|
||||||
.expand('Employee')()) as Record<string, unknown>;
|
.expand('Employee')()) as Record<string, unknown>;
|
||||||
|
|
||||||
const employee = item.Employee as { EMail?: string } | undefined;
|
const employee = item.Employee as { EMail?: string; Title?: string } | undefined;
|
||||||
|
|
||||||
const data: IFormData = {
|
const data: IFormData = {
|
||||||
...EMPTY_DATA,
|
...EMPTY_DATA,
|
||||||
@@ -61,6 +64,7 @@ export async function loadBusinessTripRequest(itemId: number, listId?: string):
|
|||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
status: (item.RequestStatus as string) || '',
|
status: (item.RequestStatus as string) || '',
|
||||||
code: (item.RequestUniqueCode as string) || ''
|
code: (item.RequestUniqueCode as string) || '',
|
||||||
|
employeeName: employee?.Title || ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './ClearanceView.module.scss';
|
import styles from './ClearanceView.module.scss';
|
||||||
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
buildPrintTitle
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -48,6 +57,8 @@ interface IClearanceViewProps {
|
|||||||
|
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Обходной лист';
|
||||||
|
|
||||||
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
|
// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые
|
||||||
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
||||||
// отдел подбора — парковочная карта (см. approverRights.ts).
|
// отдел подбора — парковочная карта (см. approverRights.ts).
|
||||||
@@ -60,6 +71,8 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
// ФИО увольняемого — для имени печатного документа.
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
|
const [hrRecordsEditable, setHrRecordsEditable] = useState<boolean>(false);
|
||||||
@@ -123,6 +136,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -274,7 +288,12 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditAnything && (
|
{canEditAnything && (
|
||||||
@@ -295,7 +314,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Обходной лист"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ import {
|
|||||||
dataListTitle,
|
dataListTitle,
|
||||||
dataListTitleForListId
|
dataListTitleForListId
|
||||||
} from '../../shared/listTitles';
|
} 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 { saveConcreteRequest, concreteRequestApprovalService } from './submit';
|
||||||
import { loadConcreteRequest } from './load';
|
import { loadConcreteRequest } from './load';
|
||||||
import { HeaderSection } from './sections/HeaderSection';
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
@@ -167,6 +168,19 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Справочник БСУ нужен только при заполнении — в режиме чтения показываем сохранённые названия.
|
||||||
|
const bsu = useBsuCatalog(context, data.BsuId, !readOnly);
|
||||||
|
|
||||||
|
// Рецепты позиций принадлежат конкретному заводу — при смене БСУ сбрасываем их.
|
||||||
|
const handleBsuChange = (bsuId: string, bsuName: string): void => {
|
||||||
|
setData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
BsuId: bsuId,
|
||||||
|
BsuName: bsuName,
|
||||||
|
Items: bsuId === prev.BsuId ? prev.Items : prev.Items.map(withoutFormula)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// Маршрут согласования зависит от начальника участка И проекта (в админке
|
// Маршрут согласования зависит от начальника участка И проекта (в админке
|
||||||
// approval-route для этого типа заявки настроено условие ByProject) —
|
// approval-route для этого типа заявки настроено условие ByProject) —
|
||||||
// planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока
|
// planned resolve без ProjectId 404-ит, поэтому матрица не готова, пока
|
||||||
@@ -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 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 => {
|
const validate = (): string | undefined => {
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return 'Заполните обязательные поля: дата поставки, начальник участка, проект и хотя бы одну позицию (класс смеси + количество).';
|
return 'Заполните обязательные поля: дата поставки, начальник участка, проект, БСУ и хотя бы одну позицию (класс смеси + количество).';
|
||||||
}
|
}
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.';
|
||||||
@@ -303,7 +317,9 @@ const ConcreteRequestForm: React.FC<IRequestFormProps> = ({
|
|||||||
set,
|
set,
|
||||||
context,
|
context,
|
||||||
onSiteChiefChange: handleSiteChiefChange,
|
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 { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './ConcreteRequestView.module.scss';
|
import styles from './ConcreteRequestView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
buildPrintTitle
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -42,6 +50,8 @@ interface IConcreteRequestViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявка на бетон';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
const NOOP_SET: SetField = () => undefined;
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
@@ -137,13 +147,18 @@ const ConcreteRequestView: React.FC<IConcreteRequestViewProps> = ({ context, ite
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.SiteChiefName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на бетон"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -51,11 +51,20 @@
|
|||||||
здесь другой, поэтому у формы свой редактор. Карточки (`FormGrid`/`FormField`),
|
здесь другой, поэтому у формы свой редактор. Карточки (`FormGrid`/`FormField`),
|
||||||
а не строки плоской таблицы — адаптивнее на мобильных, чем строка с
|
а не строки плоской таблицы — адаптивнее на мобильных, чем строка с
|
||||||
горизонтальным скроллом.
|
горизонтальным скроллом.
|
||||||
|
- **БСУ и рецепты** (`bsu/`, API `https://bsu.tnsu.kz/api/v1`, токен того же
|
||||||
|
AAD-приложения, что у approval-route): в шапке выбирается БСУ (`GET /bsu` →
|
||||||
|
`BsuId`/`BsuName`, обязателен), в позиции — рецепт этого БСУ
|
||||||
|
(`GET /bsu/{id}/formulas`): в позицию пишутся `formulaId`, название в
|
||||||
|
`mixClass` и снимок состава `ingredients` (на заводе рецепт могут поменять).
|
||||||
|
Смена БСУ сбрасывает выбранные рецепты (`withoutFormula`). API не ответил —
|
||||||
|
отправку **не блокируем**: предупреждение и ручной ввод (название БСУ /
|
||||||
|
класс смеси текстом, как до справочника). В просмотре API не вызывается —
|
||||||
|
показываются сохранённые названия.
|
||||||
- **Заголовок элемента** (`Title`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`.
|
- **Заголовок элемента** (`Title`): `Заявка на бетон — {ФИО начальника участка} — дд.мм.гггг`.
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
HeaderSection (дата поставки · начальник участка · организация (read-only) ·
|
HeaderSection (дата поставки · начальник участка · организация (read-only) ·
|
||||||
проект · мастер участка) + ItemsEditor (табличная часть поставок)
|
проект · БСУ · мастер участка) + ItemsEditor (табличная часть поставок)
|
||||||
|
|
||||||
## Точки входа
|
## Точки входа
|
||||||
- rptype: `wh-concrete-request`
|
- rptype: `wh-concrete-request`
|
||||||
|
|||||||
@@ -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',
|
'Organization',
|
||||||
'Project',
|
'Project',
|
||||||
'ProjectId',
|
'ProjectId',
|
||||||
|
'BsuId',
|
||||||
|
'BsuName',
|
||||||
'ItemsJson',
|
'ItemsJson',
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode'
|
'RequestUniqueCode'
|
||||||
@@ -62,6 +64,8 @@ export async function loadConcreteRequest(itemId: number, listId?: string): Prom
|
|||||||
Organization: (item.Organization as string) || '',
|
Organization: (item.Organization as string) || '',
|
||||||
Project: (item.Project as string) || '',
|
Project: (item.Project as string) || '',
|
||||||
ProjectId: (item.ProjectId as string) || '',
|
ProjectId: (item.ProjectId as string) || '',
|
||||||
|
BsuId: (item.BsuId as string) || '',
|
||||||
|
BsuName: (item.BsuName as string) || '',
|
||||||
Items: parseItems(item.ItemsJson)
|
Items: parseItems(item.ItemsJson)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { ISelectOption } from '../../shared/ui';
|
import { ISelectOption } from '../../shared/ui';
|
||||||
import { RequestTypeId } from '../../models/types';
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
import { IBsuCatalog } from './bsu/useBsuCatalog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
* Тип заявки этой формы — ключ реестра форм (forms/registry.ts). Имена списков
|
||||||
@@ -18,8 +19,18 @@ export const FORM_ID: RequestTypeId = 'wh-concrete-request';
|
|||||||
* согласования (ByProject, см. IFormData).
|
* согласования (ByProject, см. IFormData).
|
||||||
*/
|
*/
|
||||||
export interface IConcreteItem {
|
export interface IConcreteItem {
|
||||||
/** Класс/марка смеси и краткая характеристика (напр. «C20/25 F75 W6»). */
|
/**
|
||||||
|
* Класс/марка смеси. Выбран рецепт БСУ — название рецепта; API БСУ недоступен
|
||||||
|
* или заявка старая — свободный текст (напр. «C20/25 F75 W6»).
|
||||||
|
*/
|
||||||
mixClass: string;
|
mixClass: string;
|
||||||
|
/**
|
||||||
|
* id рецепта БСУ (GET /bsu/{id}/formulas). Нет — рецепт введён вручную или
|
||||||
|
* заявка подана до справочника БСУ, поэтому поле необязательное.
|
||||||
|
*/
|
||||||
|
formulaId?: number;
|
||||||
|
/** Состав рецепта на момент подачи — снимок: на заводе рецепт могут поменять. */
|
||||||
|
ingredients?: string;
|
||||||
/** Количество, м³. */
|
/** Количество, м³. */
|
||||||
volume: string;
|
volume: string;
|
||||||
/** Подвижность смеси — см. WORKABILITY_OPTIONS. */
|
/** Подвижность смеси — см. WORKABILITY_OPTIONS. */
|
||||||
@@ -38,6 +49,12 @@ export const EMPTY_CONCRETE_ITEM: IConcreteItem = {
|
|||||||
note: ''
|
note: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Позиция без рецепта БСУ. Рецепт, введённый вручную (без formulaId), не трогаем. */
|
||||||
|
export function withoutFormula(item: IConcreteItem): IConcreteItem {
|
||||||
|
if (item.formulaId === undefined) return item;
|
||||||
|
return { ...item, formulaId: undefined, mixClass: '', ingredients: '' };
|
||||||
|
}
|
||||||
|
|
||||||
/** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */
|
/** Подвижность бетонной смеси (ГОСТ 7473) — фиксированный набор, чтобы не плодить опечатки. */
|
||||||
export const WORKABILITY_OPTIONS: ISelectOption[] = [
|
export const WORKABILITY_OPTIONS: ISelectOption[] = [
|
||||||
{ value: 'П1', label: 'П1' },
|
{ value: 'П1', label: 'П1' },
|
||||||
@@ -90,6 +107,10 @@ export interface IFormData {
|
|||||||
Project: string;
|
Project: string;
|
||||||
/** oid проекта из справочника; '' — введён вручную без выбора. */
|
/** oid проекта из справочника; '' — введён вручную без выбора. */
|
||||||
ProjectId: string;
|
ProjectId: string;
|
||||||
|
/** uuid БСУ (бетонного завода) из API БСУ; '' — введён вручную (API недоступен). */
|
||||||
|
BsuId: string;
|
||||||
|
/** Название БСУ — для просмотра и печати без обращения к API. */
|
||||||
|
BsuName: string;
|
||||||
/** Табличная часть — позиции поставки (сериализуется в ItemsJson). */
|
/** Табличная часть — позиции поставки (сериализуется в ItemsJson). */
|
||||||
Items: IConcreteItem[];
|
Items: IConcreteItem[];
|
||||||
}
|
}
|
||||||
@@ -108,6 +129,10 @@ export interface ISectionProps {
|
|||||||
onSiteChiefChange?: (login: string) => void;
|
onSiteChiefChange?: (login: string) => void;
|
||||||
/** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */
|
/** Выбор мастера участка подтягивает его ФИО. Не передан (просмотр) — правка через set. */
|
||||||
onForemanChange?: (login: string) => void;
|
onForemanChange?: (login: string) => void;
|
||||||
|
/** Справочник БСУ. Не передан (просмотр) — БСУ и рецепты показываются сохранённым текстом. */
|
||||||
|
bsu?: IBsuCatalog;
|
||||||
|
/** Смена БСУ сбрасывает выбранные в позициях рецепты (они от прежнего завода). */
|
||||||
|
onBsuChange?: (bsuId: string, bsuName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…';
|
export const SITE_CHIEF_PLACEHOLDER = 'Начните вводить Ф.И.О. начальника участка…';
|
||||||
@@ -122,5 +147,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Organization: '',
|
Organization: '',
|
||||||
Project: '',
|
Project: '',
|
||||||
ProjectId: '',
|
ProjectId: '',
|
||||||
|
BsuId: '',
|
||||||
|
BsuName: '',
|
||||||
Items: []
|
Items: []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
{ "internalName": "Organization", "title": "Организация", "type": "Note" },
|
||||||
{ "internalName": "Project", "title": "Проект", "type": "Text", "required": true },
|
{ "internalName": "Project", "title": "Проект", "type": "Text", "required": true },
|
||||||
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
|
{ "internalName": "ProjectId", "title": "Id проекта (справочник)", "type": "Text" },
|
||||||
|
{ "internalName": "BsuId", "title": "Id БСУ (API БСУ)", "type": "Text" },
|
||||||
|
{ "internalName": "BsuName", "title": "БСУ", "type": "Text" },
|
||||||
|
|
||||||
{ "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" }
|
{ "internalName": "ItemsJson", "title": "Позиции поставки (JSON)", "type": "Note" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui';
|
||||||
import { ProjectPicker } from '../../../shared/dictionary';
|
import { ProjectPicker } from '../../../shared/dictionary';
|
||||||
import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model';
|
import { ISectionProps, SITE_CHIEF_PLACEHOLDER, FOREMAN_PLACEHOLDER } from '../model';
|
||||||
|
import { BsuField } from './BsuField';
|
||||||
|
|
||||||
/** Шапка заявки на бетон: дата поставки, начальник участка (составил), мастер участка. */
|
/** Шапка заявки на бетон: дата поставки, начальник участка (составил), проект, БСУ, мастер участка. */
|
||||||
export const HeaderSection: React.FC<ISectionProps> = ({
|
export const HeaderSection: React.FC<ISectionProps> = ({
|
||||||
data,
|
data,
|
||||||
set,
|
set,
|
||||||
context,
|
context,
|
||||||
printOrgIndex,
|
printOrgIndex,
|
||||||
onSiteChiefChange,
|
onSiteChiefChange,
|
||||||
onForemanChange
|
onForemanChange,
|
||||||
|
bsu,
|
||||||
|
onBsuChange
|
||||||
}) => (
|
}) => (
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<FormField label="Дата поставки" htmlFor="RequestDate" required>
|
<FormField label="Дата поставки" htmlFor="RequestDate" required>
|
||||||
@@ -37,6 +40,18 @@ export const HeaderSection: React.FC<ISectionProps> = ({
|
|||||||
placeholder="Начните вводить название или код проекта…"
|
placeholder="Начните вводить название или код проекта…"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<BsuField
|
||||||
|
bsuId={data.BsuId}
|
||||||
|
bsuName={data.BsuName}
|
||||||
|
catalog={bsu}
|
||||||
|
onChange={
|
||||||
|
onBsuChange ??
|
||||||
|
((id, name) => {
|
||||||
|
set('BsuId', id);
|
||||||
|
set('BsuName', name);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
<FormField label="Мастер участка" htmlFor="ForemanEmail" full>
|
<FormField label="Мастер участка" htmlFor="ForemanEmail" full>
|
||||||
<PeopleField
|
<PeopleField
|
||||||
context={context}
|
context={context}
|
||||||
|
|||||||
@@ -49,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 {
|
.empty {
|
||||||
padding: $sp-3 0;
|
padding: $sp-3 0;
|
||||||
font-family: $font-sans;
|
font-family: $font-sans;
|
||||||
|
|||||||
@@ -2,22 +2,25 @@ import * as React from 'react';
|
|||||||
import styles from './ItemsEditor.module.scss';
|
import styles from './ItemsEditor.module.scss';
|
||||||
import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui';
|
import { Button, Icon, TextInput, Select, FormGrid, FormField, useFormReadOnly } from '../../../shared/ui';
|
||||||
import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model';
|
import { EMPTY_CONCRETE_ITEM, IConcreteItem, WORKABILITY_OPTIONS } from '../model';
|
||||||
|
import { IRecipePicker, MixClassField } from './MixClassField';
|
||||||
|
|
||||||
export interface IItemsEditorProps {
|
export interface IItemsEditorProps {
|
||||||
value: IConcreteItem[];
|
value: IConcreteItem[];
|
||||||
onChange: (rows: IConcreteItem[]) => void;
|
onChange: (rows: IConcreteItem[]) => void;
|
||||||
|
/** Рецепты БСУ; не передан — класс смеси вводится текстом (см. MixClassField). */
|
||||||
|
recipePicker?: IRecipePicker;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Табличная часть заявки на бетон: класс смеси, объём, подвижность, время и
|
* Табличная часть заявки на бетон: класс смеси (рецепт БСУ), объём, подвижность,
|
||||||
* примечание — карточка на каждую поставку. Проект/объект сюда не входит: он
|
* время и примечание — карточка на каждую поставку. Проект/объект сюда не входит: он
|
||||||
* один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) —
|
* один на всю заявку и выбирается в шапке (см. HeaderSection, IFormData.Project) —
|
||||||
* от него зависит маршрут согласования, поэтому поле не дублируется по позициям.
|
* от него зависит маршрут согласования, поэтому поле не дублируется по позициям.
|
||||||
*
|
*
|
||||||
* НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным
|
* НЕ плоская таблица-простыня (как shared/ui/ItemsTable): карточки с обычным
|
||||||
* FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных.
|
* FormGrid адаптивнее узкой строки с горизонтальным скроллом на мобильных.
|
||||||
*/
|
*/
|
||||||
export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) => {
|
export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange, recipePicker }) => {
|
||||||
const readOnly = useFormReadOnly();
|
const readOnly = useFormReadOnly();
|
||||||
|
|
||||||
const setRow = (index: number, patch: Partial<IConcreteItem>): void => {
|
const setRow = (index: number, patch: Partial<IConcreteItem>): void => {
|
||||||
@@ -43,13 +46,8 @@ export const ItemsEditor: React.FC<IItemsEditorProps> = ({ value, onChange }) =>
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FormGrid>
|
<FormGrid>
|
||||||
<FormField label="Класс смеси и хар-ка" htmlFor={`mixClass-${i}`} required full>
|
<FormField label="Класс смеси (рецепт)" htmlFor={`mixClass-${i}`} required full>
|
||||||
<TextInput
|
<MixClassField id={`mixClass-${i}`} row={row} picker={recipePicker} onChange={(patch) => setRow(i, patch)} />
|
||||||
id={`mixClass-${i}`}
|
|
||||||
value={row.mixClass}
|
|
||||||
onChange={(v) => setRow(i, { mixClass: v })}
|
|
||||||
placeholder="C20/25 F75 W6"
|
|
||||||
/>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Количество, м³" htmlFor={`volume-${i}`} required>
|
<FormField label="Количество, м³" htmlFor={`volume-${i}`} required>
|
||||||
<TextInput id={`volume-${i}`} value={row.volume} onChange={(v) => setRow(i, { volume: v })} placeholder="0" />
|
<TextInput id={`volume-${i}`} value={row.volume} onChange={(v) => setRow(i, { volume: v })} placeholder="0" />
|
||||||
|
|||||||
@@ -1,11 +1,35 @@
|
|||||||
import * as React from 'react';
|
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 { ItemsEditor } from './ItemsEditor';
|
||||||
|
import { IRecipePicker } from './MixClassField';
|
||||||
import { ISectionProps } from '../model';
|
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">
|
<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>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './DismissalView.module.scss';
|
import styles from './DismissalView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, FormNotice } from '../../shared/ui';
|
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintButton, FormNotice, buildPrintTitle } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -58,6 +58,8 @@ interface IDismissalViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявление на увольнение';
|
||||||
|
|
||||||
// Заявление на увольнение только для просмотра (режим согласующего).
|
// Заявление на увольнение только для просмотра (режим согласующего).
|
||||||
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
|
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
@@ -70,6 +72,8 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
// ФИО увольняющегося — для имени печатного документа.
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Итог автосоздания обходного листа после согласования (см. onDecided ниже).
|
// Итог автосоздания обходного листа после согласования (см. onDecided ниже).
|
||||||
@@ -107,6 +111,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -265,7 +270,9 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
{printGate === 'allowed' ? (
|
{printGate === 'allowed' ? (
|
||||||
<PrintButton />
|
<PrintButton
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению">
|
<span className={styles.printBlocked} title="Печать откроется после согласования обходного листа по этому увольнению">
|
||||||
Печать станет доступна после согласования обходного листа
|
Печать станет доступна после согласования обходного листа
|
||||||
@@ -275,7 +282,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявление на увольнение"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './EmployeeTransferView.module.scss';
|
import styles from './EmployeeTransferView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
buildPrintTitle
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -41,6 +49,8 @@ interface IEmployeeTransferViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявка на перемещение сотрудника';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
const NOOP_SET: SetField = () => undefined;
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
@@ -56,6 +66,8 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
// ФИО перемещаемого — для имени печатного документа.
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
||||||
@@ -89,6 +101,7 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -136,13 +149,18 @@ const EmployeeTransferView: React.FC<IEmployeeTransferViewProps> = ({ context, i
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на перемещение сотрудника"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
PrintButton,
|
PrintButton,
|
||||||
FieldChangesProvider,
|
FieldChangesProvider,
|
||||||
FieldChangesBanner,
|
FieldChangesBanner,
|
||||||
issueLabels
|
issueLabels,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { HIRE_CHANGE_TRACKING } from './changeTracking';
|
import { HIRE_CHANGE_TRACKING } from './changeTracking';
|
||||||
@@ -87,6 +88,7 @@ const HIRE_DATE_REQUIRED_MESSAGE = 'укажите дату приёма (п. 2.
|
|||||||
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
|
const PAYROLL_HINT = 'Как согласующий вы можете указать оклады (п. 4.2, 4.3) и отдел/участок (п. 2.4).';
|
||||||
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
|
const HIRE_DATE_HINT = 'Как исполнитель укажите дату приёма (п. 2.2) — она обязательна для отметки «Выполнено».';
|
||||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||||
|
const FORM_TITLE = 'Заявка на прием';
|
||||||
|
|
||||||
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
|
// Заявка «на прием» только для просмотра (режим согласующего). Переиспользует
|
||||||
// те же секции, что и форма подачи; редактирование выключено через
|
// те же секции, что и форма подачи; редактирование выключено через
|
||||||
@@ -382,7 +384,11 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
<Icon name="copy" size="sm" />
|
<Icon name="copy" size="sm" />
|
||||||
Организация рабочего места на основе этой
|
Организация рабочего места на основе этой
|
||||||
</Button>
|
</Button>
|
||||||
{isApprovedStatus(status) && <PrintButton />}
|
{isApprovedStatus(status) && (
|
||||||
|
<PrintButton
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.HireCandidateName, code, itemId })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasEditableFields && (
|
{hasEditableFields && (
|
||||||
@@ -400,7 +406,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на прием"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
PrintButton,
|
PrintButton,
|
||||||
FieldChangesProvider,
|
FieldChangesProvider,
|
||||||
FieldChangesBanner
|
FieldChangesBanner,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, mergeFieldChanges, toChangeMap } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { SELECTION_CHANGE_TRACKING } from './changeTracking';
|
import { SELECTION_CHANGE_TRACKING } from './changeTracking';
|
||||||
@@ -80,6 +81,7 @@ interface IRecruitingSelectionViewProps {
|
|||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||||
|
const FORM_TITLE = 'Заявка на подбор';
|
||||||
|
|
||||||
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
||||||
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
|
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>,
|
||||||
@@ -366,7 +368,10 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
{isApprovedStatus(status) && <PrintButton />}
|
{/* Подбор — на вакансию, конкретного человека ещё нет: имя файла без ФИО. */}
|
||||||
|
{isApprovedStatus(status) && (
|
||||||
|
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: '', code, itemId })} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fullEditable && (
|
{fullEditable && (
|
||||||
@@ -384,7 +389,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на подбор"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -207,28 +207,6 @@ export const LANGUAGE_LEVEL_OPTIONS: ISelectOption[] = [
|
|||||||
{ value: 'Свободный', label: 'Свободный' }
|
{ value: 'Свободный', label: 'Свободный' }
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Программы (ПО) — ВРЕМЕННЫЙ список, пока нет справочника в API. Используется в
|
|
||||||
* мультивыборе «Необходимые знания в программах». Меняется здесь одним местом.
|
|
||||||
*/
|
|
||||||
export const SOFTWARE_OPTIONS: ISelectOption[] = [
|
|
||||||
{ value: '1С:Предприятие', label: '1С:Предприятие' },
|
|
||||||
{ value: 'MS Word', label: 'MS Word' },
|
|
||||||
{ value: 'MS Excel', label: 'MS Excel' },
|
|
||||||
{ value: 'MS PowerPoint', label: 'MS PowerPoint' },
|
|
||||||
{ value: 'MS Outlook', label: 'MS Outlook' },
|
|
||||||
{ value: 'MS Project', label: 'MS Project' },
|
|
||||||
{ value: 'AutoCAD', label: 'AutoCAD' },
|
|
||||||
{ value: 'Autodesk Forma', label: 'Autodesk Forma' },
|
|
||||||
{ value: 'BIM', label: 'BIM' },
|
|
||||||
{ value: 'Revit', label: 'Revit' },
|
|
||||||
{ value: 'ArchiCAD', label: 'ArchiCAD' },
|
|
||||||
{ value: 'Компас-3D', label: 'Компас-3D' },
|
|
||||||
{ value: 'Primavera', label: 'Primavera' },
|
|
||||||
{ value: 'SAP', label: 'SAP' },
|
|
||||||
{ value: 'Adobe Photoshop', label: 'Adobe Photoshop' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Категории «Обеспечение рабочего места» (+ «Другое» через allowOther). */
|
/** Категории «Обеспечение рабочего места» (+ «Другое» через allowOther). */
|
||||||
export const WORKPLACE_PROVISION_OPTIONS: ISelectOption[] = [
|
export const WORKPLACE_PROVISION_OPTIONS: ISelectOption[] = [
|
||||||
{ value: 'Оргтехника', label: 'Оргтехника' },
|
{ value: 'Оргтехника', label: 'Оргтехника' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextInput, TextArea, Select, MultiChoiceField } from '../../../shared/ui';
|
import { CollapseSection, FormField, TextInput, TextArea, Select, MultiChoiceField } from '../../../shared/ui';
|
||||||
import { PositionPicker } from '../../../shared/dictionary';
|
import { PositionPicker, SOFTWARE_OPTIONS } from '../../../shared/dictionary';
|
||||||
import { LanguagesField } from './LanguagesField';
|
import { LanguagesField } from './LanguagesField';
|
||||||
import {
|
import {
|
||||||
ISectionProps,
|
ISectionProps,
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
EDUCATION_OPTIONS,
|
EDUCATION_OPTIONS,
|
||||||
WORK_EXPERIENCE_OPTIONS,
|
WORK_EXPERIENCE_OPTIONS,
|
||||||
RESIDENCE_OPTIONS,
|
RESIDENCE_OPTIONS,
|
||||||
SOFTWARE_OPTIONS,
|
|
||||||
OTHER_VALUE
|
OTHER_VALUE
|
||||||
} from '../model';
|
} from '../model';
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
AttachmentList,
|
AttachmentList,
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead
|
OrgLetterhead,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
@@ -60,6 +61,8 @@ interface IReferralViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Реферальная рекомендация';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
const NOOP_SET: SetField = () => undefined;
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
@@ -189,14 +192,19 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
{isApprovedStatus(status) && (
|
{isApprovedStatus(status) && (
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.CandidateName, code, itemId })}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Реферальная рекомендация"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
FormModeProvider,
|
FormModeProvider,
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
Icon
|
Icon,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +58,8 @@ interface IServiceRequestViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявка на услуги';
|
||||||
|
|
||||||
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
|
// Заявка «Заявка на услуги» только для просмотра (режим согласующего).
|
||||||
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
|
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
|
||||||
@@ -166,13 +169,18 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
|
|||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.RequesterName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка на услуги"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './SkudExplanationView.module.scss';
|
import styles from './SkudExplanationView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import {
|
||||||
|
Alert,
|
||||||
|
FormCard,
|
||||||
|
InitiatorLine,
|
||||||
|
FormModeProvider,
|
||||||
|
PrintSplitButton,
|
||||||
|
OrgLetterhead,
|
||||||
|
buildPrintTitle
|
||||||
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -130,7 +138,12 @@ const SkudExplanationView: React.FC<ISkudExplanationViewProps> = ({ context, ite
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.EmployeeFio, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormCard
|
<FormCard
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
AttachmentList,
|
AttachmentList,
|
||||||
PrintButton,
|
PrintButton,
|
||||||
Icon
|
Icon,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +57,8 @@ interface ITicket1CViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявка в отдел 1С';
|
||||||
|
|
||||||
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
||||||
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
@@ -185,12 +188,12 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<PrintButton />
|
<PrintButton documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.FIO, code, itemId })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявка в отдел 1С"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
AttachmentList,
|
AttachmentList,
|
||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
Icon
|
Icon,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,8 @@ interface ITmcTransferViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Служебная записка на перемещение ТМЦ';
|
||||||
|
|
||||||
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
||||||
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
@@ -169,13 +172,18 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
|||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.AuthorName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Служебная записка на перемещение ТМЦ"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
FormNotice,
|
FormNotice,
|
||||||
PrintSignature
|
PrintSignature,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
@@ -50,6 +51,8 @@ interface IUnpaidLeaveViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявление на отпуск без сохранения заработной платы';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
const NOOP_SET: SetField = () => undefined;
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
@@ -148,13 +151,18 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявление на отпуск без сохранения заработной платы"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
PrintSplitButton,
|
PrintSplitButton,
|
||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
FormNotice,
|
FormNotice,
|
||||||
PrintSignature
|
PrintSignature,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
@@ -57,6 +58,8 @@ interface IVacationViewProps {
|
|||||||
onCodeLoaded?: (code: string) => void;
|
onCodeLoaded?: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORM_TITLE = 'Заявление на оплачиваемый ежегодный трудовой отпуск';
|
||||||
|
|
||||||
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
// В режиме чтения поля не редактируются, поэтому обновление — пустышка.
|
||||||
const NOOP_SET: SetField = () => undefined;
|
const NOOP_SET: SetField = () => undefined;
|
||||||
|
|
||||||
@@ -207,13 +210,18 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
<PrintSplitButton value={data.Organization} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={data.Organization}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: employeeName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={data.Organization} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Заявление на оплачиваемый ежегодный трудовой отпуск"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine
|
||||||
|
|||||||
@@ -23,16 +23,32 @@
|
|||||||
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
|
- **Полное право правки** (`shared/approval/fullEditRights.ts`, сейчас Ербол
|
||||||
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
|
Шаймерденов): все поля и вложения, если он есть в маршруте и заявка «На
|
||||||
согласовании»/«Выполнение» (`useApproverRights.ts`).
|
согласовании»/«Выполнение» (`useApproverRights.ts`).
|
||||||
- **Поля «Необходимое ПО» и «Доступы» заполняет согласующий-руководитель** на своём
|
- **Поля «Необходимое ПО», «Доступы» и «Мебель» заполняет согласующий-руководитель**
|
||||||
этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
|
на своём этапе (инициатор их обычно не знает). Доступны для правки, когда очередь дошла до
|
||||||
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой
|
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой
|
||||||
«Сохранить» и автоматически при «Согласовать» (`updateReviewFields`,
|
«Сохранить» и автоматически при «Согласовать» (`updateReviewFields`,
|
||||||
`DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only.
|
`DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only.
|
||||||
|
- **Необходимое ПО** — мультивыбор из общего списка `shared/dictionary/software.ts`
|
||||||
|
(тот же, что в подборе) + своё значение. В SP и в маршрут — по-прежнему ТЕКСТОМ
|
||||||
|
в колонке `Software` через запятую (`softwareList.ts`): условия «Software содержит
|
||||||
|
AutoCAD» и старые заявки со свободным текстом работают как раньше.
|
||||||
|
- **Мебель** — мультивыбор (`FURNITURE_OPTIONS`); при «Другое» появляется поле
|
||||||
|
своего значения. В SP — `WorkplaceFurnitureJson` (JSON-массив) и
|
||||||
|
`WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как
|
||||||
|
`WorkplaceFurnitureJson` (пункты через запятую, `routeFields.ts`) — на условие
|
||||||
|
шага исполнения «исполнитель по мебели».
|
||||||
|
- **Добавление исполнителей по правке согласующего** (`routeExecutorsSync.ts`):
|
||||||
|
маршрут планируется при подаче, поэтому если руководитель/Ербол поменял мебель
|
||||||
|
или ПО, при сохранении (и перед решением) строятся два плана исполнения — со
|
||||||
|
старыми и новыми значениями — и разница добавляется в конец исполнения
|
||||||
|
(«Ждёт очереди», `ApproverService.addMissingExecutors`). Никого не удаляем;
|
||||||
|
уже стоящих в исполнении (в любом статусе) не дублируем
|
||||||
|
(`shared/approval/missingExecutors.ts`).
|
||||||
- **Маска телефона** (PhoneInput).
|
- **Маска телефона** (PhoneInput).
|
||||||
- Печать — общий механизм (без «Вложений»).
|
- Печать — общий механизм (без «Вложений»).
|
||||||
|
|
||||||
## Секции формы
|
## Секции формы
|
||||||
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection
|
HireRequestPicker (только в редактируемой форме) · EmployeeSection · EquipmentSection · FurnitureSection
|
||||||
|
|
||||||
## Точки входа
|
## Точки входа
|
||||||
- rptype: `it-workplace-setup`
|
- rptype: `it-workplace-setup`
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { loadWorkplaceRequest } from './load';
|
|||||||
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
import { FurnitureSection } from './sections/FurnitureSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||||
@@ -392,6 +393,7 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
<FormModeProvider readOnly={readOnly}>
|
<FormModeProvider readOnly={readOnly}>
|
||||||
<EmployeeSection {...sectionProps} />
|
<EmployeeSection {...sectionProps} />
|
||||||
<EquipmentSection {...sectionProps} />
|
<EquipmentSection {...sectionProps} />
|
||||||
|
<FurnitureSection {...sectionProps} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
</FieldChangesProvider>
|
</FieldChangesProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
OrgLetterhead,
|
OrgLetterhead,
|
||||||
Icon,
|
Icon,
|
||||||
FieldChangesProvider,
|
FieldChangesProvider,
|
||||||
FieldChangesBanner
|
FieldChangesBanner,
|
||||||
|
buildPrintTitle
|
||||||
} from '../../shared/ui';
|
} from '../../shared/ui';
|
||||||
import { navigate } from '../../shared/routing';
|
import { navigate } from '../../shared/routing';
|
||||||
import {
|
import {
|
||||||
@@ -47,8 +48,10 @@ import { workplaceApprovalService, updateReviewFields, updateAllWorkplaceFields
|
|||||||
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
|
import { WORKPLACE_CHANGE_TRACKING } from './changeTracking';
|
||||||
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
import { hasRequiredFields, REQUIRED_FIELDS_MESSAGE } from './validation';
|
||||||
import { useApproverRights } from './useApproverRights';
|
import { useApproverRights } from './useApproverRights';
|
||||||
|
import { addExecutorsForEditedFields, IExecutorsSyncResult } from './routeExecutorsSync';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
import { FurnitureSection } from './sections/FurnitureSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IWorkplaceSetupViewProps {
|
interface IWorkplaceSetupViewProps {
|
||||||
@@ -64,13 +67,14 @@ interface IWorkplaceSetupViewProps {
|
|||||||
|
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО» и «Доступы».';
|
const FORM_TITLE = 'Организация рабочего места';
|
||||||
|
const REVIEW_FIELDS_HINT = 'Как согласующий вы можете заполнить «Необходимое ПО», «Доступы» и «Мебель».';
|
||||||
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
const FULL_EDIT_HINT = 'Как согласующий вы можете редактировать все поля и вложения заявки.';
|
||||||
|
|
||||||
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
// Заявка «орг. рабочего места» только для просмотра (режим согласующего).
|
||||||
// Исключения (оба — только на своём активном шаге, см. useApproverRights):
|
// Исключения (оба — только на своём активном шаге, см. useApproverRights):
|
||||||
// - первый согласующий шаг (по маршруту — непосредственный руководитель) может
|
// - первый согласующий шаг (по маршруту — непосредственный руководитель) может
|
||||||
// заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает);
|
// заполнить «Необходимое ПО», «Доступы» и «Мебель» (инициатор их не знает);
|
||||||
// - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения.
|
// - согласующий из shared/approval/fullEditRights.ts правит все поля и вложения.
|
||||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
@@ -102,6 +106,8 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
// Ref списка данных для ApproverService.updateRequestStatus: listId (url) для
|
||||||
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
// архивной партиции, Title текущей — иначе (см. ApproverService.dataListRef).
|
||||||
const dataListRef = listId || dataListTitle(FORM_ID);
|
const dataListRef = listId || dataListTitle(FORM_ID);
|
||||||
|
// Title списка данных (а не url) — для оповещений, шапки «Инициатор» и чтения инициатора.
|
||||||
|
const dataListName = listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID);
|
||||||
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
|
const approval = useMemo(() => workplaceApprovalService(listId), [listId]);
|
||||||
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]);
|
||||||
const attachments = useAttachmentEditor(dataListRef, itemId);
|
const attachments = useAttachmentEditor(dataListRef, itemId);
|
||||||
@@ -174,6 +180,18 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
};
|
};
|
||||||
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
|
}, [itemId, listId, dataListRef, notifyCode, setAttachmentItems]);
|
||||||
|
|
||||||
|
const notify = (newStatus?: string): void => {
|
||||||
|
if (!requestTypeId) return;
|
||||||
|
const target = {
|
||||||
|
requestTypeId,
|
||||||
|
dataListName,
|
||||||
|
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
||||||
|
};
|
||||||
|
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
||||||
|
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
||||||
|
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
const saveAllFields = async (): Promise<void> => {
|
const saveAllFields = async (): Promise<void> => {
|
||||||
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
|
const editor = { name: context.pageContext.user.displayName, email: effectiveUserEmail(context) };
|
||||||
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date());
|
const nextChanges = mergeFieldChanges(approverChanges, savedData, data, WORKPLACE_CHANGE_TRACKING, editor, new Date());
|
||||||
@@ -183,18 +201,36 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
await attachments.uploadPending();
|
await attachments.uploadPending();
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveReviewFields = (): Promise<void> =>
|
// updateReviewFields пишет только поля руководителя (ReviewFields), остальное из data игнорирует.
|
||||||
updateReviewFields(itemId, { Software: data.Software, AccessRights: data.AccessRights }, listId);
|
const saveReviewFields = (): Promise<void> => updateReviewFields(itemId, data, listId);
|
||||||
|
|
||||||
// Полное право шире: оно включает и «ПО»/«Доступы».
|
// Полное право шире: оно включает и поля руководителя. После записи полей —
|
||||||
const saveFields = (): Promise<void> => (canEditAll ? saveAllFields() : saveReviewFields());
|
// исполнители, которых привела правка мебели/ПО (маршрут планировался при
|
||||||
|
// подаче и сам их не добавит, см. routeExecutorsSync.ts).
|
||||||
|
const saveFields = async (): Promise<IExecutorsSyncResult> => {
|
||||||
|
const before = savedData;
|
||||||
|
if (canEditAll) {
|
||||||
|
await saveAllFields();
|
||||||
|
} else {
|
||||||
|
await saveReviewFields();
|
||||||
|
setSavedData(data);
|
||||||
|
}
|
||||||
|
return addExecutorsForEditedFields(
|
||||||
|
{ context, approval, approverService, requestTypeId, itemId, dataListTitle: dataListName },
|
||||||
|
before,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
const handleSave = async (): Promise<void> => {
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
setSaveErrorMessage('');
|
setSaveErrorMessage('');
|
||||||
try {
|
try {
|
||||||
await saveFields();
|
const executorsSync = await saveFields();
|
||||||
setSaveState('saved');
|
setSaveState('saved');
|
||||||
|
// Новые исполнители должны появиться в таблице «Исполнение» — перечитываем маршрут.
|
||||||
|
if (executorsSync.addedCount > 0) setDecisionSeq((n) => n + 1);
|
||||||
|
if (executorsSync.activatedRowIds.length > 0) notify();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSaveErrorMessage(friendlyErrorMessage(e));
|
setSaveErrorMessage(friendlyErrorMessage(e));
|
||||||
setSaveState('error');
|
setSaveState('error');
|
||||||
@@ -225,18 +261,6 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
const sectionProps = { data, set, context, printOrgIndex };
|
const sectionProps = { data, set, context, printOrgIndex };
|
||||||
const canEditAnything = canEditAll || canEditReviewFields;
|
const canEditAnything = canEditAll || canEditReviewFields;
|
||||||
|
|
||||||
const notify = (newStatus?: string): void => {
|
|
||||||
if (!requestTypeId) return;
|
|
||||||
const target = {
|
|
||||||
requestTypeId,
|
|
||||||
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
|
|
||||||
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
|
|
||||||
};
|
|
||||||
notifyActiveStep(context, approval, itemId, target).catch(() => undefined);
|
|
||||||
// Отклонили или вернули на доработку — письмо инициатору (при других статусах ничего не делает).
|
|
||||||
if (newStatus) notifyInitiatorOfReturn(context, approval, itemId, target, newStatus).catch(() => undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
@@ -249,7 +273,12 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<PrintSplitButton value={organizationText(data)} selectedIndex={printOrgIndex} onChange={setPrintOrgIndex} />
|
<PrintSplitButton
|
||||||
|
value={organizationText(data)}
|
||||||
|
selectedIndex={printOrgIndex}
|
||||||
|
onChange={setPrintOrgIndex}
|
||||||
|
documentTitle={buildPrintTitle({ formTitle: FORM_TITLE, personName: data.FullName, code, itemId })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditAnything && (
|
{canEditAnything && (
|
||||||
@@ -268,13 +297,10 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
<div className="rp-print-root">
|
<div className="rp-print-root">
|
||||||
<OrgLetterhead value={organizationText(data)} selectedIndex={printOrgIndex} />
|
<OrgLetterhead value={organizationText(data)} selectedIndex={printOrgIndex} />
|
||||||
<FormCard
|
<FormCard
|
||||||
title="Организация рабочего места"
|
title={FORM_TITLE}
|
||||||
subtitle={`№${code || itemId}`}
|
subtitle={`№${code || itemId}`}
|
||||||
belowSubtitle={
|
belowSubtitle={
|
||||||
<InitiatorLine
|
<InitiatorLine listTitle={dataListName} itemId={itemId} />
|
||||||
listTitle={listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID)}
|
|
||||||
itemId={itemId}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
headerRight={status ? <StatusBadge status={status} /> : undefined}
|
||||||
>
|
>
|
||||||
@@ -283,6 +309,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
<FormModeProvider readOnly={!canEditAll}>
|
<FormModeProvider readOnly={!canEditAll}>
|
||||||
<EmployeeSection {...sectionProps} />
|
<EmployeeSection {...sectionProps} />
|
||||||
<EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} />
|
<EquipmentSection {...sectionProps} approverEditable={canEditReviewFields} />
|
||||||
|
<FurnitureSection {...sectionProps} approverEditable={canEditReviewFields} />
|
||||||
</FormModeProvider>
|
</FormModeProvider>
|
||||||
</FieldChangesProvider>
|
</FieldChangesProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,25 @@ import { dataListTitle } from '../../shared/listTitles';
|
|||||||
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
import { spDateOnlyToIso } from '../../shared/dateUtils';
|
||||||
import { ICompany } from '../../shared/dictionary';
|
import { ICompany } from '../../shared/dictionary';
|
||||||
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, parseFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, EMPTY_DATA } from './model';
|
import {
|
||||||
|
COMPANIES_JSON_FIELD,
|
||||||
|
FURNITURE_JSON_FIELD,
|
||||||
|
FURNITURE_OTHER_FIELD,
|
||||||
|
FORM_ID,
|
||||||
|
IFormData,
|
||||||
|
EMPTY_DATA
|
||||||
|
} from './model';
|
||||||
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON выбранных компаний → массив. Пусто — заявка до мультивыбора (тогда
|
* JSON-колонка с массивом → массив. Пусто — колонка не заполнена (заявка создана
|
||||||
* организации есть только текстом в Organization) или битый JSON.
|
* до появления поля: компании до мультивыбора, мебель до 2026-10-29) или битый JSON.
|
||||||
*/
|
*/
|
||||||
function parseCompanies(raw: unknown): ICompany[] {
|
function parseJsonArray<T>(raw: unknown): T[] {
|
||||||
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
if (typeof raw !== 'string' || raw.trim() === '') return [];
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
return Array.isArray(parsed) ? (parsed as ICompany[]) : [];
|
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -50,6 +57,8 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
|
|||||||
...SCALAR_FIELDS,
|
...SCALAR_FIELDS,
|
||||||
'NeedsEquipment',
|
'NeedsEquipment',
|
||||||
COMPANIES_JSON_FIELD,
|
COMPANIES_JSON_FIELD,
|
||||||
|
FURNITURE_JSON_FIELD,
|
||||||
|
FURNITURE_OTHER_FIELD,
|
||||||
APPROVER_CHANGES_FIELD,
|
APPROVER_CHANGES_FIELD,
|
||||||
'RequestStatus',
|
'RequestStatus',
|
||||||
'RequestUniqueCode'
|
'RequestUniqueCode'
|
||||||
@@ -71,7 +80,9 @@ export async function loadWorkplaceRequest(itemId: number, listId?: string): Pro
|
|||||||
});
|
});
|
||||||
|
|
||||||
data.NeedsEquipment = item.NeedsEquipment === true;
|
data.NeedsEquipment = item.NeedsEquipment === true;
|
||||||
data.Companies = parseCompanies(item[COMPANIES_JSON_FIELD]);
|
data.Companies = parseJsonArray<ICompany>(item[COMPANIES_JSON_FIELD]);
|
||||||
|
data.Furniture = parseJsonArray<string>(item[FURNITURE_JSON_FIELD]);
|
||||||
|
data.FurnitureOther = (item[FURNITURE_OTHER_FIELD] as string) || '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { RequestTypeId } from '../../models/types';
|
import { RequestTypeId } from '../../models/types';
|
||||||
|
import { ISelectOption } from '@tnsu/ui-kit';
|
||||||
import { ICompany } from '../../shared/dictionary';
|
import { ICompany } from '../../shared/dictionary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,6 +15,22 @@ export const FORM_ID: RequestTypeId = 'it-workplace-setup';
|
|||||||
*/
|
*/
|
||||||
export const COMPANIES_JSON_FIELD = 'WorkplaceCompaniesJson';
|
export const COMPANIES_JSON_FIELD = 'WorkplaceCompaniesJson';
|
||||||
|
|
||||||
|
/** Note-колонка с IFormData.Furniture в JSON. Префикс Workplace — по той же причине, что у компаний. */
|
||||||
|
export const FURNITURE_JSON_FIELD = 'WorkplaceFurnitureJson';
|
||||||
|
/** Text-колонка с IFormData.FurnitureOther. */
|
||||||
|
export const FURNITURE_OTHER_FIELD = 'WorkplaceFurnitureOther';
|
||||||
|
|
||||||
|
/** Значение-триггер «Другое» в «Мебели»: при нём показываем поле ручного ввода. */
|
||||||
|
export const FURNITURE_OTHER_VALUE = 'Другое';
|
||||||
|
|
||||||
|
export const FURNITURE_OPTIONS: ISelectOption[] = [
|
||||||
|
{ value: 'Стол', label: 'Стол' },
|
||||||
|
{ value: 'Кресло', label: 'Кресло' },
|
||||||
|
{ value: 'Тумбочка', label: 'Тумбочка' },
|
||||||
|
{ value: 'Канцелярские принадлежности', label: 'Канцелярские принадлежности' },
|
||||||
|
{ value: FURNITURE_OTHER_VALUE, label: FURNITURE_OTHER_VALUE }
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка
|
* Данные формы «Организация рабочего места». Ключи = internalName колонок SP-списка
|
||||||
* «Организация рабочего места — данные».
|
* «Организация рабочего места — данные».
|
||||||
@@ -57,6 +74,11 @@ export interface IFormData {
|
|||||||
NeedsEquipment: boolean;
|
NeedsEquipment: boolean;
|
||||||
Software: string;
|
Software: string;
|
||||||
AccessRights: string;
|
AccessRights: string;
|
||||||
|
// Мебель (необязательно; заполняется вручную)
|
||||||
|
/** Выбранные пункты из FURNITURE_OPTIONS. В SP — JSON в колонке WorkplaceFurnitureJson. */
|
||||||
|
Furniture: string[];
|
||||||
|
/** Своё значение — только если выбрано «Другое», иначе ''. В SP — колонка WorkplaceFurnitureOther. */
|
||||||
|
FurnitureOther: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,5 +118,7 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Organization: '',
|
Organization: '',
|
||||||
NeedsEquipment: false,
|
NeedsEquipment: false,
|
||||||
Software: '',
|
Software: '',
|
||||||
AccessRights: ''
|
AccessRights: '',
|
||||||
|
Furniture: [],
|
||||||
|
FurnitureOther: ''
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
{ "internalName": "NeedsEquipment", "title": "Нужна ли техника", "type": "Boolean" },
|
||||||
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
|
{ "internalName": "Software", "title": "Необходимое ПО", "type": "Note" },
|
||||||
{ "internalName": "AccessRights", "title": "Доступы", "type": "Note" },
|
{ "internalName": "AccessRights", "title": "Доступы", "type": "Note" },
|
||||||
|
{ "internalName": "WorkplaceFurnitureJson", "title": "Мебель (JSON)", "type": "Note" },
|
||||||
|
{ "internalName": "WorkplaceFurnitureOther", "title": "Мебель (другое)", "type": "Text" },
|
||||||
{ "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" },
|
{ "internalName": "WorkplaceApproverChangesJson", "title": "Изменения согласующего — рабочее место (JSON)", "type": "Note" },
|
||||||
|
|
||||||
{ "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }
|
{ "internalName": "SourceHire", "title": "Источник (заявка на прием)", "type": "Lookup", "lookupList": "Прием на работу — данные", "lookupShowField": "Title" }
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import {
|
||||||
|
ApprovalListService,
|
||||||
|
ApproverService,
|
||||||
|
IApproverToWrite,
|
||||||
|
IMatrixRow,
|
||||||
|
IResolveFieldValue,
|
||||||
|
getPlannedMatrix,
|
||||||
|
planMissingExecutors
|
||||||
|
} from '../../shared/approval';
|
||||||
|
import { getCreatedBy } from '../../shared/createdBy';
|
||||||
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
|
import { IFormData } from './model';
|
||||||
|
import { routeFieldValues } from './routeFields';
|
||||||
|
|
||||||
|
/** Окружение просмотра заявки, нужное для добавления исполнителей. */
|
||||||
|
export interface IExecutorsSyncContext {
|
||||||
|
context: WebPartContext;
|
||||||
|
approval: ApprovalListService;
|
||||||
|
approverService: ApproverService;
|
||||||
|
/** Id типа заявки в approval-route; undefined — ещё не загружен. */
|
||||||
|
requestTypeId: number | undefined;
|
||||||
|
itemId: number;
|
||||||
|
/** Title списка данных заявки — по нему читаем инициатора (getCreatedBy). */
|
||||||
|
dataListTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IExecutorsSyncResult {
|
||||||
|
addedCount: number;
|
||||||
|
/** Строки, ставшие активными, — им нужно оповещение. */
|
||||||
|
activatedRowIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTHING_ADDED: IExecutorsSyncResult = { addedCount: 0, activatedRowIds: [] };
|
||||||
|
|
||||||
|
function toWrite(row: IMatrixRow): IApproverToWrite {
|
||||||
|
return {
|
||||||
|
email: row.email,
|
||||||
|
position: row.position,
|
||||||
|
allowReassign: row.allowReassign,
|
||||||
|
slaHours: row.slaHours,
|
||||||
|
askOverdueReason: row.askOverdueReason,
|
||||||
|
autoApprove: row.autoApprove
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Согласующий поправил «Мебель» или «Необходимое ПО» в поданной заявке — добавить
|
||||||
|
* исполнителей, которых эти поля приводят по маршруту из админки (иначе, напр.,
|
||||||
|
* мебель, отмеченная руководителем, осталась бы без исполнителя: маршрут
|
||||||
|
* планировался при подаче).
|
||||||
|
*
|
||||||
|
* Строим два плана исполнения — со старыми и с новыми мебелью/ПО — и добавляем
|
||||||
|
* разницу (planMissingExecutors). Остальные поля маршрута (организации, отдел)
|
||||||
|
* в обоих планах берём новые: правка организаций полным правом исполнителей
|
||||||
|
* не добавляет — такого не просили, а маршрут по ним уже был при подаче.
|
||||||
|
*
|
||||||
|
* Маршрут резолвится по инициатору (Автор элемента), как при подаче.
|
||||||
|
* Ошибка — с пояснением, что поля уже сохранены: молча терять исполнителя нельзя.
|
||||||
|
*/
|
||||||
|
export async function addExecutorsForEditedFields(
|
||||||
|
sync: IExecutorsSyncContext,
|
||||||
|
before: IFormData,
|
||||||
|
after: IFormData
|
||||||
|
): Promise<IExecutorsSyncResult> {
|
||||||
|
const fieldsBefore = routeFieldValues({ Companies: after.Companies, Furniture: before.Furniture }, before.Software);
|
||||||
|
const fieldsAfter = routeFieldValues(after, after.Software);
|
||||||
|
if (JSON.stringify(fieldsBefore) === JSON.stringify(fieldsAfter)) return NOTHING_ADDED;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!sync.requestTypeId) throw new Error('не загружен тип заявки');
|
||||||
|
const initiator = await getCreatedBy(sync.dataListTitle, sync.itemId);
|
||||||
|
if (!initiator?.email) throw new Error('не удалось определить инициатора заявки');
|
||||||
|
|
||||||
|
const planExecution = (fieldValues: IResolveFieldValue[]): Promise<IMatrixRow[]> =>
|
||||||
|
getPlannedMatrix(
|
||||||
|
sync.context,
|
||||||
|
sync.requestTypeId as number,
|
||||||
|
initiator.email,
|
||||||
|
sync.approval,
|
||||||
|
'Execution',
|
||||||
|
after.DepartmentId,
|
||||||
|
fieldValues
|
||||||
|
);
|
||||||
|
const [plannedBefore, plannedAfter, currentRows] = await Promise.all([
|
||||||
|
planExecution(fieldsBefore),
|
||||||
|
planExecution(fieldsAfter),
|
||||||
|
sync.approval.getMatrixForRequest(sync.itemId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toAdd = planMissingExecutors(plannedBefore, plannedAfter, currentRows);
|
||||||
|
if (toAdd.length === 0) return NOTHING_ADDED;
|
||||||
|
const activatedRowIds = await sync.approverService.addMissingExecutors(sync.itemId, toAdd.map(toWrite));
|
||||||
|
return { addedCount: toAdd.length, activatedRowIds };
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Изменения сохранены, но не удалось добавить исполнителей по маршруту: ${friendlyErrorMessage(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IResolveFieldValue } from '../../shared/approval';
|
import { IResolveFieldValue } from '../../shared/approval';
|
||||||
import { COMPANIES_JSON_FIELD, IFormData } from './model';
|
import { COMPANIES_JSON_FIELD, FURNITURE_JSON_FIELD, IFormData } from './model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Значения полей заявки для условных шагов маршрута (fieldValues при resolve).
|
* Значения полей заявки для условных шагов маршрута (fieldValues при resolve).
|
||||||
@@ -11,12 +11,18 @@ import { COMPANIES_JSON_FIELD, IFormData } from './model';
|
|||||||
* строку (массив → 400), а «содержит» ищет подстроку. Тот же формат, что в
|
* строку (массив → 400), а «содержит» ищет подстроку. Тот же формат, что в
|
||||||
* заявках на приём и подбор (см. RecruitingHireForm).
|
* заявках на приём и подбор (см. RecruitingHireForm).
|
||||||
*
|
*
|
||||||
* Маршрут планируется при подаче, поэтому условие по Software сработает,
|
* Мебель — «WorkplaceFurnitureJson содержит Стол» и т.п. (исполнитель по мебели):
|
||||||
* только если «Необходимое ПО» указал сам инициатор (руководитель заполняет
|
* пункты одной строкой через запятую — «содержит» ищет подстроку, разделитель
|
||||||
* его позже, на своём шаге).
|
* ему безразличен, а в диагностике админки так читается легче. Сортируем, чтобы
|
||||||
|
* порядок кликов не менял строку и не перепланировал матрицу впустую.
|
||||||
|
*
|
||||||
|
* Маршрут планируется при подаче, поэтому условия по Software и мебели при
|
||||||
|
* подаче сработают, только если их указал сам инициатор. Если их потом
|
||||||
|
* заполнит руководитель, недостающих исполнителей добавляет просмотр заявки
|
||||||
|
* при сохранении правок.
|
||||||
*/
|
*/
|
||||||
export function routeFieldValues(
|
export function routeFieldValues(
|
||||||
data: Pick<IFormData, 'Companies'>,
|
data: Pick<IFormData, 'Companies' | 'Furniture'>,
|
||||||
software: string
|
software: string
|
||||||
): IResolveFieldValue[] {
|
): IResolveFieldValue[] {
|
||||||
const fieldValues: IResolveFieldValue[] = [];
|
const fieldValues: IResolveFieldValue[] = [];
|
||||||
@@ -29,5 +35,9 @@ export function routeFieldValues(
|
|||||||
if (softwareText) {
|
if (softwareText) {
|
||||||
fieldValues.push({ fieldName: 'Software', value: softwareText });
|
fieldValues.push({ fieldName: 'Software', value: softwareText });
|
||||||
}
|
}
|
||||||
|
const furnitureText = data.Furniture.slice().sort().join(', ');
|
||||||
|
if (furnitureText) {
|
||||||
|
fieldValues.push({ fieldName: FURNITURE_JSON_FIELD, value: furnitureText });
|
||||||
|
}
|
||||||
return fieldValues;
|
return fieldValues;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { CollapseSection, FormField, TextArea, Toggle } from '../../../shared/ui';
|
import { CollapseSection, FormField, FormModeProvider, MultiChoiceField, TextArea, Toggle } from '../../../shared/ui';
|
||||||
|
import { SOFTWARE_OPTIONS } from '../../../shared/dictionary';
|
||||||
import { ISectionProps } from '../model';
|
import { ISectionProps } from '../model';
|
||||||
|
import { formatSoftware, parseSoftware } from '../softwareList';
|
||||||
|
|
||||||
interface IEquipmentSectionProps extends ISectionProps {
|
interface IEquipmentSectionProps extends ISectionProps {
|
||||||
/** Согласующий-руководитель на этапе согласования может править ПО и доступы,
|
/** Согласующий-руководитель на этапе согласования может править ПО и доступы,
|
||||||
@@ -13,13 +15,25 @@ export const EquipmentSection: React.FC<IEquipmentSectionProps> = ({ data, set,
|
|||||||
// берёт значение из FormMode (в форме подачи редактируемо, в просмотре — нет).
|
// берёт значение из FormMode (в форме подачи редактируемо, в просмотре — нет).
|
||||||
const fieldReadOnly = approverEditable ? false : undefined;
|
const fieldReadOnly = approverEditable ? false : undefined;
|
||||||
|
|
||||||
|
const software = (
|
||||||
|
<MultiChoiceField
|
||||||
|
value={parseSoftware(data.Software)}
|
||||||
|
onChange={(items) => set('Software', formatSoftware(items))}
|
||||||
|
options={SOFTWARE_OPTIONS}
|
||||||
|
allowOther
|
||||||
|
placeholder="Выберите программу или введите свою"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapseSection title="Оборудование и доступы" icon="monitor">
|
<CollapseSection title="Оборудование и доступы" icon="monitor">
|
||||||
<FormField label="Нужна ли техника" fieldKey="NeedsEquipment">
|
<FormField label="Нужна ли техника" fieldKey="NeedsEquipment">
|
||||||
<Toggle checked={data.NeedsEquipment} onChange={(v) => set('NeedsEquipment', v)} onLabel="Да" />
|
<Toggle checked={data.NeedsEquipment} onChange={(v) => set('NeedsEquipment', v)} onLabel="Да" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Необходимое ПО" htmlFor="Software" full>
|
<FormField label="Необходимое ПО" fieldKey="Software" full>
|
||||||
<TextArea id="Software" value={data.Software} onChange={(v) => set('Software', v)} readOnly={fieldReadOnly} />
|
{/* У MultiChoiceField нет своего readOnly — право руководителя даём вложенным
|
||||||
|
провайдером режима, как в FurnitureSection. */}
|
||||||
|
{approverEditable ? <FormModeProvider readOnly={false}>{software}</FormModeProvider> : software}
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Доступы" htmlFor="AccessRights" full>
|
<FormField label="Доступы" htmlFor="AccessRights" full>
|
||||||
<TextArea id="AccessRights" value={data.AccessRights} onChange={(v) => set('AccessRights', v)} readOnly={fieldReadOnly} />
|
<TextArea id="AccessRights" value={data.AccessRights} onChange={(v) => set('AccessRights', v)} readOnly={fieldReadOnly} />
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CollapseSection, FormField, FormModeProvider, MultiChoiceField, TextInput } from '../../../shared/ui';
|
||||||
|
import { FURNITURE_OPTIONS, FURNITURE_OTHER_VALUE, ISectionProps } from '../model';
|
||||||
|
|
||||||
|
interface IFurnitureSectionProps extends ISectionProps {
|
||||||
|
/** Согласующий-руководитель на своём шаге может отметить мебель, даже когда
|
||||||
|
* остальная форма read-only (как ПО и доступы в EquipmentSection). */
|
||||||
|
approverEditable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FurnitureSection: React.FC<IFurnitureSectionProps> = ({ data, set, approverEditable }) => {
|
||||||
|
const hasOther = data.Furniture.indexOf(FURNITURE_OTHER_VALUE) !== -1;
|
||||||
|
|
||||||
|
const handleFurnitureChange = (values: string[]): void => {
|
||||||
|
set('Furniture', values);
|
||||||
|
// Сняли «Другое» — своё значение больше не относится к заявке, не храним его.
|
||||||
|
if (values.indexOf(FURNITURE_OTHER_VALUE) === -1) set('FurnitureOther', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fields = (
|
||||||
|
<>
|
||||||
|
<FormField label="Мебель" fieldKey="Furniture" full>
|
||||||
|
<MultiChoiceField
|
||||||
|
value={data.Furniture}
|
||||||
|
onChange={handleFurnitureChange}
|
||||||
|
options={FURNITURE_OPTIONS}
|
||||||
|
placeholder="Выберите, что нужно"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{hasOther && (
|
||||||
|
<FormField label="Мебель (другое)" htmlFor="FurnitureOther" full>
|
||||||
|
<TextInput id="FurnitureOther" value={data.FurnitureOther} onChange={(v) => set('FurnitureOther', v)} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapseSection title="Мебель" icon="chair">
|
||||||
|
{/* MultiChoiceField берёт режим только из контекста (своего readOnly у него нет),
|
||||||
|
поэтому право руководителя даём вложенным провайдером, перекрывающим внешний. */}
|
||||||
|
{approverEditable ? <FormModeProvider readOnly={false}>{fields}</FormModeProvider> : fields}
|
||||||
|
</CollapseSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { formatSoftware, parseSoftware } from './softwareList';
|
||||||
|
|
||||||
|
describe('parseSoftware', () => {
|
||||||
|
it('разбирает сохранённый список', () => {
|
||||||
|
expect(parseSoftware('AutoCAD, MS Excel, Revit')).toEqual(['AutoCAD', 'MS Excel', 'Revit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пустой текст — пустой список', () => {
|
||||||
|
expect(parseSoftware('')).toEqual([]);
|
||||||
|
expect(parseSoftware(' , \n ')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('старый свободный текст с переносами строк — тоже чипами', () => {
|
||||||
|
expect(parseSoftware('AutoCAD\nКорпоративная почта')).toEqual(['AutoCAD', 'Корпоративная почта']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('убирает повторы', () => {
|
||||||
|
expect(parseSoftware('SAP, SAP, Revit')).toEqual(['SAP', 'Revit']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatSoftware', () => {
|
||||||
|
it('склеивает через запятую — формат, который ждут условия маршрута', () => {
|
||||||
|
expect(formatSoftware(['AutoCAD', 'Revit'])).toBe('AutoCAD, Revit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('туда и обратно — без потерь', () => {
|
||||||
|
const items = ['1С:Предприятие', 'MS Word', 'Своя программа'];
|
||||||
|
expect(parseSoftware(formatSoftware(items))).toEqual(items);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* «Необходимое ПО» — мультивыбор на экране, но в SP и в маршруте это ТЕКСТ в
|
||||||
|
* колонке Software (условия вида «Software содержит AutoCAD» заведены по нему).
|
||||||
|
* Поэтому модель хранит строку, а раздел переводит её в список и обратно.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SEPARATOR = ', ';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Текст колонки → список для мультивыбора. Делим по запятым и переносам строк:
|
||||||
|
* у старых заявок ПО написано свободным текстом — так он тоже ляжет чипами.
|
||||||
|
* Повторы убираем: чип с тем же значением был бы дублем.
|
||||||
|
*/
|
||||||
|
export function parseSoftware(text: string): string[] {
|
||||||
|
const items = text
|
||||||
|
.split(/[,\n]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
return items.filter((s, i) => items.indexOf(s) === i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список из мультивыбора → текст для колонки Software. */
|
||||||
|
export function formatSoftware(items: string[]): string {
|
||||||
|
return items.join(SEPARATOR);
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
import { ISaveItemResult, resolveDataList, saveRequestItem } from '../../shared/dataList';
|
||||||
import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
import { IFieldChange, serializeFieldChanges } from '../../shared/fieldChanges/fieldChanges';
|
||||||
import { COMPANIES_JSON_FIELD, FORM_ID, IFormData, organizationText } from './model';
|
import {
|
||||||
|
COMPANIES_JSON_FIELD,
|
||||||
|
FURNITURE_JSON_FIELD,
|
||||||
|
FURNITURE_OTHER_FIELD,
|
||||||
|
FORM_ID,
|
||||||
|
IFormData,
|
||||||
|
organizationText
|
||||||
|
} from './model';
|
||||||
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
import { APPROVER_CHANGES_FIELD } from './changeTracking';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,22 +20,30 @@ export function workplaceApprovalService(listId?: string): ApprovalListService {
|
|||||||
return approvalServiceFor(FORM_ID, listId);
|
return approvalServiceFor(FORM_ID, listId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Поля, которые заполняет согласующий-руководитель на своём шаге (см. updateReviewFields). */
|
||||||
|
export type ReviewFields = Pick<IFormData, 'Software' | 'AccessRights' | 'Furniture' | 'FurnitureOther'>;
|
||||||
|
|
||||||
|
/** Колонки SP «Мебели»: массив — JSON-ом, пустые значения — null. */
|
||||||
|
function furnitureFields(data: Pick<IFormData, 'Furniture' | 'FurnitureOther'>): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
[FURNITURE_JSON_FIELD]: data.Furniture.length > 0 ? JSON.stringify(data.Furniture) : null,
|
||||||
|
[FURNITURE_OTHER_FIELD]: data.FurnitureOther === '' ? null : data.FurnitureOther
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
|
* Обновить поля, которые заполняет согласующий-руководитель на этапе согласования
|
||||||
* (инициатор часто не знает нужное ПО и доступы). Пишет только эти два поля.
|
* (инициатор часто не знает нужное ПО, доступы и мебель). Пишет только эти поля.
|
||||||
*
|
*
|
||||||
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest): элемент уже
|
* listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadWorkplaceRequest): элемент уже
|
||||||
* существует, поэтому читаем/пишем напрямую по url его списка, если он задан.
|
* существует, поэтому читаем/пишем напрямую по url его списка, если он задан.
|
||||||
*/
|
*/
|
||||||
export async function updateReviewFields(
|
export async function updateReviewFields(id: number, fields: ReviewFields, listId?: string): Promise<void> {
|
||||||
id: number,
|
|
||||||
fields: { Software: string; AccessRights: string },
|
|
||||||
listId?: string
|
|
||||||
): Promise<void> {
|
|
||||||
const list = resolveDataList(FORM_ID, listId);
|
const list = resolveDataList(FORM_ID, listId);
|
||||||
await list.items.getById(id).update({
|
await list.items.getById(id).update({
|
||||||
Software: fields.Software === '' ? null : fields.Software,
|
Software: fields.Software === '' ? null : fields.Software,
|
||||||
AccessRights: fields.AccessRights === '' ? null : fields.AccessRights,
|
AccessRights: fields.AccessRights === '' ? null : fields.AccessRights,
|
||||||
|
...furnitureFields(fields)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +78,14 @@ function buildContentFields(data: IFormData): Record<string, unknown> {
|
|||||||
item[COMPANIES_JSON_FIELD] = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
|
item[COMPANIES_JSON_FIELD] = data.Companies.length > 0 ? JSON.stringify(data.Companies) : null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (key === 'Furniture') {
|
||||||
|
Object.assign(item, furnitureFields(data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'FurnitureOther') {
|
||||||
|
// Пишется вместе с Furniture (см. выше).
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (key === 'Organization') {
|
if (key === 'Organization') {
|
||||||
// Текстовая копия выбранных компаний — для печати и списка в SP (см. organizationText).
|
// Текстовая копия выбранных компаний — для печати и списка в SP (см. organizationText).
|
||||||
const text = organizationText(data);
|
const text = organizationText(data);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { effectiveUserEmail } from '../../shared/impersonation';
|
|||||||
import { FORM_ID } from './model';
|
import { FORM_ID } from './model';
|
||||||
|
|
||||||
export interface IApproverRights {
|
export interface IApproverRights {
|
||||||
/** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО» и «Доступы». */
|
/** Первый шаг согласования (руководитель) может заполнить «Необходимое ПО», «Доступы» и «Мебель». */
|
||||||
canEditReviewFields: boolean;
|
canEditReviewFields: boolean;
|
||||||
/** Участник маршрута из shared/approval/fullEditRights.ts правит все поля и вложения. */
|
/** Участник маршрута из shared/approval/fullEditRights.ts правит все поля и вложения. */
|
||||||
canEditAll: boolean;
|
canEditAll: boolean;
|
||||||
@@ -28,7 +28,7 @@ const FULL_EDIT_STATUSES: ReadonlyArray<string> = [REQUEST_STATUS.onApproval, RE
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Что текущий пользователь может править в поданной заявке.
|
* Что текущий пользователь может править в поданной заявке.
|
||||||
* - «ПО»/«Доступы» — только на СВОЁМ активном шаге согласования, если это первый шаг.
|
* - «ПО»/«Доступы»/«Мебель» — только на СВОЁМ активном шаге согласования, если это первый шаг.
|
||||||
* - Все поля — если пользователь есть в маршруте (согласование или исполнение)
|
* - Все поля — если пользователь есть в маршруте (согласование или исполнение)
|
||||||
* НЕЗАВИСИМО от того, чья сейчас очередь, пока заявка в работе (решение
|
* НЕЗАВИСИМО от того, чья сейчас очередь, пока заявка в работе (решение
|
||||||
* Тимура 2026-10-07: так в «Организации рабочего места» работает Ербол).
|
* Тимура 2026-10-07: так в «Организации рабочего места» работает Ербол).
|
||||||
|
|||||||
@@ -321,7 +321,8 @@ export class ApprovalListService {
|
|||||||
return this.resolveUserId(email);
|
return this.resolveUserId(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляет одну строку согласования с заданным Order (для делегата).
|
// Добавляет одну строку согласования с заданным Order (для делегата, ручного
|
||||||
|
// добавления участника и исполнителя, которого привела правка полей).
|
||||||
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
// delegatedById — Id пользователя, который выполнил переназначение (не строки
|
||||||
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
|
||||||
// строку ещё раз, если делегат недоступен (см. ApproverService.delegate).
|
// строку ещё раз, если делегат недоступен (см. ApproverService.delegate).
|
||||||
@@ -342,6 +343,12 @@ export class ApprovalListService {
|
|||||||
[FIELDS.order]: order,
|
[FIELDS.order]: order,
|
||||||
[FIELDS.allowReassign]: approver.allowReassign,
|
[FIELDS.allowReassign]: approver.allowReassign,
|
||||||
[FIELDS.phase]: phase,
|
[FIELDS.phase]: phase,
|
||||||
|
// SLA шага из маршрута админки — у исполнителя, добавленного по правке
|
||||||
|
// полей (ApproverService.addMissingExecutors). У делегата их нет: пусто/Нет,
|
||||||
|
// как и раньше. ?? null, а не || null: ноль часов — значимое значение.
|
||||||
|
[FIELDS.slaHours]: approver.slaHours ?? null,
|
||||||
|
[FIELDS.askOverdueReason]: approver.askOverdueReason ?? null,
|
||||||
|
[FIELDS.autoApprove]: approver.autoApprove ?? false,
|
||||||
...(delegatedById ? { [`${FIELDS.delegatedBy}Id`]: delegatedById } : {}),
|
...(delegatedById ? { [`${FIELDS.delegatedBy}Id`]: delegatedById } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
|
import { log } from '@tnsu/monitoring-web';
|
||||||
import { getSP } from '../pnp/pnpjs';
|
import { getSP } from '../pnp/pnpjs';
|
||||||
import '@pnp/sp/webs';
|
import '@pnp/sp/webs';
|
||||||
import '@pnp/sp/lists';
|
import '@pnp/sp/lists';
|
||||||
import { IList } from '@pnp/sp/lists';
|
import { IList } from '@pnp/sp/lists';
|
||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import { ApprovalListService } from './approvalList.service';
|
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||||
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
|
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
|
||||||
import { IApprovalRow } from './models/IApprovalRow';
|
import { IApprovalRow } from './models/IApprovalRow';
|
||||||
import { ISiteUser } from './models/ISiteUser';
|
import { ISiteUser } from './models/ISiteUser';
|
||||||
import { emitRequestsChanged } from '../requestEvents';
|
import { emitRequestsChanged } from '../requestEvents';
|
||||||
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
|
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
|
||||||
import { planStepAdd } from './routeStepAdd';
|
import { IStepAddRow, planStepAdd } from './routeStepAdd';
|
||||||
|
|
||||||
function isActiveStatus(status: string): boolean {
|
function isActiveStatus(status: string): boolean {
|
||||||
return status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.onExecution;
|
return status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.onExecution;
|
||||||
@@ -25,6 +26,14 @@ export interface IPendingRow extends IApprovalRow {
|
|||||||
isDelegator: boolean;
|
isDelegator: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Решение → текст события в логах мониторинга.
|
||||||
|
const DECISION_MESSAGE: Record<ApproverDecision, string> = {
|
||||||
|
approve: 'Заявка согласована',
|
||||||
|
return: 'Заявка возвращена на доработку',
|
||||||
|
reject: 'Заявка отклонена',
|
||||||
|
execute: 'Заявка выполнена исполнителем',
|
||||||
|
};
|
||||||
|
|
||||||
// Решение → статус его строки в списке согласования.
|
// Решение → статус его строки в списке согласования.
|
||||||
const ROW_STATUS: Record<ApproverDecision, string> = {
|
const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||||
approve: REQUEST_STATUS.approved,
|
approve: REQUEST_STATUS.approved,
|
||||||
@@ -209,6 +218,40 @@ export class ApproverService {
|
|||||||
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
|
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавить в поданную заявку исполнителей, которых привела правка полей
|
||||||
|
* маршрута согласующим (КОГО — решает planMissingExecutors). Каждый встаёт в
|
||||||
|
* КОНЕЦ исполнения «Ждёт очереди», по тем же правилам, что addStep
|
||||||
|
* (planStepAdd), но без права редактора маршрута: людей назначает маршрут из
|
||||||
|
* админки, а не пользователь. SLA шага переносится из маршрута.
|
||||||
|
*
|
||||||
|
* Возвращает Id строк, ставших активными (обычно пусто); им нужно оповещение.
|
||||||
|
*/
|
||||||
|
public async addMissingExecutors(requestId: number, executors: IApproverToWrite[]): Promise<number[]> {
|
||||||
|
if (executors.length === 0) return [];
|
||||||
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
|
const activeBefore = new Set(rows.filter((r) => isActiveStatus(r.status)).map((r) => r.id));
|
||||||
|
|
||||||
|
// По одному и с локальным дописыванием rows: иначе второй исполнитель
|
||||||
|
// получил бы тот же order, что первый, и стал бы параллельным шагом.
|
||||||
|
const planned: IStepAddRow[] = rows.slice();
|
||||||
|
for (const executor of executors) {
|
||||||
|
const approverId = await this.approval.getUserIdByEmail(executor.email);
|
||||||
|
const plan = planStepAdd(planned, APPROVAL_PHASE.execution, approverId);
|
||||||
|
if (!plan.ok) throw new Error(plan.error);
|
||||||
|
await this.approval.addApproverRow(requestId, executor, plan.order, REQUEST_STATUS.queued, APPROVAL_PHASE.execution);
|
||||||
|
planned.push({ approverId, order: plan.order, status: REQUEST_STATUS.queued, phase: APPROVAL_PHASE.execution });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.approval.activateNextQueued(requestId);
|
||||||
|
const rowsAfter = await this.approval.getForRequest(requestId);
|
||||||
|
|
||||||
|
// Новым исполнителям — права на элемент данных (см. addStep).
|
||||||
|
await this.touchRequest(requestId);
|
||||||
|
emitRequestsChanged();
|
||||||
|
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
|
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
|
||||||
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
|
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
|
||||||
@@ -302,6 +345,7 @@ export class ApproverService {
|
|||||||
const rows = await this.approval.getForRequest(requestId);
|
const rows = await this.approval.getForRequest(requestId);
|
||||||
const requestStatus = this.computeRequestStatus(rows);
|
const requestStatus = this.computeRequestStatus(rows);
|
||||||
await this.updateRequestStatus(requestId, requestStatus);
|
await this.updateRequestStatus(requestId, requestStatus);
|
||||||
|
await this.logDecision(requestId, rowId, decision, comment, requestStatus);
|
||||||
emitRequestsChanged();
|
emitRequestsChanged();
|
||||||
return requestStatus;
|
return requestStatus;
|
||||||
}
|
}
|
||||||
@@ -330,6 +374,42 @@ export class ApproverService {
|
|||||||
return REQUEST_STATUS.execution;
|
return REQUEST_STATUS.execution;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Решение — в логи мониторинга, с кодом и названием заявки, чтобы её было видно прямо в списке логов.
|
||||||
|
* Код и название дочитываются отдельно; не получилось — пишем без них: решение уже применено.
|
||||||
|
*/
|
||||||
|
private async logDecision(
|
||||||
|
requestId: number,
|
||||||
|
rowId: number,
|
||||||
|
decision: ApproverDecision,
|
||||||
|
comment: string,
|
||||||
|
requestStatus: string
|
||||||
|
): Promise<void> {
|
||||||
|
let code = '';
|
||||||
|
let title = '';
|
||||||
|
try {
|
||||||
|
const item = (await this.dataList().items.getById(requestId).select('Title', 'RequestUniqueCode')()) as {
|
||||||
|
Title?: string;
|
||||||
|
RequestUniqueCode?: string;
|
||||||
|
};
|
||||||
|
code = item.RequestUniqueCode || '';
|
||||||
|
title = item.Title || '';
|
||||||
|
} catch {
|
||||||
|
// без кода и названия — лог всё равно нужен
|
||||||
|
}
|
||||||
|
const what = [code, title].filter(Boolean).join(' · ') || `№${requestId}`;
|
||||||
|
log.info(`${DECISION_MESSAGE[decision]}: ${what}`, {
|
||||||
|
'request.id': requestId,
|
||||||
|
'request.code': code,
|
||||||
|
'request.title': title,
|
||||||
|
'request.list': this.dataListRef,
|
||||||
|
'request.status': requestStatus,
|
||||||
|
'approval.decision': decision,
|
||||||
|
'approval.rowId': rowId,
|
||||||
|
'approval.comment': comment.slice(0, 500),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private dataList(): IList {
|
private dataList(): IList {
|
||||||
return this.dataListRef.startsWith('/')
|
return this.dataListRef.startsWith('/')
|
||||||
? getSP().web.getList(this.dataListRef)
|
? getSP().web.getList(this.dataListRef)
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export { canEditAllFields } from './fullEditRights';
|
|||||||
export { planQueuedStepMove, queuedStepMoveRule } from './queuedStepMove';
|
export { planQueuedStepMove, queuedStepMoveRule } from './queuedStepMove';
|
||||||
export type { StepMoveDirection, IStepMoveRow, IStepOrderChange } from './queuedStepMove';
|
export type { StepMoveDirection, IStepMoveRow, IStepOrderChange } from './queuedStepMove';
|
||||||
export { useQueuedStepMove } from './useQueuedStepMove';
|
export { useQueuedStepMove } from './useQueuedStepMove';
|
||||||
|
export { planMissingExecutors } from './missingExecutors';
|
||||||
|
export type { IPlannedPerson, ICurrentRoutePerson } from './missingExecutors';
|
||||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||||
export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
|
export { ExecutorsMatrix } from './ui/ExecutorsMatrix';
|
||||||
export { ApprovalHistory } from './history/ApprovalHistory';
|
export { ApprovalHistory } from './history/ApprovalHistory';
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { ICurrentRoutePerson, IPlannedPerson, planMissingExecutors } from './missingExecutors';
|
||||||
|
|
||||||
|
const APPROVAL = 'Согласование';
|
||||||
|
const EXECUTION = 'Исполнение';
|
||||||
|
|
||||||
|
const IT = 'it@tnsu.kz';
|
||||||
|
const FURNITURE = 'furniture@tnsu.kz';
|
||||||
|
const MANAGER = 'manager@tnsu.kz';
|
||||||
|
|
||||||
|
function planned(email: string): IPlannedPerson {
|
||||||
|
return { email };
|
||||||
|
}
|
||||||
|
|
||||||
|
function current(email: string, phase = EXECUTION): ICurrentRoutePerson {
|
||||||
|
return { email, phase };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('planMissingExecutors', () => {
|
||||||
|
it('добавляет исполнителя, которого привела правка', () => {
|
||||||
|
const result = planMissingExecutors([planned(IT)], [planned(IT), planned(FURNITURE)], [current(IT)]);
|
||||||
|
expect(result).toEqual([planned(FURNITURE)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('план не изменился — никого не добавляет', () => {
|
||||||
|
expect(planMissingExecutors([planned(IT)], [planned(IT)], [current(IT)])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не возвращает исполнителя, которого убрали вручную (он был в плане и до правки)', () => {
|
||||||
|
expect(planMissingExecutors([planned(IT)], [planned(IT), planned(FURNITURE)], [current(FURNITURE)])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не дублирует того, кто уже есть среди исполнителей (в любом статусе)', () => {
|
||||||
|
expect(planMissingExecutors([], [planned(FURNITURE)], [current(FURNITURE)])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('согласующий с тем же email — не помеха: в исполнение добавляем', () => {
|
||||||
|
expect(planMissingExecutors([], [planned(MANAGER)], [current(MANAGER, APPROVAL)])).toEqual([planned(MANAGER)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('сравнивает email без учёта регистра и пробелов', () => {
|
||||||
|
expect(planMissingExecutors([], [planned(' Furniture@TNSU.kz ')], [current(FURNITURE)])).toEqual([]);
|
||||||
|
expect(planMissingExecutors([planned('FURNITURE@tnsu.kz')], [planned(FURNITURE)], [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('пропускает шаг без назначенного человека', () => {
|
||||||
|
expect(planMissingExecutors([], [planned('')], [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('один человек на двух шагах нового плана — добавляет один раз', () => {
|
||||||
|
expect(planMissingExecutors([], [planned(FURNITURE), planned(FURNITURE)], [])).toEqual([planned(FURNITURE)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { APPROVAL_PHASE } from './models/statuses';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каких исполнителей добавить в УЖЕ ПОДАННУЮ заявку, когда согласующий поправил
|
||||||
|
* поля, от которых зависит маршрут (напр. «Мебель» в «Организации рабочего
|
||||||
|
* места»: отметил её руководитель — нужен исполнитель по мебели).
|
||||||
|
* Чистая функция без SharePoint: решает, КОГО добавить. Выполняет
|
||||||
|
* ApproverService.addMissingExecutors.
|
||||||
|
*
|
||||||
|
* Правила (согласованы с владельцем 2026-10-29):
|
||||||
|
* - добавляем только тех, кого привела ПРАВКА: есть в плане после неё и нет в
|
||||||
|
* плане до неё. Так не вернётся исполнитель, которого вручную заменили или
|
||||||
|
* удалили, и не подтянутся правки маршрута в админке, сделанные после подачи;
|
||||||
|
* - не добавляем того, кто уже есть среди исполнителей заявки в ЛЮБОМ статусе
|
||||||
|
* (ждёт, исполняет, выполнил, делегировал) — иначе дубль;
|
||||||
|
* - никого не удаляем: сняли мебель — исполнитель по мебели остаётся;
|
||||||
|
* - шаг без назначенного человека (пустой email) пропускаем: добавить некого.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** То, что нужно о человеке в плане маршрута; подходит IMatrixRow. */
|
||||||
|
export interface IPlannedPerson {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** То, что нужно о строке маршрута заявки; подходит IMatrixRow. */
|
||||||
|
export interface ICurrentRoutePerson {
|
||||||
|
email: string;
|
||||||
|
phase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharePoint и approval-route могут вернуть один и тот же email в разном регистре.
|
||||||
|
function normalizeEmail(email: string): string {
|
||||||
|
return email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planMissingExecutors<T extends IPlannedPerson>(
|
||||||
|
plannedBefore: IPlannedPerson[],
|
||||||
|
plannedAfter: T[],
|
||||||
|
currentRows: ICurrentRoutePerson[]
|
||||||
|
): T[] {
|
||||||
|
const wasPlanned = new Set(plannedBefore.map((p) => normalizeEmail(p.email)));
|
||||||
|
const alreadyInRoute = new Set(
|
||||||
|
currentRows.filter((r) => r.phase === APPROVAL_PHASE.execution).map((r) => normalizeEmail(r.email))
|
||||||
|
);
|
||||||
|
|
||||||
|
const toAdd: T[] = [];
|
||||||
|
plannedAfter.forEach((person) => {
|
||||||
|
const email = normalizeEmail(person.email);
|
||||||
|
if (!email || wasPlanned.has(email) || alreadyInRoute.has(email)) return;
|
||||||
|
// Один человек на нескольких шагах нового плана — добавляем один раз.
|
||||||
|
alreadyInRoute.add(email);
|
||||||
|
toAdd.push(person);
|
||||||
|
});
|
||||||
|
return toAdd;
|
||||||
|
}
|
||||||
@@ -52,6 +52,15 @@ export function getApprovalApiBaseUrl(): string {
|
|||||||
return _approvalApiBaseUrl;
|
return _approvalApiBaseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API БСУ (бетонно-смесительные узлы и их рецепты) — справочник для «Заявки на бетон».
|
||||||
|
* Токен — того же AAD-приложения, что у approval-route и ЭДО (проверено по aud рабочего
|
||||||
|
* токена). Если у БСУ появится своё приложение — поменять только эту константу и объявить
|
||||||
|
* доступ в package-solution.json (webApiPermissionRequests).
|
||||||
|
*/
|
||||||
|
export const BSU_API_CLIENT_ID = APPROVAL_API_CLIENT_ID;
|
||||||
|
export const BSU_API_BASE_URL = 'https://bsu.tnsu.kz/api/v1';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ЭДО (TANSU ЭДО) — подписание документов. Токен берётся для того же AAD-приложения, что и
|
* ЭДО (TANSU ЭДО) — подписание документов. Токен берётся для того же AAD-приложения, что и
|
||||||
* у approval-route и справочников: бэкенд ЭДО принимает токены сотрудников этого приложения
|
* у approval-route и справочников: бэкенд ЭДО принимает токены сотрудников этого приложения
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import '@pnp/sp/webs';
|
|||||||
import '@pnp/sp/lists';
|
import '@pnp/sp/lists';
|
||||||
import '@pnp/sp/items';
|
import '@pnp/sp/items';
|
||||||
import { IList } from '@pnp/sp/lists';
|
import { IList } from '@pnp/sp/lists';
|
||||||
|
import { log } from '@tnsu/monitoring-web';
|
||||||
import { getSP } from './pnp/pnpjs';
|
import { getSP } from './pnp/pnpjs';
|
||||||
import { buildRequestUniqueCode } from './requestCode';
|
import { buildRequestUniqueCode } from './requestCode';
|
||||||
import { dataListTitle } from './listTitles';
|
import { dataListTitle } from './listTitles';
|
||||||
import { RequestTypeId } from '../models/types';
|
import { RequestTypeId } from '../models/types';
|
||||||
|
import { REQUEST_TYPES } from '../data/requests';
|
||||||
|
import { isSubmittedStatus } from './approval/models/statuses';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Список данных, в который фактически пишем: listId (конкретная партиция —
|
* Список данных, в который фактически пишем: listId (конкретная партиция —
|
||||||
@@ -50,6 +53,7 @@ export async function saveRequestItem(
|
|||||||
|
|
||||||
if (existingId) {
|
if (existingId) {
|
||||||
await list.items.getById(existingId).update(item);
|
await list.items.getById(existingId).update(item);
|
||||||
|
logSaved(formId, existingId, undefined, item, false);
|
||||||
return { id: existingId };
|
return { id: existingId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,5 +62,34 @@ export async function saveRequestItem(
|
|||||||
// Код известен только после создания элемента (нужен его Id).
|
// Код известен только после создания элемента (нужен его Id).
|
||||||
const code = buildRequestUniqueCode(codePrefix, id, new Date());
|
const code = buildRequestUniqueCode(codePrefix, id, new Date());
|
||||||
await list.items.getById(id).update({ RequestUniqueCode: code });
|
await list.items.getById(id).update({ RequestUniqueCode: code });
|
||||||
|
logSaved(formId, id, code, item, true);
|
||||||
return { id, code };
|
return { id, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* События для мониторинга: какая заявка создана и отправлена на согласование (пользователь — из токена).
|
||||||
|
* В тексте — вид, код и название заявки, чтобы её было видно прямо в списке логов. Решения согласующих
|
||||||
|
* пишет approverDecision.service.ts.
|
||||||
|
*/
|
||||||
|
function logSaved(
|
||||||
|
formId: RequestTypeId,
|
||||||
|
id: number,
|
||||||
|
code: string | undefined,
|
||||||
|
item: Record<string, unknown>,
|
||||||
|
created: boolean
|
||||||
|
): void {
|
||||||
|
const typeTitle = REQUEST_TYPES.find((t) => t.id === formId)?.title ?? formId;
|
||||||
|
const title = typeof item.Title === 'string' ? item.Title : '';
|
||||||
|
const status = typeof item.RequestStatus === 'string' ? item.RequestStatus : '';
|
||||||
|
const what = [typeTitle, code, title].filter(Boolean).join(' · ');
|
||||||
|
const attributes = {
|
||||||
|
'request.type': formId,
|
||||||
|
'request.typeTitle': typeTitle,
|
||||||
|
'request.id': id,
|
||||||
|
'request.code': code ?? '',
|
||||||
|
'request.title': title,
|
||||||
|
'request.status': status,
|
||||||
|
};
|
||||||
|
if (created) log.info(`Создана заявка: ${what}`, attributes);
|
||||||
|
if (isSubmittedStatus(status)) log.info(`Заявка отправлена на согласование: ${what}`, attributes);
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,3 +50,4 @@ export {
|
|||||||
parseOrganizationLine
|
parseOrganizationLine
|
||||||
} from './organizationLines';
|
} from './organizationLines';
|
||||||
export type { IOrganizationLine } from './organizationLines';
|
export type { IOrganizationLine } from './organizationLines';
|
||||||
|
export { SOFTWARE_OPTIONS } from './software';
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ISelectOption } from '@tnsu/ui-kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Программы (ПО) — ВРЕМЕННЫЙ список, пока нет справочника в API. Общий для форм:
|
||||||
|
* «Заявка на подбор» (3.8 Необходимые знания в программах) и «Организация
|
||||||
|
* рабочего места» (Необходимое ПО). Пункт добавляется здесь — появляется в обеих.
|
||||||
|
*
|
||||||
|
* Значения уходят в условия маршрута как текст («Software содержит AutoCAD»),
|
||||||
|
* поэтому существующие значения не переименовывать без правки условий в админке.
|
||||||
|
*/
|
||||||
|
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' }
|
||||||
|
];
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Button, Icon } from '@tnsu/ui-kit';
|
||||||
|
import { printRoot } from './printRoot';
|
||||||
|
|
||||||
|
interface IPrintButtonProps {
|
||||||
|
label?: string;
|
||||||
|
/** Заголовок документа = имя PDF-файла (см. buildPrintTitle). Не задан — браузер назовёт сам. */
|
||||||
|
documentTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ВРЕМЕННО: локальная кнопка «Печать» вместо PrintButton из @tnsu/ui-kit — ради
|
||||||
|
* documentTitle (см. printRoot.ts). Вид и поведение те же, что у кита.
|
||||||
|
*/
|
||||||
|
export const PrintButton: React.FC<IPrintButtonProps> = ({ label = 'Печать', documentTitle }) => (
|
||||||
|
<Button variant="secondary" onClick={() => printRoot(undefined, documentTitle)}>
|
||||||
|
<Icon name="document" size="sm" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* ВРЕМЕННО (2026-10-29): локальная копия printRoot из @tnsu/ui-kit с одним
|
||||||
|
* отличием — заголовком окна печати (title). Браузер берёт его как имя файла при
|
||||||
|
* «Сохранить как PDF»; у кита окно без <title>, и файл назывался как попало.
|
||||||
|
* План: перенести параметр title в кит (сигнатура уже совместимая:
|
||||||
|
* printRoot(selector, title)), затем удалить этот файл и вернуть импорт из кита.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Значение поля для печати. Дату (input type=date, ISO yyyy-mm-dd) — в дд.мм.гггг.
|
||||||
|
function formatValue(el: HTMLInputElement): string {
|
||||||
|
if (el.type === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(el.value)) {
|
||||||
|
const [y, m, d] = el.value.split('-');
|
||||||
|
return `${d}.${m}.${y}`;
|
||||||
|
}
|
||||||
|
return el.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Печатаем как ДАННЫЕ, а не как форму: каждое поле ввода в клоне заменяем на
|
||||||
|
// обычный текст его значения (значения берём из живого DOM — React держит их как
|
||||||
|
// свойства, в клонированном HTML их нет).
|
||||||
|
function renderValuesAsText(source: Element, clone: Element): void {
|
||||||
|
const put = (cloneEl: Element | undefined, text: string): void => {
|
||||||
|
if (!cloneEl) return;
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'rp-print-value';
|
||||||
|
div.textContent = text && text.length > 0 ? text : '—';
|
||||||
|
(cloneEl.closest('label') ?? cloneEl).replaceWith(div);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cInputs = clone.querySelectorAll('input');
|
||||||
|
source.querySelectorAll('input').forEach((el, i) => {
|
||||||
|
if (el.type === 'checkbox' || el.type === 'radio') {
|
||||||
|
put(cInputs[i], el.checked ? 'Да' : 'Нет');
|
||||||
|
} else {
|
||||||
|
put(cInputs[i], formatValue(el));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cAreas = clone.querySelectorAll('textarea');
|
||||||
|
source.querySelectorAll('textarea').forEach((el, i) => put(cAreas[i], el.value));
|
||||||
|
|
||||||
|
const cSelects = clone.querySelectorAll('select');
|
||||||
|
source.querySelectorAll('select').forEach((el, i) => {
|
||||||
|
const opt = el.options[el.selectedIndex];
|
||||||
|
put(cSelects[i], opt && opt.value ? opt.text : '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стили печатного окна — как в ките (см. комментарии там).
|
||||||
|
const PRINT_CSS =
|
||||||
|
'html,body{margin:0;background:#fff;height:auto!important;min-height:0!important}' +
|
||||||
|
'body{padding:24px;font-size:13px}' +
|
||||||
|
'svg{display:none!important}' +
|
||||||
|
'.ms-Persona-imageArea,.ms-Persona-image{display:none!important}' +
|
||||||
|
'.rp-print-root>*{border:0!important;box-shadow:none!important;padding:0!important}' +
|
||||||
|
'.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запасной путь — печать самой страницы (нет корня печати или всплывающее окно
|
||||||
|
* заблокировано). Заголовок страницы подменяем на время диалога, чтобы и тут
|
||||||
|
* имя файла было осмысленным; window.print() ждёт закрытия диалога.
|
||||||
|
*/
|
||||||
|
function printCurrentPage(title?: string): void {
|
||||||
|
if (!title) {
|
||||||
|
window.print();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pageTitle = document.title;
|
||||||
|
document.title = title;
|
||||||
|
window.print();
|
||||||
|
document.title = pageTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переносит документ (по умолчанию `.rp-print-root`) в отдельное чистое окно,
|
||||||
|
* заменяя поля ввода на текст значений, и печатает его. title — заголовок окна,
|
||||||
|
* он же имя файла при сохранении в PDF (см. buildPrintTitle).
|
||||||
|
*/
|
||||||
|
export function printRoot(selector = '.rp-print-root', title?: string): void {
|
||||||
|
const source = document.querySelector(selector);
|
||||||
|
if (!source) {
|
||||||
|
printCurrentPage(title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const win = window.open('', '_blank', 'width=980,height=1200');
|
||||||
|
if (!win) {
|
||||||
|
printCurrentPage(title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = source.cloneNode(true) as Element;
|
||||||
|
clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove());
|
||||||
|
renderValuesAsText(source, clone);
|
||||||
|
|
||||||
|
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
||||||
|
.map((n) => n.outerHTML)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
win.document.open();
|
||||||
|
win.document.write(
|
||||||
|
`<!doctype html><html><head><meta charset="utf-8">${head}` +
|
||||||
|
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
|
||||||
|
);
|
||||||
|
win.document.close();
|
||||||
|
// Через свойство, а не тегом в разметке выше: так текст не надо экранировать.
|
||||||
|
if (title) win.document.title = title;
|
||||||
|
win.focus();
|
||||||
|
|
||||||
|
const done = (): void => {
|
||||||
|
win.print();
|
||||||
|
win.close();
|
||||||
|
};
|
||||||
|
if (win.document.readyState === 'complete') {
|
||||||
|
window.setTimeout(done, 350);
|
||||||
|
} else {
|
||||||
|
win.onload = (): void => {
|
||||||
|
window.setTimeout(done, 350);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPrintTitleParts {
|
||||||
|
/** Название формы, как в шапке карточки. */
|
||||||
|
formTitle: string;
|
||||||
|
/** ФИО сотрудника (кандидата), о ком заявка; '' — пропускаем. Берётся как есть. */
|
||||||
|
personName: string;
|
||||||
|
/** Уникальный код заявки; '' у старых заявок — тогда номер элемента. */
|
||||||
|
code: string;
|
||||||
|
itemId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имя печатного документа (и PDF-файла):
|
||||||
|
* «<Название формы> — <ФИО> №<код>», напр. «Заявка на отпуск — Иванов Иван №HR-2610-45».
|
||||||
|
* Номер — как в шапке карточки (код, а у старых заявок — номер элемента).
|
||||||
|
*/
|
||||||
|
export function buildPrintTitle({ formTitle, personName, code, itemId }: IPrintTitleParts): string {
|
||||||
|
const name = personName.trim();
|
||||||
|
const head = name ? `${formTitle} — ${name}` : formTitle;
|
||||||
|
return `${head} №${code || itemId}`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { DropdownButton, Icon, PrintButton, printRoot } from '@tnsu/ui-kit';
|
import { DropdownButton, Icon } from '@tnsu/ui-kit';
|
||||||
|
import { PrintButton } from '../PrintButton/PrintButton';
|
||||||
|
import { printRoot } from '../PrintButton/printRoot';
|
||||||
|
|
||||||
interface IPrintSplitButtonProps {
|
interface IPrintSplitButtonProps {
|
||||||
/** `\n`-joined «Компания — должность», как в data.Organization. */
|
/** `\n`-joined «Компания — должность», как в data.Organization. */
|
||||||
@@ -7,6 +9,8 @@ interface IPrintSplitButtonProps {
|
|||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onChange: (index: number) => void;
|
onChange: (index: number) => void;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Заголовок документа = имя PDF-файла (см. buildPrintTitle). */
|
||||||
|
documentTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +23,8 @@ export const PrintSplitButton: React.FC<IPrintSplitButtonProps> = ({
|
|||||||
value,
|
value,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
onChange,
|
onChange,
|
||||||
label = 'Печать'
|
label = 'Печать',
|
||||||
|
documentTitle
|
||||||
}) => {
|
}) => {
|
||||||
const [pendingPrint, setPendingPrint] = React.useState(false);
|
const [pendingPrint, setPendingPrint] = React.useState(false);
|
||||||
const lines = value ? value.split('\n') : [];
|
const lines = value ? value.split('\n') : [];
|
||||||
@@ -31,11 +36,11 @@ export const PrintSplitButton: React.FC<IPrintSplitButtonProps> = ({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!pendingPrint) return;
|
if (!pendingPrint) return;
|
||||||
setPendingPrint(false);
|
setPendingPrint(false);
|
||||||
printRoot();
|
printRoot(undefined, documentTitle);
|
||||||
}, [pendingPrint]);
|
}, [pendingPrint, documentTitle]);
|
||||||
|
|
||||||
if (lines.length <= 1) {
|
if (lines.length <= 1) {
|
||||||
return <PrintButton label={label} />;
|
return <PrintButton label={label} documentTitle={documentTitle} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = lines.map((line, i) => ({ value: String(i), label: line }));
|
const options = lines.map((line, i) => ({ value: String(i), label: line }));
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ export {
|
|||||||
Icon,
|
Icon,
|
||||||
ConfirmHost,
|
ConfirmHost,
|
||||||
confirmModal,
|
confirmModal,
|
||||||
RequestNumber,
|
RequestNumber
|
||||||
PrintButton
|
|
||||||
} from '@tnsu/ui-kit';
|
} from '@tnsu/ui-kit';
|
||||||
export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit';
|
export type { ISelectOption, IConfirmOptions } from '@tnsu/ui-kit';
|
||||||
|
|
||||||
@@ -48,6 +47,9 @@ export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
|||||||
export { PeopleTextField } from './PeopleField/PeopleTextField';
|
export { PeopleTextField } from './PeopleField/PeopleTextField';
|
||||||
export { OrganizationField } from './OrganizationField/OrganizationField';
|
export { OrganizationField } from './OrganizationField/OrganizationField';
|
||||||
export { OrganizationTable } from './OrganizationField/OrganizationTable';
|
export { OrganizationTable } from './OrganizationField/OrganizationTable';
|
||||||
|
// PrintButton — ВРЕМЕННО локальный (заголовок = имя PDF), пока параметр title не перенесён в ui-kit.
|
||||||
|
export { PrintButton } from './PrintButton/PrintButton';
|
||||||
|
export { buildPrintTitle } from './PrintButton/printRoot';
|
||||||
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
|
export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton';
|
||||||
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead';
|
||||||
export { PrintSignature } from './PrintSignature/PrintSignature';
|
export { PrintSignature } from './PrintSignature/PrintSignature';
|
||||||
|
|||||||
Reference in New Issue
Block a user