4 Commits

Author SHA1 Message Date
gorbenko.pavel e34d290865 Мониторинг: логи, ошибки и запись экрана портала заявок (@tnsu/monitoring-web) (#29)
Tests / frontend-test (push) Successful in 1m24s
Логи, ошибки и запись экрана портала заявок в центральный мониторинг (monitoring.tnsu.kz).

**Что поменялось**
- `@tnsu/monitoring-web` 0.3.1 из реестра tansu.
- `RequestPortalWebPart.onInit`: `initMonitoring({ service: 'request-portal', getAccessToken: spfxTokenProvider(this.context.aadTokenProviderFactory) })` — до `installErrorLogging`. Один на страницу (экземпляров веб-части бывает несколько) — это делает сама библиотека.
- `config/package-solution.json`: запрос `Logs.Write` к регистрации мониторинга `9232d9e2-…` (рядом с уже запрошенным `Reports.Read` той же регистрации).
- Хэш коммита сборки (`git.commit` в логах, «Сборка» в плеере): `config/webpack-patch.json` ссылается на патч из пакета (`node_modules/@tnsu/monitoring-web/spfx/webpack-patch.cjs`), он подставляет `__TNSU_GIT_COMMIT__` из `GIT_COMMIT`, `GITHUB_SHA` или `git rev-parse`. Пайплайн менять не нужно.

**Что пишется**
- Необработанные ошибки и отказы промисов, `console.error` (в том числе вывод `installErrorLogging`), `log.*`.
- Запись экрана (rrweb) — **ничего не маскируется**, записывается вся страница SharePoint, не только веб-часть. Неудачные запросы — вкладка «Сеть» в плеере.
- В workbench / при `gulp serve` окружение `development` — в админке мониторинга такие записи по умолчанию скрыты.
- Нет связи с мониторингом час — тост «Ошибка №… Обратитесь к администратору» (не чаще 3 раз в сутки).

**Проверено:** продакшен-сборка (`heft build --production`) проходит, патч применяется, хэш коммита попадает в бандл веб-части.

**После выкатки .sppkg** администратор один раз одобряет запрос: SharePoint admin center → Advanced → API access → `Logs.Write` → Approve. Пока не одобрено, токена нет и данные копятся в браузере (до 50 МБ), портал работает как обычно.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: #29
2026-10-30 14:24:58 +00:00
Тимур Городецкий b1060433d0 feat: «Необходимое ПО» в рабочем месте — мультивыбор из списка
Tests / frontend-test (push) Successful in 1m28s
Вместо свободного текста — выбор из общего списка ПО (как в подборе) со
своим значением. В SP и в маршрут по-прежнему уходит текст через запятую в
колонке Software: условия «Software содержит …» и старые заявки работают
без изменений.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 09:58:32 +05:00
Тимур Городецкий cf039f4d08 refactor: общий список ПО в shared/dictionary
Список программ жил в модели «Заявки на подбор»; теперь он нужен и
«Организации рабочего места». Вынесен в shared/dictionary/software.ts, чтобы
обе формы брали его из одного места.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 09:58:31 +05:00
Тимур Городецкий 88ce1d498e feat: выбор БСУ и рецепта в заявке на бетон
Tests / frontend-test (push) Successful in 1m24s
Заявка заказывалась свободным текстом, без привязки к реальным рецептам
завода. Теперь БСУ выбирается из API bsu.tnsu.kz (BsuId/BsuName в шапке),
а класс смеси в позиции — из рецептов этого БСУ (formulaId + снимок
состава, т.к. на заводе рецепт могут поменять). Состав показывается
плашками. Если API не ответил, отправку не блокируем — ручной ввод.

Новые колонки BsuId/BsuName нужно провизионировать до деплоя.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 09:34:35 +05:00
31 changed files with 796 additions and 73 deletions
+4
View File
@@ -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"
+4
View File
@@ -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"]
}
+132 -24
View File
@@ -17,6 +17,8 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@pnp/telemetry-js": "2.0.0",
"@tnsu/monitoring-web": "^0.3.4",
"@tnsu/ui-kit": "^0.15.0", "@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0", "jszip": "~3.8.0",
"react": "17.0.1", "react": "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,6 +9909,28 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@tnsu/monitoring-web": {
"version": "0.3.4",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fmonitoring-web/-/0.3.4/monitoring-web-0.3.4.tgz",
"integrity": "sha512-tQe0ZM1tBVO3XtbNFliRaaooqvCX3gb/Q/j2lV6+rYCXprodBZByQ6PLrCu/P0me/rW8KdQ02hu7avyLXXfkoQ==",
"license": "UNLICENSED",
"dependencies": {
"@rrweb/rrweb-plugin-console-record": "^2.2.0",
"rrweb": "^2.2.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/@tnsu/ui-kit": { "node_modules/@tnsu/ui-kit": {
"version": "0.15.0", "version": "0.15.0",
"resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.15.0/ui-kit-0.15.0.tgz", "resolved": "https://git.tnsu.kz/api/packages/tansu/npm/%40tnsu%2Fui-kit/-/0.15.0/ui-kit-0.15.0.tgz",
@@ -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",
+2
View File
@@ -21,6 +21,8 @@
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0", "@pnp/sp": "^4.20.0",
"@pnp/spfx-controls-react": "^3.25.0", "@pnp/spfx-controls-react": "^3.25.0",
"@pnp/telemetry-js": "2.0.0",
"@tnsu/monitoring-web": "^0.3.4",
"@tnsu/ui-kit": "^0.15.0", "@tnsu/ui-kit": "^0.15.0",
"jszip": "~3.8.0", "jszip": "~3.8.0",
"react": "17.0.1", "react": "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 должен быть выставлен ДО загрузки партиций — они
@@ -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
}; };
// «Удалить черновик» — только у сохранённой заявки в статусе «Черновик». // «Удалить черновик» — только у сохранённой заявки в статусе «Черновик».
@@ -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>
);
};
@@ -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';
@@ -28,6 +28,10 @@
**первого** шага согласования и это текущий пользователь; сохраняются кнопкой **первого** шага согласования и это текущий пользователь; сохраняются кнопкой
«Сохранить» и автоматически при «Согласовать» (`updateReviewFields`, «Сохранить» и автоматически при «Согласовать» (`updateReviewFields`,
`DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only. `DecisionPanel.onBeforeDecide`). Остальная форма при этом read-only.
- **Необходимое ПО** — мультивыбор из общего списка `shared/dictionary/software.ts`
(тот же, что в подборе) + своё значение. В SP и в маршрут — по-прежнему ТЕКСТОМ
в колонке `Software` через запятую (`softwareList.ts`): условия «Software содержит
AutoCAD» и старые заявки со свободным текстом работают как раньше.
- **Мебель** — мультивыбор (`FURNITURE_OPTIONS`); при «Другое» появляется поле - **Мебель** — мультивыбор (`FURNITURE_OPTIONS`); при «Другое» появляется поле
своего значения. В SP — `WorkplaceFurnitureJson` (JSON-массив) и своего значения. В SP — `WorkplaceFurnitureJson` (JSON-массив) и
`WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как `WorkplaceFurnitureOther`. Необязательная. Уходит в маршрут как
@@ -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,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,3 +1,4 @@
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';
@@ -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,
@@ -336,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;
} }
@@ -364,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)
@@ -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' }
];