commit f300cd076f79d5f73d35ae77389db28d451f96f9 Author: Christoph Hehl Date: Sun May 10 10:46:05 2026 +0200 br-office-suite diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e8d0004 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +NODE_ENV=development +PORT=8080 +CORS_ORIGIN=http://localhost:5173 +JWT_ACCESS_SECRET=change-me-access +JWT_REFRESH_SECRET=change-me-refresh +COOKIE_SECURE=false +FORCE_MFA_FOR_PRIVILEGED=true +RP_ID=localhost +RP_ORIGIN=http://localhost:5173 +DATABASE_URL=postgres://br_app:change_me@localhost:5432/br_office +UPLOAD_DIR=./uploads +STORAGE_PROVIDER=local +S3_ENDPOINT=http://localhost:9000 +S3_BUCKET=br-office +S3_ACCESS_KEY=minio +S3_SECRET_KEY=miniosecret +CLAMAV_HOST=localhost +CLAMAV_PORT=3310 +VITE_API_BASE_URL=http://localhost:8080/api + +FILE_ENCRYPTION_ENABLED=true +FILE_ENCRYPTION_KEY=please-change-this-encryption-key +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=smtp-user@example.com +SMTP_PASS=please-change-this-password +SMTP_FROM=BR Office +SMTP_SECURE=false +SMTP_REQUIRE_TLS=true diff --git a/BR-Office-Suite_Handbuch_Testbeschreibung_API_mit_Installation_Fehlerhandling.docx b/BR-Office-Suite_Handbuch_Testbeschreibung_API_mit_Installation_Fehlerhandling.docx new file mode 100644 index 0000000..df892a0 Binary files /dev/null and b/BR-Office-Suite_Handbuch_Testbeschreibung_API_mit_Installation_Fehlerhandling.docx differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1a25e4 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# BR-Office Complete Suite + +Vollständiges Referenzpaket für eine Betriebsrats-Web-App mit: + +- React/Vite-Frontend im iOS-inspirierten Stil +- Express/PostgreSQL-Backend +- WebAuthn/Passkey + TOTP vollständig verdrahtet +- CRUD-Masken für alle Fachmodule aus Lasten-/Pflichtenheft +- PostgreSQL-Migrationen inkl. RLS, Audit, Auth und Dokumenten-Scan-Metadaten +- produktionsnahe Sicherheitsvorgaben + +## Module +- Dashboard +- Gremien/Ausschüsse +- Benutzer & Rollen +- Vorgänge +- Fristen +- Sitzungen +- TOPs +- Beschlüsse +- Aufgaben +- Dokumente +- Kommentare +- Vorlagen +- Benachrichtigungen +- Audit + +## Start lokal +Siehe `docs/DEPLOYMENT.md`. + +## Hinweise +Dieses Paket ist bewusst als vollständige, gut erweiterbare Referenz aufgebaut. Für produktiven Einsatz +sollten Secrets, TLS-Zertifikate, Hardware-Sicherheitsrichtlinien, Backup/Restore-Tests und ein formaler +Security-/Datenschutz-Review zusätzlich finalisiert werden. + + +## Lokale Konfiguration +Die Datei `docker-compose.local.yml` enthält jetzt direkt editierbare Umgebungsvariablen für: + +- Datei-Verschlüsselung bei Uploads (`FILE_ENCRYPTION_ENABLED`, `FILE_ENCRYPTION_KEY`) +- SMTP-Zugangsdaten (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`) + +Uploads werden bei lokaler Speicherung verschlüsselt abgelegt und beim Download transparent entschlüsselt. diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..acf2384 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2072 @@ +{ + "name": "br-office-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "br-office-backend", + "version": "1.0.0", + "dependencies": { + "@simplewebauthn/server": "^10.0.1", + "argon2": "^0.41.1", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "express-rate-limit": "^7.4.1", + "file-type": "^19.5.0", + "helmet": "^8.0.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "nodemailer": "8.0.5", + "otpauth": "^9.3.6", + "pg": "^8.12.0", + "qrcode": "^1.5.4", + "uuid": "^10.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-10.0.1.tgz", + "integrity": "sha512-djNWcRn+H+6zvihBFJSpG3fzb0NQS9c/Mw5dYOtZ9H+oDw8qn9Htqxt4cpqRvSOAfwqP7rOvE9rwqVaoGGc3hg==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^10.0.0", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz", + "integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/argon2": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.41.1.tgz", + "integrity": "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^8.1.0", + "node-gyp-build": "^4.8.1" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/file-type": { + "version": "19.6.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-19.6.0.tgz", + "integrity": "sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ==", + "license": "MIT", + "dependencies": { + "get-stream": "^9.0.1", + "strtok3": "^9.0.1", + "token-types": "^6.0.0", + "uint8array-extras": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemailer": { + "version": "8.0.5", + "resolved": "https://packages.applied-caas-gateway1.internal.api.openai.org/artifactory/api/npm/npm-public/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/otpauth": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz", + "integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/peek-readable": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.4.2.tgz", + "integrity": "sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strtok3": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-9.1.1.tgz", + "integrity": "sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^5.3.1" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..5be4c42 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,28 @@ +{ + "name": "br-office-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "node --watch src/server.js", + "start": "node src/server.js" + }, + "dependencies": { + "@simplewebauthn/server": "^10.0.1", + "argon2": "^0.41.1", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "express-rate-limit": "^7.4.1", + "file-type": "^19.5.0", + "helmet": "^8.0.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "nodemailer": "8.0.5", + "otpauth": "^9.3.6", + "pg": "^8.12.0", + "qrcode": "^1.5.4", + "uuid": "^10.0.0", + "zod": "^3.23.8" + } +} diff --git a/backend/src/config/db.js b/backend/src/config/db.js new file mode 100644 index 0000000..68c543e --- /dev/null +++ b/backend/src/config/db.js @@ -0,0 +1,32 @@ +import pg from "pg"; +import { env } from "./env.js"; +const { Pool } = pg; + +export const pool = new Pool({ + connectionString: env.databaseUrl, + max: 20, + idleTimeoutMillis: 30000, + ssl: env.nodeEnv === "production" ? { rejectUnauthorized: false } : false, +}); + +export async function query(text, params = []) { + return pool.query(text, params); +} + +export async function withDbSession(user, callback) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT set_config('app.user_id', $1, true)", [String(user?.sub || 0)]); + await client.query("SELECT set_config('app.user_role', $1, true)", [String(user?.role || 'anonymous')]); + await client.query("SELECT set_config('app.committee_id', $1, true)", [String(user?.committee_id || 0)]); + const result = await callback(client); + await client.query("COMMIT"); + return result; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } +} diff --git a/backend/src/config/env.js b/backend/src/config/env.js new file mode 100644 index 0000000..8ea080b --- /dev/null +++ b/backend/src/config/env.js @@ -0,0 +1,32 @@ +import dotenv from "dotenv"; +dotenv.config(); + +export const env = { + nodeEnv: process.env.NODE_ENV || "development", + port: Number(process.env.PORT || 8080), + corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173", + jwtAccessSecret: process.env.JWT_ACCESS_SECRET || "change-me-access", + jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || "change-me-refresh", + cookieSecure: String(process.env.COOKIE_SECURE || "false") === "true", + forceMfaForPrivileged: String(process.env.FORCE_MFA_FOR_PRIVILEGED || "true") === "true", + rpId: process.env.RP_ID || "localhost", + rpOrigin: process.env.RP_ORIGIN || "http://localhost:5173", + databaseUrl: process.env.DATABASE_URL || "postgres://br_app:change_me@localhost:5432/br_office", + uploadDir: process.env.UPLOAD_DIR || "./uploads", + storageProvider: process.env.STORAGE_PROVIDER || "local", + s3Endpoint: process.env.S3_ENDPOINT || "", + s3Bucket: process.env.S3_BUCKET || "br-office", + s3AccessKey: process.env.S3_ACCESS_KEY || "", + s3SecretKey: process.env.S3_SECRET_KEY || "", + clamavHost: process.env.CLAMAV_HOST || "localhost", + clamavPort: Number(process.env.CLAMAV_PORT || 3310), + fileEncryptionEnabled: String(process.env.FILE_ENCRYPTION_ENABLED || "true") === "true", + fileEncryptionKey: process.env.FILE_ENCRYPTION_KEY || "", + smtpHost: process.env.SMTP_HOST || "", + smtpPort: Number(process.env.SMTP_PORT || 587), + smtpUser: process.env.SMTP_USER || "", + smtpPass: process.env.SMTP_PASS || "", + smtpFrom: process.env.SMTP_FROM || "br-office@localhost", + smtpSecure: String(process.env.SMTP_SECURE || "false") === "true", + smtpRequireTls: String(process.env.SMTP_REQUIRE_TLS || "false") === "true", +}; diff --git a/backend/src/db/000_migration_table.sql b/backend/src/db/000_migration_table.sql new file mode 100644 index 0000000..836843f --- /dev/null +++ b/backend/src/db/000_migration_table.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS schema_migration ( + id BIGSERIAL PRIMARY KEY, + file_name VARCHAR(255) NOT NULL UNIQUE, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/src/db/bootstrap.js b/backend/src/db/bootstrap.js new file mode 100644 index 0000000..23de5a0 --- /dev/null +++ b/backend/src/db/bootstrap.js @@ -0,0 +1,155 @@ +import fs from "fs/promises"; +import path from "path"; +import argon2 from "argon2"; +import pkg from "pg"; + +const { Pool } = pkg; + +const MIGRATIONS_DIR = path.resolve(process.cwd(), "src/db/migrations"); +const BOOTSTRAP_SQL = path.resolve(process.cwd(), "src/db/000_migration_table.sql"); + +function isSqlFile(name) { + return name.endsWith(".sql"); +} + +async function ensureMigrationTable(client) { + const sql = await fs.readFile(BOOTSTRAP_SQL, "utf8"); + await client.query(sql); +} + +async function getAppliedMigrations(client) { + const { rows } = await client.query( + "SELECT file_name FROM schema_migration ORDER BY file_name" + ); + return new Set(rows.map((r) => r.file_name)); +} + +async function applyMigration(client, fileName, sql) { + await client.query("BEGIN"); + try { + await client.query(sql); + await client.query( + "INSERT INTO schema_migration (file_name) VALUES ($1) ON CONFLICT (file_name) DO NOTHING", + [fileName] + ); + await client.query("COMMIT"); + console.log(`[db] migration applied: ${fileName}`); + } catch (error) { + await client.query("ROLLBACK"); + throw new Error(`Migration failed: ${fileName}\n${error.message}`); + } +} + +async function ensureDefaultCommittee(client) { + await client.query( + ` + INSERT INTO committee (name, committee_type) + SELECT 'Gesamtbetriebsrat', 'general' + WHERE NOT EXISTS ( + SELECT 1 FROM committee WHERE name = 'Gesamtbetriebsrat' + ) + ` + ); +} + +async function ensureDefaultAdmin(client) { + const adminEmail = process.env.BOOTSTRAP_ADMIN_EMAIL || "admin@br-office.local"; + const adminPassword = process.env.BOOTSTRAP_ADMIN_PASSWORD || "adminadmin"; + + const existing = await client.query( + ` + SELECT id, email, role + FROM app_user + WHERE LOWER(email) = LOWER($1) + LIMIT 1 + `, + [adminEmail] + ); + + if (existing.rowCount > 0) { + console.log(`[db] default admin already exists: ${adminEmail}`); + return; + } + + const committee = await client.query( + ` + SELECT id + FROM committee + ORDER BY id + LIMIT 1 + ` + ); + + const committeeId = committee.rows[0]?.id ?? null; + const passwordHash = await argon2.hash(adminPassword); + await client.query( + ` + INSERT INTO app_user ( + full_name, + email, + password_hash, + role, + committee_id, + is_active, + security_classification, + force_password_reset, + two_factor_enabled + ) + VALUES ($1, $2, $3, 'admin', $4, TRUE, 'restricted', TRUE, FALSE) + `, + ["Systemadministrator", adminEmail, passwordHash, committeeId] + ); + + console.log(`[db] default admin created: ${adminEmail}`); +} + +export async function runBootstrapMigrations() { + const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + }); + + const client = await pool.connect(); + + try { + console.log("[db] bootstrap started"); + await ensureMigrationTable(client); + + const files = (await fs.readdir(MIGRATIONS_DIR)) + .filter(isSqlFile) + .sort((a, b) => a.localeCompare(b)); + + const applied = await getAppliedMigrations(client); + + for (const fileName of files) { + if (applied.has(fileName)) { + continue; + } + + const fullPath = path.join(MIGRATIONS_DIR, fileName); + const sql = await fs.readFile(fullPath, "utf8"); + await applyMigration(client, fileName, sql); + } + + // Nur ausführen, wenn Kernschema vorhanden ist + const tableCheck = await client.query(` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'app_user' + ) AS exists + `); + + if (tableCheck.rows[0]?.exists) { + await ensureDefaultCommittee(client); + await ensureDefaultAdmin(client); + } else { + console.warn("[db] app_user table missing; admin bootstrap skipped"); + } + + console.log("[db] bootstrap finished"); + } finally { + client.release(); + await pool.end(); + } +} diff --git a/backend/src/db/migrations/001_extensions_and_types.sql b/backend/src/db/migrations/001_extensions_and_types.sql new file mode 100644 index 0000000..740d5c4 --- /dev/null +++ b/backend/src/db/migrations/001_extensions_and_types.sql @@ -0,0 +1,14 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN + CREATE TYPE role_type AS ENUM ('admin','chair','member','secretary','advisor'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'conf_level') THEN + CREATE TYPE conf_level AS ENUM ('intern','vertraulich','streng_vertraulich'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scan_status_type') THEN + CREATE TYPE scan_status_type AS ENUM ('pending','clean','infected','failed'); + END IF; +END$$; diff --git a/backend/src/db/migrations/002_core_schema.sql b/backend/src/db/migrations/002_core_schema.sql new file mode 100644 index 0000000..75236c9 --- /dev/null +++ b/backend/src/db/migrations/002_core_schema.sql @@ -0,0 +1,268 @@ +CREATE TABLE IF NOT EXISTS committee ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + committee_type VARCHAR(50) NOT NULL DEFAULT 'general', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_user ( + id BIGSERIAL PRIMARY KEY, + full_name VARCHAR(120) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash TEXT NOT NULL DEFAULT '', + role role_type NOT NULL DEFAULT 'member', + committee_id BIGINT REFERENCES committee(id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + security_classification VARCHAR(30) NOT NULL DEFAULT 'standard', + force_password_reset BOOLEAN NOT NULL DEFAULT FALSE, + two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE, + last_mfa_at TIMESTAMPTZ, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS refresh_token ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS br_case ( + id BIGSERIAL PRIMARY KEY, + case_number VARCHAR(50) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + case_type VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'neu', + priority VARCHAR(20) NOT NULL DEFAULT 'mittel', + confidentiality_level conf_level NOT NULL DEFAULT 'intern', + summary TEXT, + affected_employee_name VARCHAR(120), + department VARCHAR(120), + committee_id BIGINT REFERENCES committee(id), + owner_user_id BIGINT NOT NULL REFERENCES app_user(id), + legal_hold BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS deadline ( + id BIGSERIAL PRIMARY KEY, + case_id BIGINT REFERENCES br_case(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + deadline_type VARCHAR(50) NOT NULL, + due_date TIMESTAMPTZ NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'offen', + remind_days_before INTEGER NOT NULL DEFAULT 2, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS meeting ( + id BIGSERIAL PRIMARY KEY, + meeting_number VARCHAR(50) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + meeting_type VARCHAR(50) NOT NULL DEFAULT 'ordentlich', + meeting_date DATE NOT NULL, + start_time TIME, + end_time TIME, + location VARCHAR(255), + status VARCHAR(30) NOT NULL DEFAULT 'geplant', + protocol_text TEXT, + is_quorate BOOLEAN, + quorum_recorded_at TIMESTAMPTZ, + quorum_recorded_by BIGINT REFERENCES app_user(id), + committee_id BIGINT REFERENCES committee(id), + chair_user_id BIGINT REFERENCES app_user(id), + secretary_user_id BIGINT REFERENCES app_user(id), + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS meeting_agenda_item ( + id BIGSERIAL PRIMARY KEY, + meeting_id BIGINT NOT NULL REFERENCES meeting(id) ON DELETE CASCADE, + agenda_number VARCHAR(20) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + duration_minutes INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS decision ( + id BIGSERIAL PRIMARY KEY, + decision_number VARCHAR(50) UNIQUE NOT NULL, + meeting_id BIGINT REFERENCES meeting(id) ON DELETE SET NULL, + agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL, + case_id BIGINT REFERENCES br_case(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + decision_text TEXT NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'offen', + votes_yes INTEGER NOT NULL DEFAULT 0, + votes_no INTEGER NOT NULL DEFAULT 0, + votes_abstain INTEGER NOT NULL DEFAULT 0, + responsible_user_id BIGINT REFERENCES app_user(id), + due_date TIMESTAMPTZ, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS task ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(30) NOT NULL DEFAULT 'offen', + priority VARCHAR(20) NOT NULL DEFAULT 'mittel', + assigned_user_id BIGINT REFERENCES app_user(id), + backup_user_id BIGINT REFERENCES app_user(id), + case_id BIGINT REFERENCES br_case(id) ON DELETE SET NULL, + meeting_id BIGINT REFERENCES meeting(id) ON DELETE SET NULL, + decision_id BIGINT REFERENCES decision(id) ON DELETE SET NULL, + due_date TIMESTAMPTZ, + confidentiality_level conf_level NOT NULL DEFAULT 'intern', + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS document_item ( + id BIGSERIAL PRIMARY KEY, + document_number VARCHAR(50) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + document_type VARCHAR(50) NOT NULL, + description TEXT, + version_label VARCHAR(30) NOT NULL DEFAULT 'v1', + file_name VARCHAR(255), + storage_path TEXT, + storage_provider VARCHAR(20) NOT NULL DEFAULT 'local', + mime_type VARCHAR(100), + size_bytes BIGINT, + sha256_hash VARCHAR(64), + confidentiality_level conf_level NOT NULL DEFAULT 'intern', + case_id BIGINT REFERENCES br_case(id) ON DELETE SET NULL, + meeting_id BIGINT REFERENCES meeting(id) ON DELETE SET NULL, + decision_id BIGINT REFERENCES decision(id) ON DELETE SET NULL, + uploaded_by BIGINT REFERENCES app_user(id), + is_signed BOOLEAN NOT NULL DEFAULT FALSE, + scan_status scan_status_type NOT NULL DEFAULT 'pending', + scan_engine VARCHAR(50), + scan_result TEXT, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS document_scan_event ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT NOT NULL REFERENCES document_item(id) ON DELETE CASCADE, + scanned_by_user_id BIGINT REFERENCES app_user(id), + status scan_status_type NOT NULL, + engine VARCHAR(50) NOT NULL, + result_text TEXT, + scanned_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS comment_item ( + id BIGSERIAL PRIMARY KEY, + entity_type VARCHAR(50) NOT NULL, + entity_id BIGINT NOT NULL, + comment_text TEXT NOT NULL, + visibility VARCHAR(30) NOT NULL DEFAULT 'intern', + author_user_id BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS template_item ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + category VARCHAR(50) NOT NULL, + description TEXT, + content TEXT NOT NULL, + version_label VARCHAR(30) NOT NULL DEFAULT 'v1', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS notification_item ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + notification_type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + entity_type VARCHAR(50), + entity_id BIGINT, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGSERIAL PRIMARY KEY, + actor_user_id BIGINT REFERENCES app_user(id), + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50), + entity_id BIGINT, + metadata JSONB, + ip_address INET, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS auth_challenge ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + challenge_type VARCHAR(40) NOT NULL, + challenge_value TEXT NOT NULL, + metadata JSONB, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS webauthn_challenge ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + flow_type VARCHAR(40) NOT NULL, + challenge TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS webauthn_credential ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + counter BIGINT NOT NULL DEFAULT 0, + transports JSONB, + credential_device_type VARCHAR(50), + credential_backed_up BOOLEAN NOT NULL DEFAULT FALSE, + nickname VARCHAR(120), + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_totp_secret ( + user_id BIGINT PRIMARY KEY REFERENCES app_user(id) ON DELETE CASCADE, + secret_base32 TEXT NOT NULL, + is_verified BOOLEAN NOT NULL DEFAULT FALSE, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/src/db/migrations/003_triggers_views_and_helpers.sql b/backend/src/db/migrations/003_triggers_views_and_helpers.sql new file mode 100644 index 0000000..27aa53e --- /dev/null +++ b/backend/src/db/migrations/003_triggers_views_and_helpers.sql @@ -0,0 +1,89 @@ +CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_case_updated_at') THEN + CREATE TRIGGER trg_case_updated_at BEFORE UPDATE ON br_case FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_deadline_updated_at BEFORE UPDATE ON deadline FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_meeting_updated_at BEFORE UPDATE ON meeting FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_decision_updated_at BEFORE UPDATE ON decision FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_task_updated_at BEFORE UPDATE ON task FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_document_updated_at BEFORE UPDATE ON document_item FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_template_updated_at BEFORE UPDATE ON template_item FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +CREATE OR REPLACE FUNCTION audit_if_changed() RETURNS trigger AS $$ +BEGIN + INSERT INTO audit_log(actor_user_id, action, entity_type, entity_id, metadata) + VALUES ( + NULL, + TG_OP, + TG_TABLE_NAME, + COALESCE(NEW.id, OLD.id), + jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW)) + ); + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_audit_case') THEN + CREATE TRIGGER trg_audit_case AFTER INSERT OR UPDATE OR DELETE ON br_case FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + CREATE TRIGGER trg_audit_deadline AFTER INSERT OR UPDATE OR DELETE ON deadline FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + CREATE TRIGGER trg_audit_meeting AFTER INSERT OR UPDATE OR DELETE ON meeting FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + CREATE TRIGGER trg_audit_decision AFTER INSERT OR UPDATE OR DELETE ON decision FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + CREATE TRIGGER trg_audit_task AFTER INSERT OR UPDATE OR DELETE ON task FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + CREATE TRIGGER trg_audit_document AFTER INSERT OR UPDATE OR DELETE ON document_item FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + END IF; +END $$; + +CREATE OR REPLACE FUNCTION app_user_id() RETURNS BIGINT AS $$ + SELECT COALESCE(NULLIF(current_setting('app.user_id', true), ''), '0')::BIGINT; +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION app_role() RETURNS TEXT AS $$ + SELECT COALESCE(NULLIF(current_setting('app.user_role', true), ''), 'anonymous'); +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION app_committee_id() RETURNS BIGINT AS $$ + SELECT COALESCE(NULLIF(current_setting('app.committee_id', true), ''), '0')::BIGINT; +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION can_read_conf_level(level conf_level, owner BIGINT, committee BIGINT) RETURNS BOOLEAN AS $$ +BEGIN + IF app_role() = 'admin' THEN RETURN TRUE; END IF; + IF level = 'intern' THEN RETURN TRUE; END IF; + IF level = 'vertraulich' THEN + RETURN owner = app_user_id() OR committee = app_committee_id() OR app_role() IN ('chair','secretary'); + END IF; + RETURN owner = app_user_id() OR app_role() IN ('chair'); +END; +$$ LANGUAGE plpgsql STABLE; + +CREATE OR REPLACE VIEW v_case_masked AS +SELECT + id, + case_number, + title, + case_type, + status, + priority, + confidentiality_level, + CASE + WHEN confidentiality_level = 'streng_vertraulich' AND app_role() NOT IN ('admin','chair') AND owner_user_id <> app_user_id() + THEN '***' + ELSE COALESCE(affected_employee_name, '') + END AS affected_employee_name_masked, + department, + committee_id, + owner_user_id, + created_at +FROM br_case +WHERE deleted_at IS NULL; diff --git a/backend/src/db/migrations/004_rls_policies.sql b/backend/src/db/migrations/004_rls_policies.sql new file mode 100644 index 0000000..bf37ced --- /dev/null +++ b/backend/src/db/migrations/004_rls_policies.sql @@ -0,0 +1,161 @@ +ALTER TABLE br_case ENABLE ROW LEVEL SECURITY; +ALTER TABLE deadline ENABLE ROW LEVEL SECURITY; +ALTER TABLE task ENABLE ROW LEVEL SECURITY; +ALTER TABLE document_item ENABLE ROW LEVEL SECURITY; +ALTER TABLE comment_item ENABLE ROW LEVEL SECURITY; +ALTER TABLE notification_item ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_user ENABLE ROW LEVEL SECURITY; +ALTER TABLE meeting ENABLE ROW LEVEL SECURITY; +ALTER TABLE decision ENABLE ROW LEVEL SECURITY; +ALTER TABLE template_item ENABLE ROW LEVEL SECURITY; +ALTER TABLE committee ENABLE ROW LEVEL SECURITY; + +ALTER TABLE br_case FORCE ROW LEVEL SECURITY; +ALTER TABLE deadline FORCE ROW LEVEL SECURITY; +ALTER TABLE task FORCE ROW LEVEL SECURITY; +ALTER TABLE document_item FORCE ROW LEVEL SECURITY; +ALTER TABLE comment_item FORCE ROW LEVEL SECURITY; +ALTER TABLE notification_item FORCE ROW LEVEL SECURITY; +ALTER TABLE app_user FORCE ROW LEVEL SECURITY; +ALTER TABLE meeting FORCE ROW LEVEL SECURITY; +ALTER TABLE decision FORCE ROW LEVEL SECURITY; +ALTER TABLE template_item FORCE ROW LEVEL SECURITY; +ALTER TABLE committee FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS committee_select ON committee; +DROP POLICY IF EXISTS committee_modify ON committee; +CREATE POLICY committee_select ON committee FOR SELECT USING (TRUE); +CREATE POLICY committee_modify ON committee FOR ALL USING (app_role() IN ('admin','chair')) WITH CHECK (app_role() IN ('admin','chair')); + +DROP POLICY IF EXISTS user_select ON app_user; +DROP POLICY IF EXISTS user_modify ON app_user; +CREATE POLICY user_select ON app_user FOR SELECT USING ( + app_role() IN ('admin','chair') OR id = app_user_id() +); +CREATE POLICY user_modify ON app_user FOR ALL USING ( + app_role() = 'admin' OR id = app_user_id() +) WITH CHECK ( + app_role() = 'admin' OR id = app_user_id() +); + +DROP POLICY IF EXISTS br_case_select_policy ON br_case; +DROP POLICY IF EXISTS br_case_modify_policy ON br_case; +CREATE POLICY br_case_select_policy ON br_case FOR SELECT USING ( + deleted_at IS NULL AND can_read_conf_level(confidentiality_level, owner_user_id, committee_id) +); +CREATE POLICY br_case_modify_policy ON br_case FOR ALL USING ( + deleted_at IS NULL AND ( + app_role() = 'admin' + OR owner_user_id = app_user_id() + OR (committee_id = app_committee_id() AND app_role() IN ('chair', 'secretary')) + ) +) WITH CHECK (can_read_conf_level(confidentiality_level, owner_user_id, committee_id)); + +DROP POLICY IF EXISTS deadline_select_policy ON deadline; +DROP POLICY IF EXISTS deadline_modify_policy ON deadline; +CREATE POLICY deadline_select_policy ON deadline FOR SELECT USING ( + deleted_at IS NULL AND EXISTS ( + SELECT 1 FROM br_case c WHERE c.id = deadline.case_id + AND can_read_conf_level(c.confidentiality_level, c.owner_user_id, c.committee_id) + ) +); +CREATE POLICY deadline_modify_policy ON deadline FOR ALL USING ( + deleted_at IS NULL AND EXISTS ( + SELECT 1 FROM br_case c WHERE c.id = deadline.case_id + AND ( + app_role() = 'admin' + OR c.owner_user_id = app_user_id() + OR (c.committee_id = app_committee_id() AND app_role() IN ('chair', 'secretary')) + ) + ) +) WITH CHECK (TRUE); + +DROP POLICY IF EXISTS meeting_select_policy ON meeting; +DROP POLICY IF EXISTS meeting_modify_policy ON meeting; +CREATE POLICY meeting_select_policy ON meeting FOR SELECT USING ( + committee_id IS NULL OR committee_id = app_committee_id() OR app_role() IN ('admin','chair','secretary') +); +CREATE POLICY meeting_modify_policy ON meeting FOR ALL USING ( + app_role() IN ('admin','chair','secretary') +) WITH CHECK (app_role() IN ('admin','chair','secretary')); + +DROP POLICY IF EXISTS decision_select_policy ON decision; +DROP POLICY IF EXISTS decision_modify_policy ON decision; +CREATE POLICY decision_select_policy ON decision FOR SELECT USING ( + case_id IS NULL OR EXISTS ( + SELECT 1 FROM br_case c WHERE c.id = decision.case_id + AND can_read_conf_level(c.confidentiality_level, c.owner_user_id, c.committee_id) + ) OR app_role() IN ('admin','chair','secretary') +); +CREATE POLICY decision_modify_policy ON decision FOR ALL USING ( + app_role() IN ('admin','chair','secretary') OR responsible_user_id = app_user_id() +) WITH CHECK (TRUE); + +DROP POLICY IF EXISTS task_select_policy ON task; +DROP POLICY IF EXISTS task_modify_policy ON task; +CREATE POLICY task_select_policy ON task FOR SELECT USING ( + deleted_at IS NULL AND ( + assigned_user_id = app_user_id() + OR backup_user_id = app_user_id() + OR app_role() IN ('admin','chair','secretary') + ) +); +CREATE POLICY task_modify_policy ON task FOR ALL USING ( + deleted_at IS NULL AND ( + app_role() IN ('admin','chair','secretary') + OR assigned_user_id = app_user_id() + OR created_by = app_user_id() + ) +) WITH CHECK (TRUE); + +DROP POLICY IF EXISTS document_select_policy ON document_item; +DROP POLICY IF EXISTS document_modify_policy ON document_item; +CREATE POLICY document_select_policy ON document_item FOR SELECT USING ( + deleted_at IS NULL + AND scan_status = 'clean' + AND ( + app_role() = 'admin' + OR uploaded_by = app_user_id() + OR EXISTS ( + SELECT 1 FROM br_case c + WHERE c.id = document_item.case_id + AND can_read_conf_level(document_item.confidentiality_level, c.owner_user_id, c.committee_id) + ) + OR case_id IS NULL + ) +); +CREATE POLICY document_modify_policy ON document_item FOR ALL USING ( + deleted_at IS NULL AND ( + app_role() IN ('admin','chair','secretary') + OR uploaded_by = app_user_id() + ) +) WITH CHECK (TRUE); + +DROP POLICY IF EXISTS comment_select_policy ON comment_item; +DROP POLICY IF EXISTS comment_modify_policy ON comment_item; +CREATE POLICY comment_select_policy ON comment_item FOR SELECT USING ( + deleted_at IS NULL AND ( + visibility = 'intern' + OR (visibility = 'vertraulich' AND app_role() IN ('admin','chair','secretary')) + ) +); +CREATE POLICY comment_modify_policy ON comment_item FOR ALL USING ( + deleted_at IS NULL AND ( + author_user_id = app_user_id() + OR app_role() IN ('admin','chair','secretary') + ) +) WITH CHECK (TRUE); + +DROP POLICY IF EXISTS template_select_policy ON template_item; +DROP POLICY IF EXISTS template_modify_policy ON template_item; +CREATE POLICY template_select_policy ON template_item FOR SELECT USING (TRUE); +CREATE POLICY template_modify_policy ON template_item FOR ALL USING (app_role() IN ('admin','chair','secretary')) WITH CHECK (app_role() IN ('admin','chair','secretary')); + +DROP POLICY IF EXISTS notification_select_policy ON notification_item; +DROP POLICY IF EXISTS notification_modify_policy ON notification_item; +CREATE POLICY notification_select_policy ON notification_item FOR SELECT USING ( + user_id = app_user_id() OR app_role() IN ('admin','chair','secretary') +); +CREATE POLICY notification_modify_policy ON notification_item FOR ALL USING ( + user_id = app_user_id() OR app_role() IN ('admin','chair','secretary') +) WITH CHECK (TRUE); diff --git a/backend/src/db/migrations/005_roles_and_seed.sql b/backend/src/db/migrations/005_roles_and_seed.sql new file mode 100644 index 0000000..10e34ec --- /dev/null +++ b/backend/src/db/migrations/005_roles_and_seed.sql @@ -0,0 +1,23 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'br_app') THEN + CREATE ROLE br_app LOGIN PASSWORD 'change_me'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'br_migration') THEN + CREATE ROLE br_migration LOGIN PASSWORD 'change_me'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'br_reporting') THEN + CREATE ROLE br_reporting LOGIN PASSWORD 'change_me'; + END IF; +END$$; + +GRANT USAGE ON SCHEMA public TO br_app, br_reporting; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO br_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO br_app; +GRANT SELECT ON v_case_masked TO br_reporting; +REVOKE ALL ON audit_log FROM PUBLIC; +GRANT SELECT ON audit_log TO br_app; + +INSERT INTO committee(name, committee_type) +VALUES ('Gesamtbetriebsrat', 'general') +ON CONFLICT DO NOTHING; diff --git a/backend/src/db/migrations/006_prod_login_hardening.sql b/backend/src/db/migrations/006_prod_login_hardening.sql new file mode 100644 index 0000000..31d4f35 --- /dev/null +++ b/backend/src/db/migrations/006_prod_login_hardening.sql @@ -0,0 +1,61 @@ +-- === LOGIN HARDENING === + +ALTER TABLE refresh_token + ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS replaced_by_token_id BIGINT REFERENCES refresh_token(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS user_agent TEXT, + ADD COLUMN IF NOT EXISTS ip_address INET, + ADD COLUMN IF NOT EXISTS device_name VARCHAR(120); + +CREATE TABLE IF NOT EXISTS auth_login_attempt ( + id BIGSERIAL PRIMARY KEY, + email_attempted VARCHAR(255), + user_id BIGINT REFERENCES app_user(id) ON DELETE SET NULL, + success BOOLEAN NOT NULL DEFAULT FALSE, + failure_reason VARCHAR(100), + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS auth_lockout ( + user_id BIGINT PRIMARY KEY REFERENCES app_user(id) ON DELETE CASCADE, + failed_attempts INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS password_reset_token ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE app_user + ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS must_enroll_webauthn BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS must_enroll_totp BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE user_totp_secret + ADD COLUMN IF NOT EXISTS secret_ciphertext BYTEA, + ADD COLUMN IF NOT EXISTS secret_key_version INTEGER NOT NULL DEFAULT 1; + +ALTER TABLE webauthn_credential + ADD COLUMN IF NOT EXISTS aaguid UUID, + ADD COLUMN IF NOT EXISTS rp_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS origin VARCHAR(255), + ADD COLUMN IF NOT EXISTS attestation_format VARCHAR(50), + ADD COLUMN IF NOT EXISTS last_ip INET, + ADD COLUMN IF NOT EXISTS is_primary BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE INDEX IF NOT EXISTS idx_refresh_token_user_id ON refresh_token(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_token_expires_at ON refresh_token(expires_at); +CREATE INDEX IF NOT EXISTS idx_auth_challenge_expires_at ON auth_challenge(expires_at); +CREATE INDEX IF NOT EXISTS idx_webauthn_challenge_expires_at ON webauthn_challenge(expires_at); +CREATE UNIQUE INDEX IF NOT EXISTS idx_app_user_email_lower ON app_user (LOWER(email)); diff --git a/backend/src/db/migrations/007_auth_functions.sql b/backend/src/db/migrations/007_auth_functions.sql new file mode 100644 index 0000000..f1cd410 --- /dev/null +++ b/backend/src/db/migrations/007_auth_functions.sql @@ -0,0 +1,32 @@ +-- === LOGIN FUNCTION === + +CREATE OR REPLACE FUNCTION auth_get_user_for_login(p_email TEXT) +RETURNS TABLE ( + id BIGINT, + email VARCHAR, + password_hash TEXT, + is_active BOOLEAN, + role role_type, + committee_id BIGINT, + two_factor_enabled BOOLEAN, + locked_until TIMESTAMPTZ +) +LANGUAGE sql +SECURITY DEFINER +AS $$ + SELECT + u.id, + u.email, + u.password_hash, + u.is_active, + u.role, + u.committee_id, + u.two_factor_enabled, + u.locked_until + FROM app_user u + WHERE LOWER(u.email) = LOWER(p_email) + LIMIT 1; +$$; + +REVOKE ALL ON FUNCTION auth_get_user_for_login(TEXT) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION auth_get_user_for_login(TEXT) TO br_app; diff --git a/backend/src/db/migrations/008_case_numbers_calendar_and_committee_defaults.sql b/backend/src/db/migrations/008_case_numbers_calendar_and_committee_defaults.sql new file mode 100644 index 0000000..c701650 --- /dev/null +++ b/backend/src/db/migrations/008_case_numbers_calendar_and_committee_defaults.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS case_number_counter ( + period_key VARCHAR(7) PRIMARY KEY, + last_value INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION next_case_number(p_now TIMESTAMPTZ DEFAULT NOW()) +RETURNS VARCHAR(50) +LANGUAGE plpgsql +AS $$ +DECLARE + period_key VARCHAR(7); + next_value INTEGER; +BEGIN + period_key := TO_CHAR(p_now, 'YYYY_MM'); + + INSERT INTO case_number_counter (period_key, last_value, updated_at) + VALUES (period_key, 1, NOW()) + ON CONFLICT (period_key) + DO UPDATE SET + last_value = case_number_counter.last_value + 1, + updated_at = NOW() + RETURNING last_value INTO next_value; + + RETURN period_key || '_' || LPAD(next_value::TEXT, 4, '0'); +END; +$$; + +ALTER TABLE br_case + ALTER COLUMN case_number SET DEFAULT next_case_number(); + +UPDATE br_case +SET case_number = next_case_number(created_at) +WHERE case_number IS NULL OR BTRIM(case_number) = ''; + +ALTER TABLE committee + ADD COLUMN IF NOT EXISTS chair_user_id BIGINT REFERENCES app_user(id), + ADD COLUMN IF NOT EXISTS secretary_user_id BIGINT REFERENCES app_user(id), + ADD COLUMN IF NOT EXISTS default_user_id BIGINT REFERENCES app_user(id); + +CREATE INDEX IF NOT EXISTS idx_committee_chair_user_id ON committee(chair_user_id); +CREATE INDEX IF NOT EXISTS idx_committee_secretary_user_id ON committee(secretary_user_id); +CREATE INDEX IF NOT EXISTS idx_committee_default_user_id ON committee(default_user_id); +CREATE INDEX IF NOT EXISTS idx_br_case_committee_id ON br_case(committee_id); +CREATE INDEX IF NOT EXISTS idx_task_assigned_user_id ON task(assigned_user_id); +CREATE INDEX IF NOT EXISTS idx_meeting_committee_id ON meeting(committee_id); +CREATE INDEX IF NOT EXISTS idx_deadline_case_id ON deadline(case_id); diff --git a/backend/src/db/migrations/008_case_type_catalog_and_case_workflow.sql b/backend/src/db/migrations/008_case_type_catalog_and_case_workflow.sql new file mode 100644 index 0000000..fc2d15d --- /dev/null +++ b/backend/src/db/migrations/008_case_type_catalog_and_case_workflow.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS case_type_catalog ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) UNIQUE NOT NULL, + label VARCHAR(120) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO case_type_catalog (code, label, sort_order) +VALUES + ('anhoerung', 'Anhörung', 10), + ('beschwerde', 'Beschwerde', 20), + ('einstellung', 'Einstellung', 30), + ('kuendigung', 'Kündigung', 40), + ('personalmassnahme', 'Personalmaßnahme', 50), + ('versetzung', 'Versetzung', 60), + ('sonstiges', 'Sonstiges', 999) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO case_type_catalog (code, label, sort_order) +SELECT DISTINCT + LOWER(REGEXP_REPLACE(TRIM(case_type), '[^a-zA-Z0-9]+', '_', 'g')) AS code, + TRIM(case_type) AS label, + 500 +FROM br_case +WHERE case_type IS NOT NULL + AND TRIM(case_type) <> '' +ON CONFLICT (code) DO NOTHING; + +ALTER TABLE br_case + ADD COLUMN IF NOT EXISTS case_type_id BIGINT REFERENCES case_type_catalog(id); + +UPDATE br_case c +SET case_type_id = ct.id +FROM case_type_catalog ct +WHERE c.case_type_id IS NULL + AND c.case_type IS NOT NULL + AND TRIM(c.case_type) <> '' + AND LOWER(REGEXP_REPLACE(TRIM(c.case_type), '[^a-zA-Z0-9]+', '_', 'g')) = ct.code; + +UPDATE br_case +SET case_type_id = ( + SELECT id FROM case_type_catalog WHERE code = 'sonstiges' +) +WHERE case_type_id IS NULL; + +ALTER TABLE br_case + ALTER COLUMN case_type_id SET NOT NULL; + +ALTER TABLE br_case + ALTER COLUMN priority SET DEFAULT 'mittel'; diff --git a/backend/src/db/migrations/009_calendar_personal_events_and_sync.sql b/backend/src/db/migrations/009_calendar_personal_events_and_sync.sql new file mode 100644 index 0000000..c1ff740 --- /dev/null +++ b/backend/src/db/migrations/009_calendar_personal_events_and_sync.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS calendar_event ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + event_kind VARCHAR(30) NOT NULL DEFAULT 'personal', + source_type VARCHAR(30) NOT NULL DEFAULT 'manual', + source_uid VARCHAR(255), + source_name VARCHAR(255), + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ, + all_day BOOLEAN NOT NULL DEFAULT FALSE, + location VARCHAR(255), + status VARCHAR(30) NOT NULL DEFAULT 'geplant', + priority VARCHAR(20) NOT NULL DEFAULT 'mittel', + visibility conf_level NOT NULL DEFAULT 'intern', + owner_user_id BIGINT REFERENCES app_user(id), + committee_id BIGINT REFERENCES committee(id), + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_calendar_event_starts_at ON calendar_event(starts_at); +CREATE INDEX IF NOT EXISTS idx_calendar_event_owner_user_id ON calendar_event(owner_user_id); +CREATE INDEX IF NOT EXISTS idx_calendar_event_committee_id ON calendar_event(committee_id); +CREATE INDEX IF NOT EXISTS idx_calendar_event_source_type_uid ON calendar_event(source_type, source_uid); diff --git a/backend/src/db/migrations/010_case_type_catalog_and_dropdowns.sql b/backend/src/db/migrations/010_case_type_catalog_and_dropdowns.sql new file mode 100644 index 0000000..72f1a0a --- /dev/null +++ b/backend/src/db/migrations/010_case_type_catalog_and_dropdowns.sql @@ -0,0 +1,54 @@ +CREATE TABLE IF NOT EXISTS case_type_catalog ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) UNIQUE NOT NULL, + label VARCHAR(120) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO case_type_catalog (code, label, sort_order) +VALUES + ('anhoerung', 'Anhörung', 10), + ('beschwerde', 'Beschwerde', 20), + ('einstellung', 'Einstellung', 30), + ('kuendigung', 'Kündigung', 40), + ('personalmassnahme', 'Personalmaßnahme', 50), + ('versetzung', 'Versetzung', 60), + ('sonstiges', 'Sonstiges', 999) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO case_type_catalog (code, label, sort_order) +SELECT DISTINCT + LOWER(REGEXP_REPLACE(TRIM(case_type), '[^a-zA-Z0-9]+', '_', 'g')) AS code, + TRIM(case_type) AS label, + 500 +FROM br_case +WHERE case_type IS NOT NULL + AND TRIM(case_type) <> '' +ON CONFLICT (code) DO NOTHING; + +ALTER TABLE br_case + ADD COLUMN IF NOT EXISTS case_type_id BIGINT REFERENCES case_type_catalog(id); + +UPDATE br_case c +SET case_type_id = ct.id +FROM case_type_catalog ct +WHERE c.case_type_id IS NULL + AND c.case_type IS NOT NULL + AND TRIM(c.case_type) <> '' + AND LOWER(REGEXP_REPLACE(TRIM(c.case_type), '[^a-zA-Z0-9]+', '_', 'g')) = ct.code; + +UPDATE br_case +SET case_type_id = ( + SELECT id FROM case_type_catalog WHERE code = 'sonstiges' +) +WHERE case_type_id IS NULL; + +ALTER TABLE br_case + ALTER COLUMN case_type_id SET NOT NULL; + +ALTER TABLE br_case + ALTER COLUMN priority SET DEFAULT 'mittel'; + +CREATE INDEX IF NOT EXISTS idx_br_case_case_type_id ON br_case(case_type_id); diff --git a/backend/src/db/migrations/011_meeting_number_generator.sql b/backend/src/db/migrations/011_meeting_number_generator.sql new file mode 100644 index 0000000..8a1d27a --- /dev/null +++ b/backend/src/db/migrations/011_meeting_number_generator.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS meeting_number_counter ( + period_ym CHAR(7) PRIMARY KEY, + last_value INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION next_meeting_number(p_date DATE DEFAULT CURRENT_DATE) +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + v_period TEXT; + v_seq INTEGER; +BEGIN + v_period := TO_CHAR(p_date, 'YYYY_MM'); + + LOOP + UPDATE meeting_number_counter + SET last_value = last_value + 1, + updated_at = NOW() + WHERE period_ym = v_period + RETURNING last_value INTO v_seq; + + IF FOUND THEN + EXIT; + END IF; + + BEGIN + INSERT INTO meeting_number_counter(period_ym, last_value) + VALUES (v_period, 1); + v_seq := 1; + EXIT; + EXCEPTION + WHEN unique_violation THEN + -- paralleler Insert, erneut versuchen + END; + END LOOP; + + RETURN 'SITZ-' || v_period || '_' || LPAD(v_seq::TEXT, 4, '0'); +END; +$$; + +UPDATE meeting +SET meeting_number = next_meeting_number(COALESCE(meeting_date, CURRENT_DATE)) +WHERE meeting_number IS NULL + OR TRIM(meeting_number) = ''; + +ALTER TABLE meeting + ALTER COLUMN meeting_number SET DEFAULT next_meeting_number(); + +ALTER TABLE meeting + ALTER COLUMN meeting_number SET NOT NULL; diff --git a/backend/src/db/migrations/012_agenda_item_pool_and_case_number_fix.sql b/backend/src/db/migrations/012_agenda_item_pool_and_case_number_fix.sql new file mode 100644 index 0000000..b1a24b7 --- /dev/null +++ b/backend/src/db/migrations/012_agenda_item_pool_and_case_number_fix.sql @@ -0,0 +1,30 @@ +CREATE OR REPLACE FUNCTION next_case_number(p_now TIMESTAMPTZ DEFAULT NOW()) +RETURNS VARCHAR(50) +LANGUAGE plpgsql +AS $$ +DECLARE + v_period_key VARCHAR(7); + v_next_value INTEGER; +BEGIN + v_period_key := TO_CHAR(p_now, 'YYYY_MM'); + + INSERT INTO case_number_counter (period_key, last_value, updated_at) + VALUES (v_period_key, 1, NOW()) + ON CONFLICT (period_key) + DO UPDATE SET + last_value = case_number_counter.last_value + 1, + updated_at = NOW() + RETURNING case_number_counter.last_value INTO v_next_value; + + RETURN v_period_key || '_' || LPAD(v_next_value::TEXT, 4, '0'); +END; +$$; + +ALTER TABLE meeting_agenda_item + ALTER COLUMN meeting_id DROP NOT NULL; + +ALTER TABLE meeting_agenda_item + ALTER COLUMN agenda_number DROP NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_meeting_id ON meeting_agenda_item(meeting_id); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_unassigned ON meeting_agenda_item(id) WHERE meeting_id IS NULL; diff --git a/backend/src/db/migrations/013_agenda_numbers_case_links_and_decisions.sql b/backend/src/db/migrations/013_agenda_numbers_case_links_and_decisions.sql new file mode 100644 index 0000000..ca23e00 --- /dev/null +++ b/backend/src/db/migrations/013_agenda_numbers_case_links_and_decisions.sql @@ -0,0 +1,62 @@ +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS case_id BIGINT REFERENCES br_case(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_case_id ON meeting_agenda_item(case_id); + +CREATE TABLE IF NOT EXISTS meeting_agenda_counter ( + meeting_id BIGINT PRIMARY KEY REFERENCES meeting(id) ON DELETE CASCADE, + last_value INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION next_agenda_number(p_meeting_id BIGINT) +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + v_seq INTEGER; +BEGIN + IF p_meeting_id IS NULL THEN + RAISE EXCEPTION 'meeting_id is required for next_agenda_number'; + END IF; + + LOOP + UPDATE meeting_agenda_counter + SET last_value = last_value + 1, + updated_at = NOW() + WHERE meeting_id = p_meeting_id + RETURNING last_value INTO v_seq; + + IF FOUND THEN + EXIT; + END IF; + + BEGIN + INSERT INTO meeting_agenda_counter(meeting_id, last_value) + VALUES (p_meeting_id, 1); + v_seq := 1; + EXIT; + EXCEPTION + WHEN unique_violation THEN + NULL; + END; + END LOOP; + + RETURN v_seq::TEXT; +END; +$$; + +INSERT INTO meeting_agenda_item(meeting_id, agenda_number, title, description, duration_minutes, case_id) +SELECT NULL, + NULL, + c.case_number || ' – ' || c.title, + COALESCE(c.summary, 'Automatisch aus Vorgang ' || c.case_number || ' erzeugt'), + NULL, + c.id + FROM br_case c + WHERE c.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM meeting_agenda_item ai + WHERE ai.case_id = c.id + ); diff --git a/backend/src/db/migrations/014_decision_number_generator.sql b/backend/src/db/migrations/014_decision_number_generator.sql new file mode 100644 index 0000000..2ea34b7 --- /dev/null +++ b/backend/src/db/migrations/014_decision_number_generator.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS decision_number_counter ( + period_key VARCHAR(7) PRIMARY KEY, + last_value INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION next_decision_number(p_now TIMESTAMPTZ DEFAULT NOW()) +RETURNS VARCHAR(50) +LANGUAGE plpgsql +AS $$ +DECLARE + v_period_key VARCHAR(7); + v_next_value INTEGER; +BEGIN + v_period_key := TO_CHAR(p_now, 'YYYY_MM'); + + INSERT INTO decision_number_counter (period_key, last_value, updated_at) + VALUES (v_period_key, 1, NOW()) + ON CONFLICT (period_key) + DO UPDATE SET + last_value = decision_number_counter.last_value + 1, + updated_at = NOW() + RETURNING decision_number_counter.last_value INTO v_next_value; + + RETURN 'BES-' || v_period_key || '_' || LPAD(v_next_value::TEXT, 4, '0'); +END; +$$; + +UPDATE decision +SET decision_number = next_decision_number(created_at) +WHERE decision_number IS NULL + OR TRIM(decision_number) = ''; + +ALTER TABLE decision + ALTER COLUMN decision_number SET DEFAULT next_decision_number(); + +ALTER TABLE decision + ALTER COLUMN decision_number SET NOT NULL; diff --git a/backend/src/db/migrations/015_document_item_deleted_at_backfill.sql b/backend/src/db/migrations/015_document_item_deleted_at_backfill.sql new file mode 100644 index 0000000..3f9e5fa --- /dev/null +++ b/backend/src/db/migrations/015_document_item_deleted_at_backfill.sql @@ -0,0 +1,5 @@ +ALTER TABLE IF EXISTS document_item + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_document_item_deleted_at + ON document_item (deleted_at); diff --git a/backend/src/db/migrations/016_meeting_protocol_template.sql b/backend/src/db/migrations/016_meeting_protocol_template.sql new file mode 100644 index 0000000..fc3ba18 --- /dev/null +++ b/backend/src/db/migrations/016_meeting_protocol_template.sql @@ -0,0 +1,56 @@ +DO $body$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM template_item + WHERE category = 'meeting_protocol' + ) THEN + INSERT INTO template_item( + title, + category, + description, + content, + version_label, + is_active + ) VALUES ( + 'Sitzungsprotokoll Standard', + 'meeting_protocol', + 'Wird beim Beenden einer Sitzung zur automatischen Protokollerzeugung verwendet. Platzhalter wie {{meeting_number}} oder {{agenda_sections}} können frei umgestellt werden.', + $template$# Betriebsrat {{committee_name}} +# Protokoll der BR-Sitzung am {{meeting_date}} +VERTRAULICH + +{{meeting_title_block}} + +## Organisatorisches und Feststellungen + +Sitzungsnummer: {{meeting_number}} +Sitzungsart: {{meeting_type}} +Sitzungsbeginn: {{start_time}} +Sitzungsort: {{location}} +Sitzungsleitung: {{chair_name}} +Schriftführung: {{secretary_name}} +{{quorum_statement}} +{{attendees_block}} + +## Protokollnotizen +{{protocol_notes}} + +## Tagesordnung und Beratung +{{agenda_sections}} + +## Sitzungsende +Schließung der Sitzung um {{end_time}} Uhr. + +{{signature_place}} +______________________________ +{{chair_name}} + +______________________________ +{{secretary_name}}$template$, + 'v1', + TRUE + ); + END IF; +END +$body$; diff --git a/backend/src/db/migrations/017_document_versioning.sql b/backend/src/db/migrations/017_document_versioning.sql new file mode 100644 index 0000000..23ec318 --- /dev/null +++ b/backend/src/db/migrations/017_document_versioning.sql @@ -0,0 +1,55 @@ +ALTER TABLE IF EXISTS document_item + ADD COLUMN IF NOT EXISTS version_number INTEGER; + +UPDATE document_item + SET version_number = 1 + WHERE version_number IS NULL; + +ALTER TABLE IF EXISTS document_item + ALTER COLUMN version_number SET DEFAULT 1; + +ALTER TABLE IF EXISTS document_item + ALTER COLUMN version_number SET NOT NULL; + +ALTER TABLE IF EXISTS document_item + ADD COLUMN IF NOT EXISTS version_group_id BIGINT; + +UPDATE document_item + SET version_group_id = id + WHERE version_group_id IS NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_document_item_version_group' + ) THEN + ALTER TABLE document_item + ADD CONSTRAINT fk_document_item_version_group + FOREIGN KEY (version_group_id) + REFERENCES document_item(id) + ON DELETE SET NULL; + END IF; +END +$$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'document_item_document_number_key' + AND conrelid = 'document_item'::regclass + ) THEN + ALTER TABLE document_item + DROP CONSTRAINT document_item_document_number_key; + END IF; +END +$$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_document_item_document_number_version_unique + ON document_item (document_number, version_number); + +CREATE INDEX IF NOT EXISTS idx_document_item_version_group_id + ON document_item (version_group_id); diff --git a/backend/src/db/migrations/018_meeting_decision_report_template.sql b/backend/src/db/migrations/018_meeting_decision_report_template.sql new file mode 100644 index 0000000..5c5e51a --- /dev/null +++ b/backend/src/db/migrations/018_meeting_decision_report_template.sql @@ -0,0 +1,46 @@ +DO $body$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM template_item + WHERE category = 'meeting_decision_report' + ) THEN + INSERT INTO template_item( + title, + category, + description, + content, + version_label, + is_active + ) VALUES ( + 'Beschlussbericht Standard', + 'meeting_decision_report', + 'Wird beim Beenden einer Sitzung zur automatischen Berichtserzeugung für alle behandelten Beschlüsse verwendet. Platzhalter wie {{meeting_number}} oder {{decision_sections}} können frei angepasst werden.', + $template$# Beschlussbericht zur BR-Sitzung am {{meeting_date}} +VERTRAULICH + +{{meeting_title_block}} + +## Sitzungsdaten +Sitzungsnummer: {{meeting_number}} +Sitzungsart: {{meeting_type}} +Sitzungsdatum: {{meeting_date}} +Sitzungsort: {{location}} +Sitzungsleitung: {{chair_name}} +Schriftführung: {{secretary_name}} + +## Behandelte Beschlüsse ab TOP 1 +{{decision_sections}} + +{{signature_place}} +______________________________ +{{chair_name}} + +______________________________ +{{secretary_name}}$template$, + 'v1', + TRUE + ); + END IF; +END +$body$; diff --git a/backend/src/db/migrations/019_committee_member.sql b/backend/src/db/migrations/019_committee_member.sql new file mode 100644 index 0000000..3a3f5ea --- /dev/null +++ b/backend/src/db/migrations/019_committee_member.sql @@ -0,0 +1,24 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS committee_member ( + committee_id BIGINT NOT NULL REFERENCES committee(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by BIGINT NULL REFERENCES app_user(id) ON DELETE SET NULL, + PRIMARY KEY (committee_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_committee_member_user_id + ON committee_member (user_id); + +CREATE INDEX IF NOT EXISTS idx_committee_member_committee_id + ON committee_member (committee_id); + +-- Bestehende 1:1-Zuordnungen aus app_user.committee_id in die neue M:N-Tabelle übernehmen +INSERT INTO committee_member (committee_id, user_id, created_at, created_by) +SELECT u.committee_id, u.id, NOW(), NULL + FROM app_user u + WHERE u.committee_id IS NOT NULL +ON CONFLICT (committee_id, user_id) DO NOTHING; + +COMMIT; diff --git a/backend/src/db/migrations/020_task_id.sql b/backend/src/db/migrations/020_task_id.sql new file mode 100644 index 0000000..d3c9984 --- /dev/null +++ b/backend/src/db/migrations/020_task_id.sql @@ -0,0 +1,6 @@ +ALTER TABLE task + ADD COLUMN IF NOT EXISTS committee_id BIGINT REFERENCES committee(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_task_committee_id ON task(committee_id); +CREATE INDEX IF NOT EXISTS idx_task_agenda_item_id ON task(agenda_item_id); diff --git a/backend/src/db/migrations/021_meeting_quorum.sql b/backend/src/db/migrations/021_meeting_quorum.sql new file mode 100644 index 0000000..4f45344 --- /dev/null +++ b/backend/src/db/migrations/021_meeting_quorum.sql @@ -0,0 +1,16 @@ +ALTER TABLE meeting + ADD COLUMN IF NOT EXISTS is_quorate BOOLEAN, + ADD COLUMN IF NOT EXISTS quorum_recorded_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS quorum_recorded_by BIGINT REFERENCES app_user(id); + +UPDATE template_item + SET content = REPLACE( + content, + 'Schriftführung: {{secretary_name}}', + 'Schriftführung: {{secretary_name}} +{{quorum_statement}}' + ), + version_label = CASE WHEN version_label = 'v1' THEN 'v2' ELSE version_label END, + updated_at = NOW() + WHERE category = 'meeting_protocol' + AND content NOT LIKE '%{{quorum_statement}}%'; diff --git a/backend/src/db/migrations/022_permissions_matrix.sql b/backend/src/db/migrations/022_permissions_matrix.sql new file mode 100644 index 0000000..68eac7f --- /dev/null +++ b/backend/src/db/migrations/022_permissions_matrix.sql @@ -0,0 +1,586 @@ +CREATE TABLE IF NOT EXISTS app_role_profile ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(80) NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL, + description TEXT, + system_role role_type UNIQUE, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_permission ( + permission_key VARCHAR(120) PRIMARY KEY, + label VARCHAR(160) NOT NULL, + category VARCHAR(80) NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_page ( + page_key VARCHAR(80) PRIMARY KEY, + path VARCHAR(120) NOT NULL UNIQUE, + label VARCHAR(120) NOT NULL, + nav_group VARCHAR(80) NOT NULL DEFAULT 'work', + sort_order INTEGER NOT NULL DEFAULT 100, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_role_permission ( + role_profile_id BIGINT NOT NULL REFERENCES app_role_profile(id) ON DELETE CASCADE, + permission_key VARCHAR(120) NOT NULL REFERENCES app_permission(permission_key) ON DELETE CASCADE, + granted BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (role_profile_id, permission_key) +); + +CREATE TABLE IF NOT EXISTS app_role_page ( + role_profile_id BIGINT NOT NULL REFERENCES app_role_profile(id) ON DELETE CASCADE, + page_key VARCHAR(80) NOT NULL REFERENCES app_page(page_key) ON DELETE CASCADE, + can_view BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (role_profile_id, page_key) +); + +CREATE TABLE IF NOT EXISTS app_user_role_profile ( + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + role_profile_id BIGINT NOT NULL REFERENCES app_role_profile(id) ON DELETE CASCADE, + assigned_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, role_profile_id) +); + +CREATE TABLE IF NOT EXISTS app_team_role ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(80) NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL, + description TEXT, + can_view_cases BOOLEAN NOT NULL DEFAULT FALSE, + can_write_cases BOOLEAN NOT NULL DEFAULT FALSE, + can_view_documents BOOLEAN NOT NULL DEFAULT FALSE, + can_write_documents BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_user_team_role ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + team_role_id BIGINT NOT NULL REFERENCES app_team_role(id) ON DELETE CASCADE, + committee_id BIGINT REFERENCES committee(id) ON DELETE CASCADE, + assigned_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_app_user_team_role_global + ON app_user_team_role(user_id, team_role_id) + WHERE committee_id IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_app_user_team_role_committee + ON app_user_team_role(user_id, team_role_id, committee_id) + WHERE committee_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS br_case_team_access ( + case_id BIGINT NOT NULL REFERENCES br_case(id) ON DELETE CASCADE, + team_role_id BIGINT NOT NULL REFERENCES app_team_role(id) ON DELETE CASCADE, + can_read BOOLEAN NOT NULL DEFAULT TRUE, + can_write BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (case_id, team_role_id) +); + +CREATE TABLE IF NOT EXISTS document_team_access ( + document_id BIGINT NOT NULL REFERENCES document_item(id) ON DELETE CASCADE, + team_role_id BIGINT NOT NULL REFERENCES app_team_role(id) ON DELETE CASCADE, + can_read BOOLEAN NOT NULL DEFAULT TRUE, + can_write BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (document_id, team_role_id) +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_app_role_profile_updated_at') THEN + CREATE TRIGGER trg_app_role_profile_updated_at BEFORE UPDATE ON app_role_profile FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_app_team_role_updated_at') THEN + CREATE TRIGGER trg_app_team_role_updated_at BEFORE UPDATE ON app_team_role FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +INSERT INTO app_role_profile(code, name, description, system_role, is_system) +VALUES + ('admin', 'admin', 'Systemverwaltung', 'admin', TRUE), + ('chair', 'chair', 'Sitzungen, TOPs, Beschlüsse, Vorlagen, Ausschussverwaltung', 'chair', TRUE), + ('secretary', 'secretary', 'Protokolle, Sitzungen, TOPs, Dokumente', 'secretary', TRUE), + ('member', 'member', 'Eigene Vorgänge, Aufgaben, Kommentare, Kalender', 'member', TRUE), + ('advisor', 'advisor', 'Lesender/beratender Zugriff auf freigegebene Inhalte', 'advisor', TRUE) +ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name, + description = EXCLUDED.description, + system_role = EXCLUDED.system_role, + is_system = TRUE, + is_active = TRUE, + updated_at = NOW(); + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES + ('dashboard', '/', 'Dashboard', 'dashboard', 0, 'Startseite und Überblick'), + ('cases', '/cases', 'Vorgänge', 'work', 10, 'Vorgangsverwaltung'), + ('deadlines', '/deadlines', 'Fristen', 'work', 20, 'Fristenverwaltung'), + ('meetings', '/meetings', 'Sitzungen', 'work', 30, 'Sitzungen planen, starten und protokollieren'), + ('agenda-items', '/agenda-items', 'TOP-Bestand', 'work', 40, 'Tagesordnungspunkte'), + ('decisions', '/decisions', 'Beschlüsse', 'work', 50, 'Beschlussverwaltung'), + ('reports', '/reports', 'Reports', 'work', 60, 'Berichte und Auswertungen'), + ('tasks', '/tasks', 'Aufgaben', 'work', 70, 'Aufgabenverwaltung'), + ('calendar', '/calendar', 'Kalender', 'work', 80, 'Kalender'), + ('documents', '/documents', 'Dokumente', 'work', 90, 'Dokumentenverwaltung'), + ('comments', '/comments', 'Kommentare', 'work', 100, 'Kommentare'), + ('notifications', '/notifications', 'Benachrichtigungen', 'work', 110, 'Hinweise'), + ('users', '/users', 'Benutzer', 'admin', 200, 'Benutzerverwaltung'), + ('committees', '/committees', 'Ausschüsse', 'admin', 210, 'Ausschussverwaltung'), + ('templates', '/templates', 'Vorlagen / Formular-Editor', 'admin', 220, 'Vorlagenverwaltung und HTML-/Formular-Editor'), + ('permissions', '/permissions', 'Rollen & Rechte', 'admin', 230, 'Rechtematrix und Teamrollen'), + ('security', '/security', 'Sicherheit', 'admin', 240, 'MFA und Sicherheit'), + ('audit', '/audit', 'Audit', 'admin', 250, 'Audit-Log') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('system.admin', 'Systemverwaltung', 'System', 'Voller Systemzugriff'), + ('system.users', 'Benutzer verwalten', 'System', 'Benutzer anlegen, ändern und deaktivieren'), + ('system.permissions', 'Rollen und Rechte verwalten', 'System', 'Rechtematrix, Custom-Rollen und Teamrollen verwalten'), + ('system.security', 'Sicherheit verwalten', 'System', 'Sicherheits- und MFA-Funktionen verwalten'), + ('system.audit', 'Audit lesen', 'System', 'Audit-Log anzeigen'), + ('cases.read.own', 'Eigene Vorgänge lesen', 'Vorgänge', 'Eigene/erstellte Vorgänge lesen'), + ('cases.read.all', 'Alle Vorgänge lesen', 'Vorgänge', 'Alle Vorgänge rollenbasiert lesen'), + ('cases.write', 'Vorgänge bearbeiten', 'Vorgänge', 'Vorgänge anlegen und bearbeiten'), + ('cases.write.all', 'Alle Vorgänge bearbeiten', 'Vorgänge', 'Nicht nur eigene Vorgänge bearbeiten'), + ('cases.delete', 'Vorgänge löschen', 'Vorgänge', 'Vorgänge löschen/archivieren'), + ('deadlines.write', 'Fristen bearbeiten', 'Fristen', 'Fristen anlegen und bearbeiten'), + ('meetings.write', 'Sitzungen bearbeiten', 'Sitzungen', 'Sitzungen planen, starten, stoppen und löschen'), + ('agenda.write', 'TOPs bearbeiten', 'Sitzungen', 'Tagesordnungspunkte bearbeiten'), + ('decisions.write', 'Beschlüsse bearbeiten', 'Beschlüsse', 'Beschlüsse anlegen und ändern'), + ('reports.read', 'Reports lesen', 'Reports', 'Berichte anzeigen'), + ('tasks.write', 'Aufgaben bearbeiten', 'Aufgaben', 'Aufgaben anlegen und ändern'), + ('calendar.write', 'Kalender bearbeiten', 'Kalender', 'Kalendereinträge anlegen und ändern'), + ('documents.read.released', 'Freigegebene Dokumente lesen', 'Dokumente', 'Freigegebene/saubere interne Dokumente lesen'), + ('documents.read.all', 'Alle Dokumente lesen', 'Dokumente', 'Alle Dokumente rollenbasiert lesen'), + ('documents.write', 'Dokumente bearbeiten', 'Dokumente', 'Dokumente hochladen, versionieren und ändern'), + ('documents.write.all', 'Alle Dokumente bearbeiten', 'Dokumente', 'Nicht nur eigene Dokumente bearbeiten'), + ('comments.write', 'Kommentare schreiben', 'Kommentare', 'Kommentare anlegen und bearbeiten'), + ('notifications.write', 'Benachrichtigungen bearbeiten', 'Benachrichtigungen', 'Benachrichtigungen anlegen/ändern'), + ('templates.write', 'Vorlagen bearbeiten', 'Vorlagen', 'Vorlagen anlegen und ändern'), + ('committees.write', 'Ausschüsse bearbeiten', 'Administration', 'Ausschüsse und Mitgliedschaften verwalten') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +CREATE OR REPLACE FUNCTION grant_role_pages(role_code TEXT, page_keys TEXT[]) RETURNS VOID AS $$ +DECLARE + role_id BIGINT; +BEGIN + SELECT id INTO role_id FROM app_role_profile WHERE code = role_code; + IF role_id IS NULL THEN RETURN; END IF; + + DELETE FROM app_role_page WHERE role_profile_id = role_id; + + INSERT INTO app_role_page(role_profile_id, page_key, can_view) + SELECT role_id, unnest(page_keys), TRUE + ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION grant_role_permissions(role_code TEXT, permission_keys TEXT[]) RETURNS VOID AS $$ +DECLARE + role_id BIGINT; +BEGIN + SELECT id INTO role_id FROM app_role_profile WHERE code = role_code; + IF role_id IS NULL THEN RETURN; END IF; + + DELETE FROM app_role_permission WHERE role_profile_id = role_id; + + INSERT INTO app_role_permission(role_profile_id, permission_key, granted) + SELECT role_id, unnest(permission_keys), TRUE + ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; +END; +$$ LANGUAGE plpgsql; + +SELECT grant_role_pages('admin', ARRAY[ + 'dashboard','cases','deadlines','meetings','agenda-items','decisions','reports','tasks','calendar','documents','comments','notifications', + 'users','committees','templates','permissions','security','audit' +]); +SELECT grant_role_pages('chair', ARRAY[ + 'dashboard','cases','deadlines','meetings','agenda-items','decisions','reports','tasks','calendar','documents','comments','notifications','committees','templates' +]); +SELECT grant_role_pages('secretary', ARRAY[ + 'dashboard','meetings','agenda-items','decisions','reports','tasks','calendar','documents','comments','notifications' +]); +SELECT grant_role_pages('member', ARRAY[ + 'dashboard','cases','deadlines','tasks','calendar','documents','comments','notifications' +]); +SELECT grant_role_pages('advisor', ARRAY[ + 'dashboard','calendar','documents','comments','notifications' +]); + +SELECT grant_role_permissions('admin', ARRAY( + SELECT permission_key FROM app_permission +)); +SELECT grant_role_permissions('chair', ARRAY[ + 'cases.read.all','cases.write','cases.write.all','cases.delete','deadlines.write','meetings.write','agenda.write','decisions.write', + 'reports.read','tasks.write','calendar.write','documents.read.all','documents.write','documents.write.all','comments.write','notifications.write','templates.write','committees.write' +]); +SELECT grant_role_permissions('secretary', ARRAY[ + 'meetings.write','agenda.write','decisions.write','reports.read','tasks.write','calendar.write','documents.write','comments.write','notifications.write' +]); +SELECT grant_role_permissions('member', ARRAY[ + 'cases.read.own','cases.write','deadlines.write','tasks.write','calendar.write','documents.write','comments.write','notifications.write' +]); +SELECT grant_role_permissions('advisor', ARRAY[ + 'documents.read.released' +]); + +DROP FUNCTION IF EXISTS grant_role_pages(TEXT, TEXT[]); +DROP FUNCTION IF EXISTS grant_role_permissions(TEXT, TEXT[]); + +CREATE OR REPLACE FUNCTION app_has_permission(permission TEXT, target_user_id BIGINT DEFAULT app_user_id(), target_role TEXT DEFAULT app_role()) RETURNS BOOLEAN AS $$ +DECLARE + allowed BOOLEAN; +BEGIN + IF target_role = 'admin' THEN + RETURN TRUE; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM app_role_profile rp + JOIN app_role_permission rperm ON rperm.role_profile_id = rp.id AND rperm.granted = TRUE + WHERE rperm.permission_key = permission + AND rp.is_active = TRUE + AND ( + rp.system_role::TEXT = target_role + OR EXISTS ( + SELECT 1 + FROM app_user_role_profile urp + WHERE urp.role_profile_id = rp.id + AND urp.user_id = target_user_id + ) + ) + ) INTO allowed; + + RETURN COALESCE(allowed, FALSE); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_can_view_page(page_key TEXT, target_user_id BIGINT DEFAULT app_user_id(), target_role TEXT DEFAULT app_role()) RETURNS BOOLEAN AS $$ +DECLARE + allowed BOOLEAN; +BEGIN + IF target_role = 'admin' THEN + RETURN TRUE; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM app_role_profile rp + JOIN app_role_page rpage ON rpage.role_profile_id = rp.id AND rpage.can_view = TRUE + WHERE rpage.page_key = app_can_view_page.page_key + AND rp.is_active = TRUE + AND ( + rp.system_role::TEXT = target_role + OR EXISTS ( + SELECT 1 + FROM app_user_role_profile urp + WHERE urp.role_profile_id = rp.id + AND urp.user_id = target_user_id + ) + ) + ) INTO allowed; + + RETURN COALESCE(allowed, FALSE); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_user_has_case_team_access(target_case_id BIGINT, write_access BOOLEAN DEFAULT FALSE, target_user_id BIGINT DEFAULT app_user_id()) RETURNS BOOLEAN AS $$ +DECLARE + allowed BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM br_case c + JOIN app_user_team_role utr ON utr.user_id = target_user_id AND (utr.committee_id IS NULL OR utr.committee_id = c.committee_id) + JOIN app_team_role tr ON tr.id = utr.team_role_id AND tr.is_active = TRUE + LEFT JOIN br_case_team_access cta ON cta.case_id = c.id AND cta.team_role_id = tr.id + WHERE c.id = target_case_id + AND ( + (write_access = FALSE AND (tr.can_view_cases = TRUE OR cta.can_read = TRUE OR cta.can_write = TRUE)) + OR + (write_access = TRUE AND (tr.can_write_cases = TRUE OR cta.can_write = TRUE)) + ) + ) INTO allowed; + + RETURN COALESCE(allowed, FALSE); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_user_has_document_team_access(target_document_id BIGINT, write_access BOOLEAN DEFAULT FALSE, target_user_id BIGINT DEFAULT app_user_id()) RETURNS BOOLEAN AS $$ +DECLARE + allowed BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM document_item d + LEFT JOIN br_case c ON c.id = d.case_id + JOIN app_user_team_role utr ON utr.user_id = target_user_id AND (utr.committee_id IS NULL OR utr.committee_id = COALESCE(c.committee_id, 0) OR c.id IS NULL) + JOIN app_team_role tr ON tr.id = utr.team_role_id AND tr.is_active = TRUE + LEFT JOIN document_team_access dta ON dta.document_id = d.id AND dta.team_role_id = tr.id + WHERE d.id = target_document_id + AND ( + (write_access = FALSE AND (tr.can_view_documents = TRUE OR dta.can_read = TRUE OR dta.can_write = TRUE)) + OR + (write_access = TRUE AND (tr.can_write_documents = TRUE OR dta.can_write = TRUE)) + ) + ) INTO allowed; + + RETURN COALESCE(allowed, FALSE); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_can_read_case(target_case_id BIGINT) RETURNS BOOLEAN AS $$ +DECLARE + row br_case%ROWTYPE; +BEGIN + SELECT * INTO row FROM br_case WHERE id = target_case_id AND deleted_at IS NULL; + IF NOT FOUND THEN RETURN FALSE; END IF; + + IF app_role() IN ('admin', 'chair') OR app_has_permission('cases.read.all') THEN RETURN TRUE; END IF; + IF row.owner_user_id = app_user_id() OR row.created_by = app_user_id() THEN RETURN TRUE; END IF; + IF app_user_has_case_team_access(row.id, FALSE) THEN RETURN TRUE; END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_can_write_case(target_case_id BIGINT) RETURNS BOOLEAN AS $$ +DECLARE + row br_case%ROWTYPE; +BEGIN + SELECT * INTO row FROM br_case WHERE id = target_case_id AND deleted_at IS NULL; + IF NOT FOUND THEN RETURN FALSE; END IF; + + IF app_role() IN ('admin', 'chair') OR app_has_permission('cases.write.all') THEN RETURN TRUE; END IF; + IF (row.owner_user_id = app_user_id() OR row.created_by = app_user_id()) AND app_has_permission('cases.write') THEN RETURN TRUE; END IF; + IF app_user_has_case_team_access(row.id, TRUE) AND app_has_permission('cases.write') THEN RETURN TRUE; END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_can_read_document(target_document_id BIGINT) RETURNS BOOLEAN AS $$ +DECLARE + row document_item%ROWTYPE; +BEGIN + SELECT * INTO row FROM document_item WHERE id = target_document_id AND deleted_at IS NULL; + IF NOT FOUND THEN RETURN FALSE; END IF; + IF row.scan_status <> 'clean' THEN RETURN FALSE; END IF; + + IF app_role() IN ('admin', 'chair') OR app_has_permission('documents.read.all') THEN RETURN TRUE; END IF; + IF row.uploaded_by = app_user_id() THEN RETURN TRUE; END IF; + IF row.case_id IS NOT NULL AND app_can_read_case(row.case_id) THEN RETURN TRUE; END IF; + IF app_user_has_document_team_access(row.id, FALSE) THEN RETURN TRUE; END IF; + IF row.case_id IS NULL AND row.decision_id IS NULL AND row.confidentiality_level = 'intern' AND app_has_permission('documents.read.released') THEN RETURN TRUE; END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_can_write_document(target_document_id BIGINT) RETURNS BOOLEAN AS $$ +DECLARE + row document_item%ROWTYPE; +BEGIN + SELECT * INTO row FROM document_item WHERE id = target_document_id AND deleted_at IS NULL; + IF NOT FOUND THEN RETURN FALSE; END IF; + + IF app_role() IN ('admin', 'chair') OR app_has_permission('documents.write.all') THEN RETURN TRUE; END IF; + IF row.uploaded_by = app_user_id() AND app_has_permission('documents.write') THEN RETURN TRUE; END IF; + IF row.case_id IS NOT NULL AND app_can_write_case(row.case_id) AND app_has_permission('documents.write') THEN RETURN TRUE; END IF; + IF app_user_has_document_team_access(row.id, TRUE) AND app_has_permission('documents.write') THEN RETURN TRUE; END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION can_read_conf_level(level conf_level, owner BIGINT, committee BIGINT) RETURNS BOOLEAN AS $$ +BEGIN + IF app_role() IN ('admin', 'chair') OR app_has_permission('cases.read.all') THEN RETURN TRUE; END IF; + IF owner = app_user_id() THEN RETURN TRUE; END IF; + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE; + +DROP POLICY IF EXISTS br_case_select_policy ON br_case; +DROP POLICY IF EXISTS br_case_modify_policy ON br_case; +CREATE POLICY br_case_select_policy ON br_case FOR SELECT USING (app_can_read_case(id)); +CREATE POLICY br_case_modify_policy ON br_case FOR ALL USING (app_can_write_case(id)) WITH CHECK ( + app_role() IN ('admin','chair') + OR app_has_permission('cases.write.all') + OR (app_has_permission('cases.write') AND (owner_user_id = app_user_id() OR created_by = app_user_id())) +); + +DROP POLICY IF EXISTS deadline_select_policy ON deadline; +DROP POLICY IF EXISTS deadline_modify_policy ON deadline; +CREATE POLICY deadline_select_policy ON deadline FOR SELECT USING ( + deleted_at IS NULL AND EXISTS (SELECT 1 FROM br_case c WHERE c.id = deadline.case_id AND app_can_read_case(c.id)) +); +CREATE POLICY deadline_modify_policy ON deadline FOR ALL USING ( + deleted_at IS NULL AND EXISTS (SELECT 1 FROM br_case c WHERE c.id = deadline.case_id AND app_can_write_case(c.id)) +) WITH CHECK (app_has_permission('deadlines.write')); + +DROP POLICY IF EXISTS decision_select_policy ON decision; +DROP POLICY IF EXISTS decision_modify_policy ON decision; +CREATE POLICY decision_select_policy ON decision FOR SELECT USING ( + case_id IS NULL OR app_can_read_case(case_id) OR app_has_permission('decisions.write') +); +CREATE POLICY decision_modify_policy ON decision FOR ALL USING ( + app_has_permission('decisions.write') OR responsible_user_id = app_user_id() +) WITH CHECK (app_has_permission('decisions.write') OR responsible_user_id = app_user_id()); + +DROP POLICY IF EXISTS document_select_policy ON document_item; +DROP POLICY IF EXISTS document_modify_policy ON document_item; +CREATE POLICY document_select_policy ON document_item FOR SELECT USING (app_can_read_document(id)); +CREATE POLICY document_modify_policy ON document_item FOR ALL USING (app_can_write_document(id)) WITH CHECK ( + app_role() IN ('admin','chair') + OR app_has_permission('documents.write.all') + OR (app_has_permission('documents.write') AND uploaded_by = app_user_id()) + OR (case_id IS NOT NULL AND app_can_write_case(case_id) AND app_has_permission('documents.write')) +); + +DROP POLICY IF EXISTS meeting_select_policy ON meeting; +DROP POLICY IF EXISTS meeting_modify_policy ON meeting; +CREATE POLICY meeting_select_policy ON meeting FOR SELECT USING ( + app_can_view_page('meetings') OR app_has_permission('meetings.write') OR committee_id IS NULL OR committee_id = app_committee_id() +); +CREATE POLICY meeting_modify_policy ON meeting FOR ALL USING (app_has_permission('meetings.write')) WITH CHECK (app_has_permission('meetings.write')); + +DROP POLICY IF EXISTS template_select_policy ON template_item; +DROP POLICY IF EXISTS template_modify_policy ON template_item; +CREATE POLICY template_select_policy ON template_item FOR SELECT USING (app_can_view_page('templates')); +CREATE POLICY template_modify_policy ON template_item FOR ALL USING (app_has_permission('templates.write')) WITH CHECK (app_has_permission('templates.write')); + +DROP POLICY IF EXISTS committee_modify ON committee; +CREATE POLICY committee_modify ON committee FOR ALL USING (app_has_permission('committees.write')) WITH CHECK (app_has_permission('committees.write')); + +ALTER TABLE app_role_profile ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_permission ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_page ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_role_permission ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_role_page ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_user_role_profile ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_team_role ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_user_team_role ENABLE ROW LEVEL SECURITY; +ALTER TABLE br_case_team_access ENABLE ROW LEVEL SECURITY; +ALTER TABLE document_team_access ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS app_role_profile_select ON app_role_profile; +DROP POLICY IF EXISTS app_role_profile_modify ON app_role_profile; +CREATE POLICY app_role_profile_select ON app_role_profile FOR SELECT USING ( + app_has_permission('system.permissions') + OR system_role::TEXT = app_role() + OR EXISTS ( + SELECT 1 FROM app_user_role_profile urp + WHERE urp.role_profile_id = app_role_profile.id + AND urp.user_id = app_user_id() + ) +); +CREATE POLICY app_role_profile_modify ON app_role_profile FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_permission_select ON app_permission; +DROP POLICY IF EXISTS app_permission_modify ON app_permission; +CREATE POLICY app_permission_select ON app_permission FOR SELECT USING (TRUE); +CREATE POLICY app_permission_modify ON app_permission FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_page_select ON app_page; +DROP POLICY IF EXISTS app_page_modify ON app_page; +CREATE POLICY app_page_select ON app_page FOR SELECT USING (TRUE); +CREATE POLICY app_page_modify ON app_page FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_role_permission_select ON app_role_permission; +DROP POLICY IF EXISTS app_role_permission_modify ON app_role_permission; +CREATE POLICY app_role_permission_select ON app_role_permission FOR SELECT USING ( + app_has_permission('system.permissions') + OR EXISTS ( + SELECT 1 FROM app_role_profile rp + WHERE rp.id = app_role_permission.role_profile_id + AND ( + rp.system_role::TEXT = app_role() + OR EXISTS ( + SELECT 1 FROM app_user_role_profile urp + WHERE urp.role_profile_id = rp.id + AND urp.user_id = app_user_id() + ) + ) + ) +); +CREATE POLICY app_role_permission_modify ON app_role_permission FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_role_page_select ON app_role_page; +DROP POLICY IF EXISTS app_role_page_modify ON app_role_page; +CREATE POLICY app_role_page_select ON app_role_page FOR SELECT USING ( + app_has_permission('system.permissions') + OR EXISTS ( + SELECT 1 FROM app_role_profile rp + WHERE rp.id = app_role_page.role_profile_id + AND ( + rp.system_role::TEXT = app_role() + OR EXISTS ( + SELECT 1 FROM app_user_role_profile urp + WHERE urp.role_profile_id = rp.id + AND urp.user_id = app_user_id() + ) + ) + ) +); +CREATE POLICY app_role_page_modify ON app_role_page FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_user_role_profile_select ON app_user_role_profile; +DROP POLICY IF EXISTS app_user_role_profile_modify ON app_user_role_profile; +CREATE POLICY app_user_role_profile_select ON app_user_role_profile FOR SELECT USING (app_has_permission('system.permissions') OR user_id = app_user_id()); +CREATE POLICY app_user_role_profile_modify ON app_user_role_profile FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_team_role_select ON app_team_role; +DROP POLICY IF EXISTS app_team_role_modify ON app_team_role; +CREATE POLICY app_team_role_select ON app_team_role FOR SELECT USING (app_has_permission('system.permissions') OR TRUE); +CREATE POLICY app_team_role_modify ON app_team_role FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_user_team_role_select ON app_user_team_role; +DROP POLICY IF EXISTS app_user_team_role_modify ON app_user_team_role; +CREATE POLICY app_user_team_role_select ON app_user_team_role FOR SELECT USING (app_has_permission('system.permissions') OR user_id = app_user_id()); +CREATE POLICY app_user_team_role_modify ON app_user_team_role FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS br_case_team_access_select ON br_case_team_access; +DROP POLICY IF EXISTS br_case_team_access_modify ON br_case_team_access; +CREATE POLICY br_case_team_access_select ON br_case_team_access FOR SELECT USING (app_has_permission('system.permissions') OR app_can_write_case(case_id)); +CREATE POLICY br_case_team_access_modify ON br_case_team_access FOR ALL USING (app_can_write_case(case_id) OR app_has_permission('system.permissions')) WITH CHECK (app_can_write_case(case_id) OR app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS document_team_access_select ON document_team_access; +DROP POLICY IF EXISTS document_team_access_modify ON document_team_access; +CREATE POLICY document_team_access_select ON document_team_access FOR SELECT USING (app_has_permission('system.permissions') OR app_can_write_document(document_id)); +CREATE POLICY document_team_access_modify ON document_team_access FOR ALL USING (app_can_write_document(document_id) OR app_has_permission('system.permissions')) WITH CHECK (app_can_write_document(document_id) OR app_has_permission('system.permissions')); + +GRANT SELECT, INSERT, UPDATE, DELETE ON app_role_profile, app_permission, app_page, app_role_permission, app_role_page, app_user_role_profile, app_team_role, app_user_team_role, br_case_team_access, document_team_access TO br_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO br_app; diff --git a/backend/src/db/migrations/023_finance.sql b/backend/src/db/migrations/023_finance.sql new file mode 100644 index 0000000..e155edf --- /dev/null +++ b/backend/src/db/migrations/023_finance.sql @@ -0,0 +1,197 @@ +CREATE TABLE IF NOT EXISTS finance_project ( + id BIGSERIAL PRIMARY KEY, + project_code VARCHAR(50) UNIQUE, + name VARCHAR(180) NOT NULL, + description TEXT, + status VARCHAR(30) NOT NULL DEFAULT 'aktiv' CHECK (status IN ('aktiv','pausiert','abgeschlossen','archiviert')), + calculation_mode VARCHAR(30) NOT NULL DEFAULT 'sum_only' CHECK (calculation_mode IN ('sum_only','project_budget','annual_budget')), + committee_id BIGINT REFERENCES committee(id) ON DELETE SET NULL, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS finance_project_budget ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES finance_project(id) ON DELETE CASCADE, + budget_type VARCHAR(20) NOT NULL CHECK (budget_type IN ('project','annual')), + budget_year INTEGER, + amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (amount >= 0), + currency CHAR(3) NOT NULL DEFAULT 'EUR', + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT finance_project_budget_year_check CHECK ( + (budget_type = 'project' AND budget_year IS NULL) + OR + (budget_type = 'annual' AND budget_year IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_finance_project_budget_project + ON finance_project_budget(project_id) + WHERE budget_type = 'project'; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_finance_project_budget_annual + ON finance_project_budget(project_id, budget_year) + WHERE budget_type = 'annual'; + +CREATE TABLE IF NOT EXISTS finance_expense ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES finance_project(id) ON DELETE CASCADE, + expense_date DATE NOT NULL DEFAULT CURRENT_DATE, + budget_year INTEGER NOT NULL DEFAULT EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER, + title VARCHAR(255) NOT NULL, + vendor VARCHAR(180), + cost_category VARCHAR(120), + net_amount NUMERIC(14,2) NOT NULL CHECK (net_amount >= 0), + tax_rate_percent NUMERIC(6,3) NOT NULL DEFAULT 19 CHECK (tax_rate_percent >= 0), + tax_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + gross_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (gross_amount >= 0), + currency CHAR(3) NOT NULL DEFAULT 'EUR', + payment_status VARCHAR(30) NOT NULL DEFAULT 'offen' CHECK (payment_status IN ('geplant','offen','bezahlt','storniert')), + notes TEXT, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_finance_expense_project ON finance_expense(project_id); +CREATE INDEX IF NOT EXISTS idx_finance_expense_budget_year ON finance_expense(budget_year); +CREATE INDEX IF NOT EXISTS idx_finance_expense_date ON finance_expense(expense_date); + +CREATE TABLE IF NOT EXISTS finance_expense_document ( + expense_id BIGINT NOT NULL REFERENCES finance_expense(id) ON DELETE CASCADE, + document_id BIGINT NOT NULL REFERENCES document_item(id) ON DELETE CASCADE, + created_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (expense_id, document_id) +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_project_updated_at') THEN + CREATE TRIGGER trg_finance_project_updated_at BEFORE UPDATE ON finance_project FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_project_budget_updated_at') THEN + CREATE TRIGGER trg_finance_project_budget_updated_at BEFORE UPDATE ON finance_project_budget FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_expense_updated_at') THEN + CREATE TRIGGER trg_finance_expense_updated_at BEFORE UPDATE ON finance_expense FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES ('finance', '/finance', 'Finanzen', 'work', 65, 'Kostenüberwachung, Budgets, Jahresbudgets und Belege') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('finance.read', 'Finanzen lesen', 'Finanzen', 'Finanzprojekte, Budgets, Ausgaben und Belege anzeigen'), + ('finance.write', 'Finanzen bearbeiten', 'Finanzen', 'Finanzprojekte, Budgets, Ausgaben und Belege anlegen und ändern'), + ('finance.delete', 'Finanzdaten löschen', 'Finanzen', 'Finanzprojekte und Ausgaben archivieren/löschen'), + ('finance.budget', 'Budgets verwalten', 'Finanzen', 'Projektbudgets und Jahresbudgets pflegen') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_page(role_profile_id, page_key, can_view) +SELECT rp.id, 'finance', TRUE + FROM app_role_profile rp + WHERE rp.code IN ('admin','chair') +ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT rp.id, perm.permission_key, TRUE + FROM app_role_profile rp + CROSS JOIN (VALUES ('finance.read'), ('finance.write'), ('finance.delete'), ('finance.budget')) AS perm(permission_key) + WHERE rp.code IN ('admin','chair') +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +ALTER TABLE finance_project ENABLE ROW LEVEL SECURITY; +ALTER TABLE finance_project_budget ENABLE ROW LEVEL SECURITY; +ALTER TABLE finance_expense ENABLE ROW LEVEL SECURITY; +ALTER TABLE finance_expense_document ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS finance_project_select ON finance_project; +DROP POLICY IF EXISTS finance_project_modify ON finance_project; +CREATE POLICY finance_project_select ON finance_project FOR SELECT USING ( + deleted_at IS NULL AND app_has_permission('finance.read') +); +CREATE POLICY finance_project_modify ON finance_project FOR ALL USING ( + app_has_permission('finance.write') +) WITH CHECK (app_has_permission('finance.write')); + +DROP POLICY IF EXISTS finance_project_budget_select ON finance_project_budget; +DROP POLICY IF EXISTS finance_project_budget_modify ON finance_project_budget; +CREATE POLICY finance_project_budget_select ON finance_project_budget FOR SELECT USING ( + app_has_permission('finance.read') +); +CREATE POLICY finance_project_budget_modify ON finance_project_budget FOR ALL USING ( + app_has_permission('finance.budget') OR app_has_permission('finance.write') +) WITH CHECK (app_has_permission('finance.budget') OR app_has_permission('finance.write')); + +DROP POLICY IF EXISTS finance_expense_select ON finance_expense; +DROP POLICY IF EXISTS finance_expense_modify ON finance_expense; +CREATE POLICY finance_expense_select ON finance_expense FOR SELECT USING ( + deleted_at IS NULL AND app_has_permission('finance.read') +); +CREATE POLICY finance_expense_modify ON finance_expense FOR ALL USING ( + app_has_permission('finance.write') +) WITH CHECK (app_has_permission('finance.write')); + +DROP POLICY IF EXISTS finance_expense_document_select ON finance_expense_document; +DROP POLICY IF EXISTS finance_expense_document_modify ON finance_expense_document; +CREATE POLICY finance_expense_document_select ON finance_expense_document FOR SELECT USING ( + app_has_permission('finance.read') +); +CREATE POLICY finance_expense_document_modify ON finance_expense_document FOR ALL USING ( + app_has_permission('finance.write') +) WITH CHECK (app_has_permission('finance.write')); + +DROP POLICY IF EXISTS document_select_policy ON document_item; +DROP POLICY IF EXISTS document_modify_policy ON document_item; +CREATE POLICY document_select_policy ON document_item FOR SELECT USING ( + app_can_read_document(id) + OR EXISTS ( + SELECT 1 + FROM finance_expense_document fed + JOIN finance_expense fe ON fe.id = fed.expense_id AND fe.deleted_at IS NULL + WHERE fed.document_id = document_item.id + AND app_has_permission('finance.read') + ) +); +CREATE POLICY document_modify_policy ON document_item FOR ALL USING ( + app_can_write_document(id) + OR (document_item.document_type = 'finance_receipt' AND document_item.uploaded_by = app_user_id() AND app_has_permission('finance.write')) + OR EXISTS ( + SELECT 1 + FROM finance_expense_document fed + JOIN finance_expense fe ON fe.id = fed.expense_id AND fe.deleted_at IS NULL + WHERE fed.document_id = document_item.id + AND app_has_permission('finance.write') + ) +) WITH CHECK ( + app_role() IN ('admin','chair') + OR app_has_permission('documents.write.all') + OR (app_has_permission('documents.write') AND uploaded_by = app_user_id()) + OR (case_id IS NOT NULL AND app_can_write_case(case_id) AND app_has_permission('documents.write')) + OR (document_item.document_type = 'finance_receipt' AND document_item.uploaded_by = app_user_id() AND app_has_permission('finance.write')) + OR app_has_permission('finance.write') +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON finance_project, finance_project_budget, finance_expense, finance_expense_document TO br_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO br_app; diff --git a/backend/src/db/migrations/024_decision_voting.sql b/backend/src/db/migrations/024_decision_voting.sql new file mode 100644 index 0000000..4ac76a6 --- /dev/null +++ b/backend/src/db/migrations/024_decision_voting.sql @@ -0,0 +1,175 @@ +BEGIN; + +ALTER TABLE decision + ADD COLUMN IF NOT EXISTS voting_open BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS voting_started_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS voting_started_by BIGINT REFERENCES app_user(id), + ADD COLUMN IF NOT EXISTS voting_closed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS voting_closed_by BIGINT REFERENCES app_user(id); + +CREATE TABLE IF NOT EXISTS decision_vote ( + decision_id BIGINT NOT NULL REFERENCES decision(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + vote_value VARCHAR(20) NOT NULL CHECK (vote_value IN ('yes', 'no', 'abstain')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (decision_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_decision_vote_decision_id + ON decision_vote(decision_id); + +CREATE INDEX IF NOT EXISTS idx_decision_vote_user_id + ON decision_vote(user_id); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_decision_vote_updated_at') THEN + CREATE TRIGGER trg_decision_vote_updated_at + BEFORE UPDATE ON decision_vote + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +ALTER TABLE decision_vote ENABLE ROW LEVEL SECURITY; +ALTER TABLE decision_vote FORCE ROW LEVEL SECURITY; + +-- Benutzer desselben Gremiums dürfen sich gegenseitig für Abstimmungslisten sehen. +-- Admin/chair behalten den bisherigen erweiterten Zugriff; jeder Benutzer sieht sich weiterhin selbst. +DROP POLICY IF EXISTS user_select ON app_user; +CREATE POLICY user_select ON app_user FOR SELECT USING ( + app_role() IN ('admin','chair') + OR id = app_user_id() + OR EXISTS ( + SELECT 1 + FROM committee_member mine + JOIN committee_member other_member ON other_member.committee_id = mine.committee_id + WHERE mine.user_id = app_user_id() + AND other_member.user_id = app_user.id + ) +); + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('decisions.vote.manage', 'Abstimmungen verwalten', 'Beschlüsse', 'Beschluss-Abstimmungen öffnen, schließen und auswerten') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT id, 'decisions.vote.manage', TRUE + FROM app_role_profile + WHERE code IN ('admin', 'chair', 'secretary') +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +DROP POLICY IF EXISTS decision_modify_policy ON decision; +CREATE POLICY decision_modify_policy ON decision FOR ALL USING ( + app_has_permission('decisions.write') + OR app_has_permission('decisions.vote.manage') + OR responsible_user_id = app_user_id() + OR EXISTS ( + SELECT 1 + FROM meeting m + JOIN committee_member cm ON cm.committee_id = m.committee_id + WHERE m.id = decision.meeting_id + AND cm.user_id = app_user_id() + ) +) WITH CHECK ( + app_has_permission('decisions.write') + OR app_has_permission('decisions.vote.manage') + OR responsible_user_id = app_user_id() + OR EXISTS ( + SELECT 1 + FROM meeting m + JOIN committee_member cm ON cm.committee_id = m.committee_id + WHERE m.id = decision.meeting_id + AND cm.user_id = app_user_id() + ) +); + +DROP POLICY IF EXISTS decision_vote_select_policy ON decision_vote; +DROP POLICY IF EXISTS decision_vote_insert_policy ON decision_vote; +DROP POLICY IF EXISTS decision_vote_update_policy ON decision_vote; +DROP POLICY IF EXISTS decision_vote_delete_policy ON decision_vote; + +CREATE POLICY decision_vote_select_policy ON decision_vote FOR SELECT USING ( + user_id = app_user_id() + OR app_has_permission('decisions.vote.manage') + OR EXISTS ( + SELECT 1 + FROM decision d + JOIN meeting m ON m.id = d.meeting_id + JOIN committee_member cm ON cm.committee_id = m.committee_id + WHERE d.id = decision_vote.decision_id + AND cm.user_id = app_user_id() + ) +); + +CREATE POLICY decision_vote_insert_policy ON decision_vote FOR INSERT WITH CHECK ( + user_id = app_user_id() + AND app_role() <> 'advisor' + AND EXISTS ( + SELECT 1 + FROM decision d + JOIN meeting m ON m.id = d.meeting_id + JOIN committee_member cm ON cm.committee_id = m.committee_id + WHERE d.id = decision_vote.decision_id + AND d.voting_open = TRUE + AND m.status = 'gestartet' + AND cm.user_id = app_user_id() + ) +); + +CREATE POLICY decision_vote_update_policy ON decision_vote FOR UPDATE USING ( + user_id = app_user_id() + AND app_role() <> 'advisor' + AND EXISTS ( + SELECT 1 + FROM decision d + JOIN meeting m ON m.id = d.meeting_id + JOIN committee_member cm ON cm.committee_id = m.committee_id + WHERE d.id = decision_vote.decision_id + AND d.voting_open = TRUE + AND m.status = 'gestartet' + AND cm.user_id = app_user_id() + ) +) WITH CHECK ( + user_id = app_user_id() + AND app_role() <> 'advisor' +); + +CREATE POLICY decision_vote_delete_policy ON decision_vote FOR DELETE USING ( + app_has_permission('decisions.vote.manage') +); + + +CREATE OR REPLACE FUNCTION sync_decision_vote_counts(p_decision_id BIGINT) +RETURNS TABLE(votes_yes INTEGER, votes_no INTEGER, votes_abstain INTEGER) AS $$ +DECLARE + yes_count INTEGER; + no_count INTEGER; + abstain_count INTEGER; +BEGIN + SELECT COUNT(*) FILTER (WHERE vote_value = 'yes')::int, + COUNT(*) FILTER (WHERE vote_value = 'no')::int, + COUNT(*) FILTER (WHERE vote_value = 'abstain')::int + INTO yes_count, no_count, abstain_count + FROM decision_vote + WHERE decision_id = p_decision_id; + + UPDATE decision + SET votes_yes = COALESCE(yes_count, 0), + votes_no = COALESCE(no_count, 0), + votes_abstain = COALESCE(abstain_count, 0), + updated_at = NOW() + WHERE id = p_decision_id; + + RETURN QUERY SELECT COALESCE(yes_count, 0), COALESCE(no_count, 0), COALESCE(abstain_count, 0); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +GRANT EXECUTE ON FUNCTION sync_decision_vote_counts(BIGINT) TO PUBLIC; + +COMMIT; diff --git a/backend/src/db/migrations/025_kummerkasten.sql b/backend/src/db/migrations/025_kummerkasten.sql new file mode 100644 index 0000000..3c92ead --- /dev/null +++ b/backend/src/db/migrations/025_kummerkasten.sql @@ -0,0 +1,82 @@ +CREATE TABLE IF NOT EXISTS kummerkasten_setting ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled BOOLEAN NOT NULL DEFAULT FALSE, + access_password_hash TEXT, + intro_text TEXT NOT NULL DEFAULT 'Reichen Sie hier vertraulich einen Vorschlag oder ein Anliegen für die Tagesordnung ein.', + target_committee_id BIGINT REFERENCES committee(id) ON DELETE SET NULL, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO kummerkasten_setting(id) +VALUES (1) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS kummerkasten_submission ( + id BIGSERIAL PRIMARY KEY, + agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + submitter_name VARCHAR(255), + submitter_contact VARCHAR(255), + target_committee_id BIGINT REFERENCES committee(id) ON DELETE SET NULL, + status VARCHAR(40) NOT NULL DEFAULT 'converted_to_agenda', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_kummerkasten_submission_created_at ON kummerkasten_submission(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_kummerkasten_submission_agenda_item ON kummerkasten_submission(agenda_item_id); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_kummerkasten_setting_updated_at') THEN + CREATE TRIGGER trg_kummerkasten_setting_updated_at + BEFORE UPDATE ON kummerkasten_setting + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES + ('kummerkasten-admin', '/kummerkasten-admin', 'Kummerkasten', 'admin', 245, 'Öffentliche Kummerkasten-Seite konfigurieren') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('kummerkasten.configure', 'Kummerkasten konfigurieren', 'Kummerkasten', 'Öffentliche Kummerkasten-Seite einschalten und Kennwort verwalten') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_page(role_profile_id, page_key, can_view) +SELECT id, 'kummerkasten-admin', TRUE + FROM app_role_profile + WHERE code = 'admin' +ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT id, 'kummerkasten.configure', TRUE + FROM app_role_profile + WHERE code = 'admin' +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +ALTER TABLE kummerkasten_setting ENABLE ROW LEVEL SECURITY; +ALTER TABLE kummerkasten_submission ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS kummerkasten_setting_select ON kummerkasten_setting; +DROP POLICY IF EXISTS kummerkasten_setting_modify ON kummerkasten_setting; +CREATE POLICY kummerkasten_setting_select ON kummerkasten_setting FOR SELECT USING (app_has_permission('kummerkasten.configure')); +CREATE POLICY kummerkasten_setting_modify ON kummerkasten_setting FOR ALL USING (app_has_permission('kummerkasten.configure')) WITH CHECK (app_has_permission('kummerkasten.configure')); + +DROP POLICY IF EXISTS kummerkasten_submission_select ON kummerkasten_submission; +DROP POLICY IF EXISTS kummerkasten_submission_modify ON kummerkasten_submission; +CREATE POLICY kummerkasten_submission_select ON kummerkasten_submission FOR SELECT USING (app_has_permission('kummerkasten.configure')); +CREATE POLICY kummerkasten_submission_modify ON kummerkasten_submission FOR ALL USING (app_has_permission('kummerkasten.configure')) WITH CHECK (app_has_permission('kummerkasten.configure')); diff --git a/backend/src/db/migrations/026_remove_kummerkasten_default_duration.sql b/backend/src/db/migrations/026_remove_kummerkasten_default_duration.sql new file mode 100644 index 0000000..a53b466 --- /dev/null +++ b/backend/src/db/migrations/026_remove_kummerkasten_default_duration.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS kummerkasten_setting + DROP COLUMN IF EXISTS default_duration_minutes; diff --git a/backend/src/db/migrations/027_remove_kummerkasten_duration_minutes.sql b/backend/src/db/migrations/027_remove_kummerkasten_duration_minutes.sql new file mode 100644 index 0000000..fa21541 --- /dev/null +++ b/backend/src/db/migrations/027_remove_kummerkasten_duration_minutes.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS kummerkasten_submission + DROP COLUMN IF EXISTS duration_minutes; diff --git a/backend/src/db/migrations/028_finance_invoice_lines_tax_codes.sql b/backend/src/db/migrations/028_finance_invoice_lines_tax_codes.sql new file mode 100644 index 0000000..6220e55 --- /dev/null +++ b/backend/src/db/migrations/028_finance_invoice_lines_tax_codes.sql @@ -0,0 +1,180 @@ +CREATE TABLE IF NOT EXISTS finance_tax_code ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(160) NOT NULL UNIQUE, + label VARCHAR(220) NOT NULL, + tax_rate_percent NUMERIC(6,3) NOT NULL DEFAULT 0 CHECK (tax_rate_percent >= 0 AND tax_rate_percent <= 100), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 1000, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS finance_expense_line ( + id BIGSERIAL PRIMARY KEY, + expense_id BIGINT NOT NULL REFERENCES finance_expense(id) ON DELETE CASCADE, + line_no INTEGER NOT NULL DEFAULT 1, + description VARCHAR(255) NOT NULL, + tax_code_id BIGINT REFERENCES finance_tax_code(id) ON DELETE SET NULL, + tax_code_key VARCHAR(160), + net_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (net_amount >= 0), + tax_rate_percent NUMERIC(6,3) NOT NULL DEFAULT 0 CHECK (tax_rate_percent >= 0 AND tax_rate_percent <= 100), + tax_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + gross_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (gross_amount >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT finance_expense_line_line_no_unique UNIQUE (expense_id, line_no) +); + +CREATE INDEX IF NOT EXISTS idx_finance_expense_line_expense ON finance_expense_line(expense_id); +CREATE INDEX IF NOT EXISTS idx_finance_expense_line_tax_code ON finance_expense_line(tax_code_id); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_tax_code_updated_at') THEN + CREATE TRIGGER trg_finance_tax_code_updated_at BEFORE UPDATE ON finance_tax_code FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_expense_line_updated_at') THEN + CREATE TRIGGER trg_finance_expense_line_updated_at BEFORE UPDATE ON finance_expense_line FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +INSERT INTO finance_tax_code(code, label, tax_rate_percent, is_active, sort_order) +VALUES + ('Kfz_Betriebskosten_19%', 'Kfz Betriebskosten 19%', 19.000, TRUE, 10), + ('Mietwagen_Betanken_19%', 'Mietwagen Betanken 19%', 19.000, TRUE, 11), + ('Mietwagen_Ausland', 'Mietwagen Ausland', 0.000, TRUE, 12), + ('Bahn_ueber_50_km_19%', 'Bahn ueber 50 km 19%', 19.000, TRUE, 13), + ('Bahn_ueber_50_km', 'Bahn ueber 50 km', 0.000, TRUE, 14), + ('Bahn_unter_50_km_7%', 'Bahn unter 50 km 7%', 7.000, TRUE, 15), + ('Bahn_unter_50_km', 'Bahn unter 50 km', 0.000, TRUE, 16), + ('Bewirtungskosten_19%', 'Bewirtungskosten 19%', 19.000, TRUE, 17), + ('Bewirtungskosten_7%', 'Bewirtungskosten 7%', 7.000, TRUE, 18), + ('Bewirtungskosten', 'Bewirtungskosten', 0.000, TRUE, 19), + ('Bewirtungskosten_30_19%', 'Bewirtungskosten 30 19%', 19.000, TRUE, 20), + ('Bewirtungskosten_30_7%', 'Bewirtungskosten 30 7%', 7.000, TRUE, 21), + ('Bewirtungskosten_30', 'Bewirtungskosten 30', 0.000, TRUE, 22), + ('Buerobedarf_19%', 'Buerobedarf 19%', 19.000, TRUE, 23), + ('Buerobedarf', 'Buerobedarf', 0.000, TRUE, 24), + ('Bus_ueber_50_km_19%', 'Bus ueber 50 km 19%', 19.000, TRUE, 25), + ('Bus_ueber_50_km', 'Bus ueber 50 km', 0.000, TRUE, 26), + ('Bus_unter_50_km_7%', 'Bus unter 50 km 7%', 7.000, TRUE, 27), + ('Bus_unter_50_km', 'Bus unter 50 km', 0.000, TRUE, 28), + ('Flug_19%', 'Flug 19%', 19.000, TRUE, 29), + ('Flug', 'Flug', 0.000, TRUE, 30), + ('Fruehstueck_(Hotel_Pension)_19%', 'Fruehstueck (Hotel Pension) 19%', 19.000, TRUE, 31), + ('Fruehstueck_(Hotel_Pension)', 'Fruehstueck (Hotel Pension)', 0.000, TRUE, 32), + ('Mittagessen_(Hotel_Pension)_19%', 'Mittagessen (Hotel Pension) 19%', 19.000, TRUE, 33), + ('Mittagessen_(Hotel_Pension)', 'Mittagessen (Hotel Pension)', 0.000, TRUE, 34), + ('Abendessen_(Hotel_Pension)_19%', 'Abendessen (Hotel Pension) 19%', 19.000, TRUE, 35), + ('Abendessen_(Hotel_Pension)', 'Abendessen (Hotel Pension)', 0.000, TRUE, 36), + ('Handy_19%', 'Handy 19%', 19.000, TRUE, 37), + ('Handy', 'Handy', 0.000, TRUE, 38), + ('Hotel_Pension_7%', 'Hotel Pension 7%', 7.000, TRUE, 39), + ('Hotel_Pension', 'Hotel Pension', 0.000, TRUE, 40), + ('Internet_19%', 'Internet 19%', 19.000, TRUE, 41), + ('Internet', 'Internet', 0.000, TRUE, 42), + ('Lebensmittel_19%%', 'Lebensmittel 19%%', 19.000, TRUE, 43), + ('Lebensmittel_7%', 'Lebensmittel 7%', 7.000, TRUE, 44), + ('Lebensmittel', 'Lebensmittel', 0.000, TRUE, 45), + ('Mietwagen_19%', 'Mietwagen 19%', 19.000, TRUE, 46), + ('Mietwagen', 'Mietwagen', 0.000, TRUE, 47), + ('Mietwohnung_Appartement_19%', 'Mietwohnung Appartement 19%', 19.000, TRUE, 48), + ('Mietwohnung_Appartement', 'Mietwohnung Appartement', 0.000, TRUE, 49), + ('Parkgebuehren_19%', 'Parkgebuehren 19%', 19.000, TRUE, 50), + ('Parkgebuehren', 'Parkgebuehren', 0.000, TRUE, 51), + ('Porto_19%', 'Porto 19%', 19.000, TRUE, 52), + ('Porto', 'Porto', 0.000, TRUE, 53), + ('Reservierung_19%', 'Reservierung 19%', 19.000, TRUE, 54), + ('Reservierung', 'Reservierung', 0.000, TRUE, 55), + ('Sonstiges_(19_%_MwSt.)', 'Sonstiges (19 % MwSt.)', 19.000, TRUE, 56), + ('Sonstiges_(7_%_MwSt.)', 'Sonstiges (7 % MwSt.)', 7.000, TRUE, 57), + ('Sonstiges', 'Sonstiges', 0.000, TRUE, 58), + ('Tankbelege_19%', 'Tankbelege 19%', 19.000, TRUE, 59), + ('Tankbelege', 'Tankbelege', 0.000, TRUE, 60), + ('Taxi_ueber_50_km_19%', 'Taxi ueber 50 km 19%', 19.000, TRUE, 61), + ('Taxi_ueber_50_km', 'Taxi ueber 50 km', 0.000, TRUE, 62), + ('Taxi_unter_50_km_7%', 'Taxi unter 50 km 7%', 7.000, TRUE, 63), + ('Taxi_unter_50_km', 'Taxi unter 50 km', 0.000, TRUE, 64), + ('Telefonkosten_19%', 'Telefonkosten 19%', 19.000, TRUE, 65), + ('Telefonkosten', 'Telefonkosten', 0.000, TRUE, 66), + ('Abendessen_(Hotel/Pension)_19%', 'Abendessen (Hotel/Pension) 19%', 19.000, TRUE, 67) +ON CONFLICT (code) DO UPDATE + SET label = EXCLUDED.label, + tax_rate_percent = EXCLUDED.tax_rate_percent, + sort_order = EXCLUDED.sort_order, + updated_at = NOW(); + + + +INSERT INTO finance_expense_line( + expense_id, line_no, description, tax_code_key, + net_amount, tax_rate_percent, tax_amount, gross_amount +) +SELECT e.id, + 1, + COALESCE(NULLIF(e.cost_category, ''), e.title, 'Rechnungsposition'), + e.cost_category, + e.net_amount, + e.tax_rate_percent, + e.tax_amount, + e.gross_amount + FROM finance_expense e + WHERE NOT EXISTS ( + SELECT 1 FROM finance_expense_line l WHERE l.expense_id = e.id + ); + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES ('finance-tax-codes', '/finance-tax-codes', 'MwSt.-Sätze Finanzen', 'admin', 55, 'Kostenarten und MwSt.-Sätze für Rechnungspositionen pflegen') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('finance.tax_codes', 'MwSt.-Sätze verwalten', 'Finanzen', 'Kostenarten und MwSt.-Sätze für Finanz-Rechnungspositionen pflegen') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_page(role_profile_id, page_key, can_view) +SELECT rp.id, 'finance-tax-codes', TRUE + FROM app_role_profile rp + WHERE rp.code IN ('admin','chair') +ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT rp.id, 'finance.tax_codes', TRUE + FROM app_role_profile rp + WHERE rp.code IN ('admin','chair') +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +ALTER TABLE finance_tax_code ENABLE ROW LEVEL SECURITY; +ALTER TABLE finance_expense_line ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS finance_tax_code_select ON finance_tax_code; +DROP POLICY IF EXISTS finance_tax_code_modify ON finance_tax_code; +CREATE POLICY finance_tax_code_select ON finance_tax_code FOR SELECT USING ( + app_has_permission('finance.read') OR app_has_permission('finance.tax_codes') +); +CREATE POLICY finance_tax_code_modify ON finance_tax_code FOR ALL USING ( + app_has_permission('finance.tax_codes') OR app_has_permission('finance.budget') +) WITH CHECK (app_has_permission('finance.tax_codes') OR app_has_permission('finance.budget')); + +DROP POLICY IF EXISTS finance_expense_line_select ON finance_expense_line; +DROP POLICY IF EXISTS finance_expense_line_modify ON finance_expense_line; +CREATE POLICY finance_expense_line_select ON finance_expense_line FOR SELECT USING ( + app_has_permission('finance.read') +); +CREATE POLICY finance_expense_line_modify ON finance_expense_line FOR ALL USING ( + app_has_permission('finance.write') +) WITH CHECK (app_has_permission('finance.write')); + +GRANT SELECT, INSERT, UPDATE, DELETE ON finance_tax_code, finance_expense_line TO br_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO br_app; diff --git a/backend/src/db/migrations/029_department_catalog.sql b/backend/src/db/migrations/029_department_catalog.sql new file mode 100644 index 0000000..9fc1249 --- /dev/null +++ b/backend/src/db/migrations/029_department_catalog.sql @@ -0,0 +1,95 @@ +CREATE TABLE IF NOT EXISTS department_catalog ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(80) NOT NULL UNIQUE, + label VARCHAR(120) NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 1000, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_department_catalog_updated_at') THEN + CREATE TRIGGER trg_department_catalog_updated_at + BEFORE UPDATE ON department_catalog + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +INSERT INTO department_catalog(code, label, sort_order) +VALUES + ('it', 'IT', 10), + ('personal', 'Personal', 20), + ('fertigung', 'Fertigung', 30), + ('qualitaet', 'Qualität', 40), + ('logistik', 'Logistik', 50), + ('einkauf', 'Einkauf', 60), + ('verwaltung', 'Verwaltung', 70), + ('sonstiges', 'Sonstiges', 999) +ON CONFLICT (code) DO UPDATE + SET label = EXCLUDED.label, + sort_order = EXCLUDED.sort_order, + is_active = TRUE; + +INSERT INTO department_catalog(code, label, sort_order) +SELECT DISTINCT + lower(regexp_replace(regexp_replace(trim(department), '[^[:alnum:]]+', '_', 'g'), '^_+|_+$', '', 'g')) AS code, + trim(department) AS label, + 500 + FROM br_case + WHERE department IS NOT NULL + AND trim(department) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM department_catalog d + WHERE d.code = lower(regexp_replace(regexp_replace(trim(br_case.department), '[^[:alnum:]]+', '_', 'g'), '^_+|_+$', '', 'g')) + OR d.code = trim(br_case.department) + ) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES ('departments', '/departments', 'Abteilungen', 'admin', 215, 'Abteilungen für Vorgänge pflegen') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES ('departments.manage', 'Abteilungen verwalten', 'Administration', 'Abteilungen anlegen, bearbeiten und deaktivieren/löschen') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_page(role_profile_id, page_key, can_view) +SELECT id, 'departments', TRUE + FROM app_role_profile + WHERE code IN ('admin', 'chair') +ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT id, 'departments.manage', TRUE + FROM app_role_profile + WHERE code IN ('admin', 'chair') +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +ALTER TABLE department_catalog ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS department_catalog_select ON department_catalog; +DROP POLICY IF EXISTS department_catalog_modify ON department_catalog; +CREATE POLICY department_catalog_select ON department_catalog FOR SELECT USING ( + app_can_view_page('departments') + OR app_has_permission('departments.manage') + OR app_has_permission('cases.write') + OR app_can_view_page('cases') +); +CREATE POLICY department_catalog_modify ON department_catalog FOR ALL USING (app_has_permission('departments.manage')) WITH CHECK (app_has_permission('departments.manage')); + +GRANT SELECT, INSERT, UPDATE, DELETE ON department_catalog TO br_app; +GRANT USAGE, SELECT ON SEQUENCE department_catalog_id_seq TO br_app; diff --git a/backend/src/db/migrations/030_conf_level_oeffentlich.sql b/backend/src/db/migrations/030_conf_level_oeffentlich.sql new file mode 100644 index 0000000..fd78a80 --- /dev/null +++ b/backend/src/db/migrations/030_conf_level_oeffentlich.sql @@ -0,0 +1,12 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'conf_level' + AND e.enumlabel = 'oeffentlich' + ) THEN + ALTER TYPE conf_level ADD VALUE 'oeffentlich'; + END IF; +END $$; diff --git a/backend/src/db/migrations/031_document_categories_public_documents.sql b/backend/src/db/migrations/031_document_categories_public_documents.sql new file mode 100644 index 0000000..6eee397 --- /dev/null +++ b/backend/src/db/migrations/031_document_categories_public_documents.sql @@ -0,0 +1,152 @@ +CREATE TABLE IF NOT EXISTS document_category_catalog ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(80) NOT NULL UNIQUE, + label VARCHAR(120) NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 1000, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public_document_setting ( + id INTEGER PRIMARY KEY DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + intro_text TEXT NOT NULL DEFAULT 'Vom Betriebsrat freigegebene Dokumente', + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT public_document_setting_singleton CHECK (id = 1) +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_document_category_catalog_updated_at') THEN + CREATE TRIGGER trg_document_category_catalog_updated_at + BEFORE UPDATE ON document_category_catalog + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_public_document_setting_updated_at') THEN + CREATE TRIGGER trg_public_document_setting_updated_at + BEFORE UPDATE ON public_document_setting + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +ALTER TABLE document_item + ADD COLUMN IF NOT EXISTS document_category_id BIGINT REFERENCES document_category_catalog(id); + +INSERT INTO document_category_catalog(code, label, description, sort_order) +VALUES + ('allgemein', 'Allgemein', 'Allgemeine Dokumente', 10), + ('protokoll', 'Protokoll', 'Sitzungs- und Gesprächsprotokolle', 20), + ('beschluss', 'Beschluss', 'Beschlussbezogene Dokumente', 30), + ('rechnung', 'Rechnung', 'Rechnungen und Belege', 40), + ('vertrag', 'Vertrag', 'Verträge und Vereinbarungen', 50), + ('vorlage', 'Vorlage', 'Vorlagen und Muster', 60), + ('oeffentlich', 'Öffentlich', 'Öffentlich freigegebene Dokumente', 70), + ('sonstiges', 'Sonstiges', 'Sonstige Dokumente', 999) +ON CONFLICT (code) DO UPDATE + SET label = EXCLUDED.label, + description = EXCLUDED.description, + sort_order = EXCLUDED.sort_order, + is_active = TRUE; + +UPDATE document_item d + SET document_category_id = c.id + FROM document_category_catalog c + WHERE d.document_category_id IS NULL + AND c.code = CASE + WHEN lower(COALESCE(d.document_type, '')) LIKE '%protokoll%' THEN 'protokoll' + WHEN lower(COALESCE(d.document_type, '')) LIKE '%beschluss%' THEN 'beschluss' + WHEN lower(COALESCE(d.document_type, '')) LIKE '%rechnung%' OR lower(COALESCE(d.document_type, '')) LIKE '%beleg%' THEN 'rechnung' + WHEN lower(COALESCE(d.document_type, '')) LIKE '%vertrag%' THEN 'vertrag' + WHEN lower(COALESCE(d.document_type, '')) LIKE '%vorlage%' THEN 'vorlage' + ELSE 'allgemein' + END; + +INSERT INTO public_document_setting(id, enabled, intro_text) +VALUES (1, FALSE, 'Vom Betriebsrat freigegebene Dokumente') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES + ('document_categories', '/document-categories', 'Dokumentenkategorien', 'admin', 216, 'Kategorien für Dokumente pflegen'), + ('public_documents_admin', '/public-documents-admin', 'Öffentliche Dokumente', 'admin', 217, 'Öffentliche Dokumentenseite konfigurieren') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('document_categories.manage', 'Dokumentenkategorien verwalten', 'Administration', 'Dokumentenkategorien anlegen, bearbeiten und deaktivieren/löschen'), + ('public_documents.configure', 'Öffentliche Dokumente konfigurieren', 'Administration', 'Öffentliche Dokumentenseite ein- und ausschalten sowie Beschreibung pflegen') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_page(role_profile_id, page_key, can_view) +SELECT id, page_key, TRUE + FROM app_role_profile + CROSS JOIN (VALUES ('document_categories'), ('public_documents_admin')) AS p(page_key) + WHERE code IN ('admin', 'chair') +ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT id, permission_key, TRUE + FROM app_role_profile + CROSS JOIN (VALUES ('document_categories.manage'), ('public_documents.configure')) AS p(permission_key) + WHERE code IN ('admin', 'chair') +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +CREATE OR REPLACE FUNCTION app_can_read_document(target_document_id BIGINT) RETURNS BOOLEAN AS $$ +DECLARE + row document_item%ROWTYPE; +BEGIN + SELECT * INTO row FROM document_item WHERE id = target_document_id AND deleted_at IS NULL; + IF NOT FOUND THEN RETURN FALSE; END IF; + IF row.scan_status <> 'clean' THEN RETURN FALSE; END IF; + + IF row.confidentiality_level = 'oeffentlich' THEN RETURN TRUE; END IF; + IF app_role() IN ('admin', 'chair') OR app_has_permission('documents.read.all') THEN RETURN TRUE; END IF; + IF row.uploaded_by = app_user_id() THEN RETURN TRUE; END IF; + IF row.case_id IS NOT NULL AND app_can_read_case(row.case_id) THEN RETURN TRUE; END IF; + IF app_user_has_document_team_access(row.id, FALSE) THEN RETURN TRUE; END IF; + IF row.case_id IS NULL AND row.decision_id IS NULL AND row.confidentiality_level = 'intern' AND app_has_permission('documents.read.released') THEN RETURN TRUE; END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +ALTER TABLE document_category_catalog ENABLE ROW LEVEL SECURITY; +ALTER TABLE public_document_setting ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS document_category_catalog_select ON document_category_catalog; +DROP POLICY IF EXISTS document_category_catalog_modify ON document_category_catalog; +CREATE POLICY document_category_catalog_select ON document_category_catalog FOR SELECT USING ( + is_active = TRUE + OR app_can_view_page('documents') + OR app_can_view_page('document_categories') + OR app_has_permission('documents.write') + OR app_has_permission('document_categories.manage') +); +CREATE POLICY document_category_catalog_modify ON document_category_catalog FOR ALL USING (app_has_permission('document_categories.manage')) WITH CHECK (app_has_permission('document_categories.manage')); + +DROP POLICY IF EXISTS public_document_setting_select ON public_document_setting; +DROP POLICY IF EXISTS public_document_setting_modify ON public_document_setting; +CREATE POLICY public_document_setting_select ON public_document_setting FOR SELECT USING (TRUE); +CREATE POLICY public_document_setting_modify ON public_document_setting FOR ALL USING (app_has_permission('public_documents.configure')) WITH CHECK (app_has_permission('public_documents.configure')); + +CREATE INDEX IF NOT EXISTS idx_document_item_category ON document_item(document_category_id); +CREATE INDEX IF NOT EXISTS idx_document_item_public ON document_item(confidentiality_level, scan_status, deleted_at); + +GRANT SELECT, INSERT, UPDATE, DELETE ON document_category_catalog, public_document_setting TO br_app; +GRANT USAGE, SELECT ON SEQUENCE document_category_catalog_id_seq TO br_app; diff --git a/backend/src/db/migrations/032_public_documents_password.sql b/backend/src/db/migrations/032_public_documents_password.sql new file mode 100644 index 0000000..38b8d8b --- /dev/null +++ b/backend/src/db/migrations/032_public_documents_password.sql @@ -0,0 +1,8 @@ +ALTER TABLE public_document_setting + ADD COLUMN IF NOT EXISTS access_password_hash TEXT; + +COMMENT ON COLUMN public_document_setting.access_password_hash IS 'Argon2-Hash des globalen Kennworts für die fast öffentliche Dokumentenseite.'; + +UPDATE app_permission + SET description = 'Öffentliche Dokumentenseite ein- und ausschalten, Kennwort setzen sowie Beschreibung pflegen' + WHERE permission_key = 'public_documents.configure'; diff --git a/backend/src/db/migrations/033_public_documents_intro_default.sql b/backend/src/db/migrations/033_public_documents_intro_default.sql new file mode 100644 index 0000000..92aa175 --- /dev/null +++ b/backend/src/db/migrations/033_public_documents_intro_default.sql @@ -0,0 +1,4 @@ +UPDATE public_document_setting + SET intro_text = 'Vom Betriebsrat freigegebene Dokumente' + WHERE id = 1 + AND intro_text = 'Hier finden Sie öffentlich freigegebene Dokumente.'; diff --git a/backend/src/db/migrations/034_comment_protocol_toggle.sql b/backend/src/db/migrations/034_comment_protocol_toggle.sql new file mode 100644 index 0000000..d0e3ab1 --- /dev/null +++ b/backend/src/db/migrations/034_comment_protocol_toggle.sql @@ -0,0 +1,6 @@ +ALTER TABLE comment_item + ADD COLUMN IF NOT EXISTS include_in_protocol BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE INDEX IF NOT EXISTS idx_comment_item_agenda_protocol + ON comment_item(entity_type, entity_id, include_in_protocol) + WHERE deleted_at IS NULL; diff --git a/backend/src/db/migrations/035_meeting_protocol_attendees.sql b/backend/src/db/migrations/035_meeting_protocol_attendees.sql new file mode 100644 index 0000000..d3013e3 --- /dev/null +++ b/backend/src/db/migrations/035_meeting_protocol_attendees.sql @@ -0,0 +1,18 @@ +UPDATE template_item + SET content = CASE + WHEN content LIKE '%{{quorum_statement}}%' THEN + REPLACE(content, '{{quorum_statement}}', '{{quorum_statement}} +{{attendees_block}}') + WHEN content LIKE '%## Protokollnotizen%' THEN + REPLACE(content, '## Protokollnotizen', '{{attendees_block}} + +## Protokollnotizen') + ELSE + content || ' + +{{attendees_block}}' + END, + version_label = CASE WHEN version_label IN ('v1', 'v2') THEN 'v3' ELSE version_label END, + updated_at = NOW() + WHERE category = 'meeting_protocol' + AND content NOT LIKE '%{{attendees_block}}%'; diff --git a/backend/src/db/migrations/036_agenda_item_return_workflow.sql b/backend/src/db/migrations/036_agenda_item_return_workflow.sql new file mode 100644 index 0000000..eaa8833 --- /dev/null +++ b/backend/src/db/migrations/036_agenda_item_return_workflow.sql @@ -0,0 +1,49 @@ +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS status VARCHAR(30) NOT NULL DEFAULT 'offen'; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS returned_from_meeting_id BIGINT REFERENCES meeting(id) ON DELETE SET NULL; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS returned_at TIMESTAMPTZ; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS returned_by BIGINT REFERENCES app_user(id) ON DELETE SET NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_meeting_agenda_item_status' + AND conrelid = 'meeting_agenda_item'::regclass + ) THEN + ALTER TABLE meeting_agenda_item + ADD CONSTRAINT chk_meeting_agenda_item_status + CHECK (status IN ('offen', 'in_sitzung', 'rueckfrage', 'klaerung', 'erledigt')); + END IF; +END $$; + +UPDATE meeting_agenda_item + SET status = CASE WHEN meeting_id IS NULL THEN 'offen' ELSE 'in_sitzung' END + WHERE status IS NULL + OR status NOT IN ('offen', 'in_sitzung', 'rueckfrage', 'klaerung', 'erledigt') + OR (meeting_id IS NOT NULL AND status = 'offen'); + +CREATE TABLE IF NOT EXISTS meeting_agenda_item_return_log ( + id BIGSERIAL PRIMARY KEY, + agenda_item_id BIGINT NOT NULL REFERENCES meeting_agenda_item(id) ON DELETE CASCADE, + meeting_id BIGINT NOT NULL REFERENCES meeting(id) ON DELETE CASCADE, + agenda_number_at_return VARCHAR(20), + title_at_return VARCHAR(255) NOT NULL, + return_type VARCHAR(30) NOT NULL DEFAULT 'rueckfrage', + remark TEXT NOT NULL, + include_in_protocol BOOLEAN NOT NULL DEFAULT TRUE, + returned_by BIGINT REFERENCES app_user(id) ON DELETE SET NULL, + returned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_meeting_agenda_item_return_type CHECK (return_type IN ('rueckfrage', 'klaerung', 'bemerkung')) +); + +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_status ON meeting_agenda_item(status); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_return_log_meeting ON meeting_agenda_item_return_log(meeting_id, returned_at DESC); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_return_log_agenda ON meeting_agenda_item_return_log(agenda_item_id, returned_at DESC); diff --git a/backend/src/db/migrations/037_agenda_item_parent_child_followups.sql b/backend/src/db/migrations/037_agenda_item_parent_child_followups.sql new file mode 100644 index 0000000..9a4f651 --- /dev/null +++ b/backend/src/db/migrations/037_agenda_item_parent_child_followups.sql @@ -0,0 +1,45 @@ +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS parent_agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS root_agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_type VARCHAR(30); + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_note TEXT; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_created_from_meeting_id BIGINT REFERENCES meeting(id) ON DELETE SET NULL; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_created_from_agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_created_at TIMESTAMPTZ; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_created_by BIGINT REFERENCES app_user(id) ON DELETE SET NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_meeting_agenda_item_follow_up_type' + AND conrelid = 'meeting_agenda_item'::regclass + ) THEN + ALTER TABLE meeting_agenda_item + ADD CONSTRAINT chk_meeting_agenda_item_follow_up_type + CHECK (follow_up_type IS NULL OR follow_up_type IN ('rueckfrage', 'klaerung', 'bemerkung')); + END IF; +END $$; + +ALTER TABLE meeting_agenda_item_return_log + ADD COLUMN IF NOT EXISTS follow_up_agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_parent ON meeting_agenda_item(parent_agenda_item_id); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_root ON meeting_agenda_item(root_agenda_item_id); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_follow_up_source ON meeting_agenda_item(follow_up_created_from_meeting_id, follow_up_created_from_agenda_item_id); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_return_log_follow_up ON meeting_agenda_item_return_log(follow_up_agenda_item_id); diff --git a/backend/src/db/migrations/038_agenda_item_followup_klaerung.sql b/backend/src/db/migrations/038_agenda_item_followup_klaerung.sql new file mode 100644 index 0000000..50d96a4 --- /dev/null +++ b/backend/src/db/migrations/038_agenda_item_followup_klaerung.sql @@ -0,0 +1,23 @@ +-- Erweitert den Parent/Child-Folge-TOP-Workflow um den Typ "Klärung". +-- Diese Migration ist für bestehende Datenbanken nötig, in denen 036/037 bereits ausgeführt wurden. + +ALTER TABLE meeting_agenda_item + DROP CONSTRAINT IF EXISTS chk_meeting_agenda_item_status; + +ALTER TABLE meeting_agenda_item + ADD CONSTRAINT chk_meeting_agenda_item_status + CHECK (status IN ('offen', 'in_sitzung', 'rueckfrage', 'klaerung', 'erledigt')); + +ALTER TABLE meeting_agenda_item_return_log + DROP CONSTRAINT IF EXISTS chk_meeting_agenda_item_return_type; + +ALTER TABLE meeting_agenda_item_return_log + ADD CONSTRAINT chk_meeting_agenda_item_return_type + CHECK (return_type IN ('rueckfrage', 'klaerung', 'bemerkung')); + +ALTER TABLE meeting_agenda_item + DROP CONSTRAINT IF EXISTS chk_meeting_agenda_item_follow_up_type; + +ALTER TABLE meeting_agenda_item + ADD CONSTRAINT chk_meeting_agenda_item_follow_up_type + CHECK (follow_up_type IS NULL OR follow_up_type IN ('rueckfrage', 'klaerung', 'bemerkung')); diff --git a/backend/src/db/migrations/039_template_html_editor_assets.sql b/backend/src/db/migrations/039_template_html_editor_assets.sql new file mode 100644 index 0000000..9005768 --- /dev/null +++ b/backend/src/db/migrations/039_template_html_editor_assets.sql @@ -0,0 +1,80 @@ +CREATE TABLE IF NOT EXISTS template_asset ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + file_name VARCHAR(255) NOT NULL, + storage_path TEXT NOT NULL, + storage_provider VARCHAR(30) NOT NULL DEFAULT 'local', + mime_type VARCHAR(120) NOT NULL, + size_bytes BIGINT NOT NULL DEFAULT 0, + sha256_hash VARCHAR(64) NOT NULL, + uploaded_by BIGINT REFERENCES app_user(id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE template_item + ADD COLUMN IF NOT EXISTS content_format VARCHAR(20) NOT NULL DEFAULT 'markdown'; + +ALTER TABLE template_item + ADD COLUMN IF NOT EXISTS logo_asset_id BIGINT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'template_item_logo_asset_id_fkey' + ) THEN + ALTER TABLE template_item + ADD CONSTRAINT template_item_logo_asset_id_fkey + FOREIGN KEY (logo_asset_id) REFERENCES template_asset(id) ON DELETE SET NULL; + END IF; +END$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'template_item_content_format_check' + ) THEN + ALTER TABLE template_item + ADD CONSTRAINT template_item_content_format_check + CHECK (content_format IN ('markdown', 'html')); + END IF; +END$$; + +CREATE INDEX IF NOT EXISTS idx_template_item_logo_asset_id ON template_item(logo_asset_id); +CREATE INDEX IF NOT EXISTS idx_template_asset_active ON template_asset(is_active, created_at DESC); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_template_asset_updated_at' + ) THEN + CREATE TRIGGER trg_template_asset_updated_at + BEFORE UPDATE ON template_asset + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END$$; + +ALTER TABLE template_asset ENABLE ROW LEVEL SECURITY; +ALTER TABLE template_asset FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS template_asset_select_policy ON template_asset; +DROP POLICY IF EXISTS template_asset_modify_policy ON template_asset; +CREATE POLICY template_asset_select_policy ON template_asset + FOR SELECT USING (app_can_view_page('templates')); +CREATE POLICY template_asset_modify_policy ON template_asset + FOR ALL USING (app_has_permission('templates.write')) + WITH CHECK (app_has_permission('templates.write')); + +UPDATE template_item + SET content = '{{template_logo}} + +' || content, + updated_at = NOW() + WHERE category = 'meeting_protocol' + AND content NOT LIKE '%{{template_logo}}%'; diff --git a/backend/src/db/migrations/040_formular_editor_navigation.sql b/backend/src/db/migrations/040_formular_editor_navigation.sql new file mode 100644 index 0000000..cbb5da4 --- /dev/null +++ b/backend/src/db/migrations/040_formular_editor_navigation.sql @@ -0,0 +1,4 @@ +UPDATE app_page + SET label = 'Vorlagen / Formular-Editor', + description = 'Vorlagenverwaltung und HTML-/Formular-Editor' + WHERE page_key = 'templates'; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..9b795aa --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,18 @@ +import { verifyAccessToken } from "../utils/jwt.js"; + +export function authenticate(req, res, next) { + const header = req.headers.authorization || ""; + const token = header.startsWith("Bearer ") ? header.slice(7) : ""; + if (!token) return res.status(401).json({ error: "Nicht angemeldet" }); + try { + req.user = verifyAccessToken(token); + next(); + } catch { + res.status(401).json({ error: "Ungültiges Token" }); + } +} + +export function requireMfa(req, res, next) { + if (!req.user?.mfa_verified) return res.status(403).json({ error: "MFA erforderlich" }); + next(); +} diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js new file mode 100644 index 0000000..b2aee2a --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -0,0 +1,4 @@ +export function errorHandler(err, req, res, next) { + console.error(err); + res.status(err.status || 500).json({ error: err.publicMessage || err.message || "Interner Fehler" }); +} diff --git a/backend/src/middleware/rbac.js b/backend/src/middleware/rbac.js new file mode 100644 index 0000000..e1f0646 --- /dev/null +++ b/backend/src/middleware/rbac.js @@ -0,0 +1,38 @@ +import { userHasPermission, userCanViewPage } from "../services/accessControl.js"; + +export function requireRoles(...roles) { + return (req, res, next) => { + if (!roles.includes(req.user?.role)) { + return res.status(403).json({ error: "Keine Berechtigung" }); + } + next(); + }; +} + +export function requirePermission(permissionKey, fallbackRoles = []) { + return async (req, res, next) => { + try { + const allowed = await userHasPermission(req.user?.sub, req.user?.role, permissionKey); + if (!allowed) return res.status(403).json({ error: "Keine Berechtigung" }); + next(); + } catch (err) { + // Fallback greift nur, falls die neue Permission-Matrix noch nicht migriert ist. + if (fallbackRoles.includes(req.user?.role)) return next(); + next(err); + } + }; +} + +export function requirePageAccess(pageKey, fallbackRoles = []) { + return async (req, res, next) => { + try { + const allowed = await userCanViewPage(req.user?.sub, req.user?.role, pageKey); + if (!allowed) return res.status(403).json({ error: "Keine Berechtigung für diese Seite" }); + next(); + } catch (err) { + // Fallback greift nur, falls die neue Permission-Matrix noch nicht migriert ist. + if (fallbackRoles.includes(req.user?.role)) return next(); + next(err); + } + }; +} diff --git a/backend/src/middleware/security.js b/backend/src/middleware/security.js new file mode 100644 index 0000000..d04df25 --- /dev/null +++ b/backend/src/middleware/security.js @@ -0,0 +1,20 @@ +import rateLimit from "express-rate-limit"; +import helmet from "helmet"; +import cors from "cors"; +import { env } from "../config/env.js"; + +export const commonSecurity = [ + helmet({ + contentSecurityPolicy: false + }), + cors({ + origin: env.corsOrigin, + credentials: true, + }), + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 500, + standardHeaders: true, + legacyHeaders: false, + }), +]; diff --git a/backend/src/middleware/upload.js b/backend/src/middleware/upload.js new file mode 100644 index 0000000..32f5fe7 --- /dev/null +++ b/backend/src/middleware/upload.js @@ -0,0 +1,5 @@ +import multer from "multer"; +export const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 25 * 1024 * 1024 }, +}); diff --git a/backend/src/middleware/validate.js b/backend/src/middleware/validate.js new file mode 100644 index 0000000..7493342 --- /dev/null +++ b/backend/src/middleware/validate.js @@ -0,0 +1,10 @@ +export function validate(schema) { + return (req, res, next) => { + const result = schema.safeParse(req.body); + if (!result.success) { + return res.status(400).json({ error: "Validierungsfehler", details: result.error.issues }); + } + req.body = result.data; + next(); + }; +} diff --git a/backend/src/modules/agenda-items/routes.js b/backend/src/modules/agenda-items/routes.js new file mode 100644 index 0000000..dc323a7 --- /dev/null +++ b/backend/src/modules/agenda-items/routes.js @@ -0,0 +1,385 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission, requireRoles } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { writeAudit } from "../../utils/audit.js"; + +const router = express.Router(); +router.use(authenticate); + +const createSchema = z.object({ + title: z.string().min(3).max(255), + description: z.string().max(8000).optional().nullable(), + duration_minutes: z.coerce.number().int().min(0).max(600).optional().nullable(), + case_id: z.coerce.number().int().positive().optional().nullable(), +}); + +const updateSchema = z.object({ + title: z.string().min(3).max(255).optional(), + description: z.string().max(8000).optional().nullable(), + duration_minutes: z.coerce.number().int().min(0).max(600).optional().nullable(), + case_id: z.coerce.number().int().positive().optional().nullable(), +}); + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function normalizeNullableNumber(value) { + if (value === undefined) return undefined; + if (value === null || value === "") return null; + return Number(value); +} + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === "") return fallback; + if (typeof value === "boolean") return value; + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "ja", "on"].includes(normalized)) return true; + if (["0", "false", "no", "nein", "off"].includes(normalized)) return false; + return fallback; +} + +function mapAgendaItem(row) { + if (!row) return null; + return { + id: row.id, + meeting_id: row.meeting_id, + meeting_number: row.meeting_number, + agenda_number: row.agenda_number, + title: row.title, + description: row.description, + duration_minutes: row.duration_minutes, + case_id: row.case_id, + case_number: row.case_number, + decision_id: row.decision_id, + decision_number: row.decision_number, + decision_title: row.decision_title, + status: row.status || (row.meeting_id ? "in_sitzung" : "offen"), + parent_agenda_item_id: row.parent_agenda_item_id, + parent_agenda_number: row.parent_agenda_number, + parent_title: row.parent_title, + root_agenda_item_id: row.root_agenda_item_id, + follow_up_type: row.follow_up_type, + follow_up_note: row.follow_up_note, + follow_up_created_from_meeting_id: row.follow_up_created_from_meeting_id, + follow_up_source_meeting_number: row.follow_up_source_meeting_number, + follow_up_created_from_agenda_item_id: row.follow_up_created_from_agenda_item_id, + follow_up_created_at: row.follow_up_created_at, + follow_up_created_by: row.follow_up_created_by, + follow_up_created_by_name: row.follow_up_created_by_name, + returned_from_meeting_id: row.returned_from_meeting_id, + returned_at: row.returned_at, + returned_by: row.returned_by, + returned_by_name: row.returned_by_name, + last_return_remark: row.last_return_remark, + last_return_type: row.last_return_type, + created_at: row.created_at, + }; +} + +async function assertCaseReference(db, caseId) { + if (caseId == null) return; + const result = await db.query(`SELECT id FROM br_case WHERE id = $1 AND deleted_at IS NULL`, [caseId]); + if (!result.rows[0]) { + throw Object.assign(new Error("Vorgang nicht gefunden"), { + status: 400, + publicMessage: "Vorgang nicht gefunden", + }); + } +} + +async function getAgendaItems(db, options = {}) { + const includeAssigned = Boolean(options.includeAssigned); + const result = await db.query( + `SELECT ai.id, + ai.meeting_id, + m.meeting_number, + ai.agenda_number, + ai.title, + ai.description, + ai.duration_minutes, + ai.case_id, + c.case_number, + latest_decision.id AS decision_id, + latest_decision.decision_number, + latest_decision.title AS decision_title, + ai.status, + ai.parent_agenda_item_id, + parent_ai.agenda_number AS parent_agenda_number, + parent_ai.title AS parent_title, + ai.root_agenda_item_id, + ai.follow_up_type, + ai.follow_up_note, + ai.follow_up_created_from_meeting_id, + source_meeting.meeting_number AS follow_up_source_meeting_number, + ai.follow_up_created_from_agenda_item_id, + ai.follow_up_created_at, + ai.follow_up_created_by, + fubu.full_name AS follow_up_created_by_name, + ai.returned_from_meeting_id, + ai.returned_at, + ai.returned_by, + ru.full_name AS returned_by_name, + COALESCE(ai.follow_up_note, last_return.remark) AS last_return_remark, + COALESCE(ai.follow_up_type, last_return.return_type) AS last_return_type, + ai.created_at + FROM meeting_agenda_item ai + LEFT JOIN meeting m ON m.id = ai.meeting_id + LEFT JOIN br_case c ON c.id = ai.case_id + LEFT JOIN meeting_agenda_item parent_ai ON parent_ai.id = ai.parent_agenda_item_id + LEFT JOIN meeting source_meeting ON source_meeting.id = ai.follow_up_created_from_meeting_id + LEFT JOIN app_user fubu ON fubu.id = ai.follow_up_created_by + LEFT JOIN app_user ru ON ru.id = ai.returned_by + LEFT JOIN LATERAL ( + SELECT d.id, d.decision_number, d.title + FROM decision d + WHERE d.agenda_item_id = ai.id + ORDER BY d.created_at DESC, d.id DESC + LIMIT 1 + ) latest_decision ON TRUE + LEFT JOIN LATERAL ( + SELECT l.remark, l.return_type + FROM meeting_agenda_item_return_log l + WHERE l.agenda_item_id = ai.id + ORDER BY l.returned_at DESC, l.id DESC + LIMIT 1 + ) last_return ON TRUE + WHERE ($1::boolean = TRUE OR ai.meeting_id IS NULL) + ORDER BY m.meeting_date DESC NULLS LAST, + COALESCE(NULLIF(regexp_replace(COALESCE(ai.agenda_number, ''), '[^0-9]', '', 'g'), ''), '999999')::int, + ai.created_at DESC, + ai.id DESC`, + [includeAssigned] + ); + + return result.rows.map(mapAgendaItem); +} + +async function getAgendaItemById(db, id) { + const result = await db.query( + `SELECT ai.id, + ai.meeting_id, + m.meeting_number, + ai.agenda_number, + ai.title, + ai.description, + ai.duration_minutes, + ai.case_id, + c.case_number, + latest_decision.id AS decision_id, + latest_decision.decision_number, + latest_decision.title AS decision_title, + ai.status, + ai.parent_agenda_item_id, + parent_ai.agenda_number AS parent_agenda_number, + parent_ai.title AS parent_title, + ai.root_agenda_item_id, + ai.follow_up_type, + ai.follow_up_note, + ai.follow_up_created_from_meeting_id, + source_meeting.meeting_number AS follow_up_source_meeting_number, + ai.follow_up_created_from_agenda_item_id, + ai.follow_up_created_at, + ai.follow_up_created_by, + fubu.full_name AS follow_up_created_by_name, + ai.returned_from_meeting_id, + ai.returned_at, + ai.returned_by, + ru.full_name AS returned_by_name, + COALESCE(ai.follow_up_note, last_return.remark) AS last_return_remark, + COALESCE(ai.follow_up_type, last_return.return_type) AS last_return_type, + ai.created_at + FROM meeting_agenda_item ai + LEFT JOIN meeting m ON m.id = ai.meeting_id + LEFT JOIN br_case c ON c.id = ai.case_id + LEFT JOIN meeting_agenda_item parent_ai ON parent_ai.id = ai.parent_agenda_item_id + LEFT JOIN meeting source_meeting ON source_meeting.id = ai.follow_up_created_from_meeting_id + LEFT JOIN app_user fubu ON fubu.id = ai.follow_up_created_by + LEFT JOIN app_user ru ON ru.id = ai.returned_by + LEFT JOIN LATERAL ( + SELECT d.id, d.decision_number, d.title + FROM decision d + WHERE d.agenda_item_id = ai.id + ORDER BY d.created_at DESC, d.id DESC + LIMIT 1 + ) latest_decision ON TRUE + LEFT JOIN LATERAL ( + SELECT l.remark, l.return_type + FROM meeting_agenda_item_return_log l + WHERE l.agenda_item_id = ai.id + ORDER BY l.returned_at DESC, l.id DESC + LIMIT 1 + ) last_return ON TRUE + WHERE ai.id = $1`, + [id] + ); + + return mapAgendaItem(result.rows[0]); +} + +router.get("/", async (req, res, next) => { + try { + const includeAssigned = parseBoolean(req.query.include_assigned, false) || parseBoolean(req.query.all, false); + const rows = await withDbSession(req.user, (db) => getAgendaItems(db, { includeAssigned })); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/:id", async (req, res, next) => { + try { + const row = await withDbSession(req.user, (db) => getAgendaItemById(db, req.params.id)); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.post( + "/", + requireMfa, + requirePermission("agenda.write", ["admin", "chair", "secretary"]), + validate(createSchema), + async (req, res, next) => { + try { + const payload = { + title: req.body.title.trim(), + description: normalizeNullableText(req.body.description), + duration_minutes: req.body.duration_minutes ?? null, + case_id: normalizeNullableNumber(req.body.case_id), + }; + + const row = await withDbSession(req.user, async (db) => { + await assertCaseReference(db, payload.case_id); + + const result = await db.query( + `INSERT INTO meeting_agenda_item(meeting_id, agenda_number, title, description, duration_minutes, case_id, status) + VALUES (NULL, NULL, $1, $2, $3, $4, 'offen') + RETURNING id`, + [payload.title, payload.description, payload.duration_minutes, payload.case_id] + ); + + return getAgendaItemById(db, result.rows[0].id); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "AGENDA_ITEM_CREATED_POOL", + entityType: "agenda_item", + entityId: row.id, + ipAddress: req.ip, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +router.put( + "/:id", + requireMfa, + requirePermission("agenda.write", ["admin", "chair", "secretary"]), + validate(updateSchema), + async (req, res, next) => { + try { + const payload = { + title: req.body.title !== undefined ? req.body.title.trim() : undefined, + description: normalizeNullableText(req.body.description), + duration_minutes: req.body.duration_minutes, + case_id: normalizeNullableNumber(req.body.case_id), + }; + + const row = await withDbSession(req.user, async (db) => { + const existing = await getAgendaItemById(db, req.params.id); + if (!existing) return null; + + await assertCaseReference(db, payload.case_id); + + const fields = []; + const values = []; + let idx = 1; + + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + for (const [column, value] of [ + ["title", payload.title], + ["description", payload.description], + ["duration_minutes", payload.duration_minutes], + ["case_id", payload.case_id], + ]) { + if (value !== undefined) push(column, value); + } + + if (existing.meeting_id === null && ["rueckfrage", "klaerung"].includes(existing.status)) { + push("status", "offen"); + } + + if (!fields.length) { + throw Object.assign(new Error("Keine Felder zum Aktualisieren"), { + status: 400, + publicMessage: "Keine Felder zum Aktualisieren", + }); + } + + values.push(req.params.id); + await db.query(`UPDATE meeting_agenda_item SET ${fields.join(", ")} WHERE id = $${idx}`, values); + return getAgendaItemById(db, req.params.id); + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "AGENDA_ITEM_UPDATED_POOL", + entityType: "agenda_item", + entityId: row.id, + ipAddress: req.ip, + }); + + res.json(row); + } catch (err) { + next(err); + } + } +); + +router.delete("/:id", requireMfa, requirePermission("agenda.write", ["admin", "chair", "secretary"]), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getAgendaItemById(db, req.params.id); + if (!existing) return null; + await db.query(`DELETE FROM meeting_agenda_item WHERE id = $1`, [req.params.id]); + return existing; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "AGENDA_ITEM_DELETED_POOL", + entityType: "agenda_item", + entityId: row.id, + ipAddress: req.ip, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/modules/audit/routes.js b/backend/src/modules/audit/routes.js new file mode 100644 index 0000000..e5aebe4 --- /dev/null +++ b/backend/src/modules/audit/routes.js @@ -0,0 +1,24 @@ +import express from "express"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission, requireRoles } from "../../middleware/rbac.js"; +import { withDbSession } from "../../config/db.js"; + +const router = express.Router(); +router.use(authenticate, requireMfa, requirePermission("system.audit", ["admin", "chair", "secretary"])); + +router.get("/", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query( + `SELECT id, actor_user_id, action, entity_type, entity_id, metadata, ip_address, created_at + FROM audit_log + ORDER BY created_at DESC + LIMIT 500` + ); + return result.rows; + }); + res.json(rows); + } catch (err) { next(err); } +}); + +export default router; diff --git a/backend/src/modules/auth/routes.js b/backend/src/modules/auth/routes.js new file mode 100644 index 0000000..69332ac --- /dev/null +++ b/backend/src/modules/auth/routes.js @@ -0,0 +1,255 @@ +import express from "express"; +import { + loginSchema, + registerSchema, + loginSecondFactorSchema, + totpVerifySchema, + webauthnFinishSchema, +} from "./schemas.js"; +import { validate } from "../../middleware/validate.js"; +import { + login, + refresh, + register, + finalizeSecondFactor, + userByLoginToken, +} from "./service.js"; +import { authenticate } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/rbac.js"; +import { env } from "../../config/env.js"; +import { + beginRegistration, + finishRegistration, + beginAuthentication, + finishAuthentication, +} from "../../services/webauthn.js"; +import { startTotpEnrollment, verifyTotpEnrollment } from "../../services/totp.js"; +import { writeAudit } from "../../utils/audit.js"; + +const router = express.Router(); + +function getRefreshCookieOptions() { + const isProduction = String(env.nodeEnv || "").toLowerCase() === "production"; + + return { + httpOnly: true, + sameSite: "lax", + secure: isProduction && Boolean(env.cookieSecure), + path: "/api/auth", + maxAge: 7 * 24 * 60 * 60 * 1000, + }; +} + +function setRefreshCookie(res, token) { + res.cookie("refresh_token", token, getRefreshCookieOptions()); +} + +function clearRefreshCookie(res) { + res.clearCookie("refresh_token", getRefreshCookieOptions()); +} + +router.post("/login", validate(loginSchema), async (req, res, next) => { + try { + const result = await login({ ...req.body, ipAddress: req.ip }); + + if (result.mfaRequired) { + return res.status(202).json({ + mfaRequired: true, + loginToken: result.loginToken, + availableSecondFactors: result.availableSecondFactors || null, + userHint: result.userHint || null, + }); + } + + setRefreshCookie(res, result.refreshToken); + + res.json({ + accessToken: result.accessToken, + user: result.user, + }); + } catch (err) { + next(err); + } +}); + +router.post("/login/totp", validate(loginSecondFactorSchema), async (req, res, next) => { + try { + const result = await finalizeSecondFactor({ + loginToken: req.body.login_token, + totpCode: req.body.totp_code, + ipAddress: req.ip, + }); + + setRefreshCookie(res, result.refreshToken); + + res.json({ + accessToken: result.accessToken, + user: result.user, + }); + } catch (err) { + next(err); + } +}); + +router.post( + "/login/webauthn/options", + validate(loginSecondFactorSchema.pick({ login_token: true })), + async (req, res, next) => { + try { + const user = await userByLoginToken(req.body.login_token); + if (!user) { + return res.status(401).json({ error: "Login-Challenge abgelaufen" }); + } + + const options = await beginAuthentication(user); + res.json(options); + } catch (err) { + next(err); + } + } +); + +router.post( + "/login/webauthn/verify", + validate(webauthnFinishSchema.extend({ login_token: loginSecondFactorSchema.shape.login_token })), + async (req, res, next) => { + try { + const user = await userByLoginToken(req.body.login_token); + if (!user) { + return res.status(401).json({ error: "Login-Challenge abgelaufen" }); + } + + await finishAuthentication(user, req.body.credential); + + const result = await finalizeSecondFactor({ + loginToken: req.body.login_token, + ipAddress: req.ip, + }); + + setRefreshCookie(res, result.refreshToken); + + res.json({ + accessToken: result.accessToken, + user: result.user, + }); + } catch (err) { + next(err); + } + } +); + +router.post("/refresh", async (req, res, next) => { + try { + const token = req.cookies?.refresh_token; + + if (!token) { + return res.status(401).json({ error: "Kein Refresh-Token" }); + } + + const result = await refresh(token, req.ip); + + setRefreshCookie(res, result.refreshToken); + + res.json({ + accessToken: result.accessToken, + user: result.user, + }); + } catch (err) { + clearRefreshCookie(res); + next(err); + } +}); + +router.post( + "/register", + authenticate, + requirePermission("system.users", ["admin"]), + validate(registerSchema), + async (req, res, next) => { + try { + const user = await register(req.body, req.user.sub, req.ip); + res.status(201).json(user); + } catch (err) { + next(err); + } + } +); + +router.post("/mfa/totp/setup", authenticate, async (req, res, next) => { + try { + const setup = await startTotpEnrollment({ + id: req.user.sub, + email: req.user.email || "user@example.local", + }); + res.json(setup); + } catch (err) { + next(err); + } +}); + +router.post("/mfa/totp/verify", authenticate, validate(totpVerifySchema), async (req, res, next) => { + try { + const result = await verifyTotpEnrollment( + { id: req.user.sub, email: req.user.email || "user@example.local" }, + req.body.token + ); + + await writeAudit({ + actorId: req.user.sub, + action: "MFA_TOTP_ENROLLED", + entityType: "user", + entityId: req.user.sub, + ipAddress: req.ip, + }); + + res.json(result); + } catch (err) { + next(err); + } +}); + +router.post("/mfa/webauthn/register/options", authenticate, async (req, res, next) => { + try { + const user = { + id: req.user.sub, + email: req.user.email || `user-${req.user.sub}@local`, + full_name: req.user.email || `User ${req.user.sub}`, + }; + + const options = await beginRegistration(user); + res.json(options); + } catch (err) { + next(err); + } +}); + +router.post( + "/mfa/webauthn/register/verify", + authenticate, + validate(webauthnFinishSchema), + async (req, res, next) => { + try { + const user = { id: req.user.sub }; + const result = await finishRegistration(user, req.body.credential); + + await writeAudit({ + actorId: req.user.sub, + action: "WEBAUTHN_REGISTERED", + entityType: "user", + entityId: req.user.sub, + ipAddress: req.ip, + }); + + res.json(result); + } catch (err) { + next(err); + } + } +); + +router.post("/logout", async (req, res) => { + clearRefreshCookie(res); + res.json({ ok: true }); +}); + +export default router; diff --git a/backend/src/modules/auth/schemas.js b/backend/src/modules/auth/schemas.js new file mode 100644 index 0000000..082b7db --- /dev/null +++ b/backend/src/modules/auth/schemas.js @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +export const registerSchema = z.object({ + full_name: z.string().min(2).max(120), + email: z.string().email(), + password: z.string().min(12), + role: z.enum(["admin", "chair", "member", "secretary", "advisor"]), + committee_id: z.number().int().nullable().optional(), +}); + +export const loginSecondFactorSchema = z.object({ + login_token: z.string().min(10), + totp_code: z.string().regex(/^\d{6}$/).optional(), +}); + +export const totpVerifySchema = z.object({ + token: z.string().regex(/^\d{6}$/), +}); + +export const webauthnFinishSchema = z.object({ + credential: z.any(), +}); diff --git a/backend/src/modules/auth/service.js b/backend/src/modules/auth/service.js new file mode 100644 index 0000000..84305f4 --- /dev/null +++ b/backend/src/modules/auth/service.js @@ -0,0 +1,165 @@ +import argon2 from "argon2"; +import crypto from "crypto"; +import { query } from "../../config/db.js"; +import { signAccessToken, signRefreshToken, verifyRefreshToken } from "../../utils/jwt.js"; +import { writeAudit } from "../../utils/audit.js"; +import { env } from "../../config/env.js"; +import { verifyTotpCode } from "../../services/totp.js"; +import { getUserAccess } from "../../services/accessControl.js"; + +function makeLoginToken() { + return crypto.randomBytes(24).toString("hex"); +} + +function needsSecondFactor(user, webauthnCount) { + const privileged = ["admin", "chair", "secretary"].includes(user.role); + return Boolean(user.two_factor_enabled || webauthnCount > 0 || (env.forceMfaForPrivileged && privileged)); +} + +export async function login({ email, password, ipAddress }) { + const result = await query( + `SELECT id, full_name, email, password_hash, role, committee_id, is_active, two_factor_enabled + FROM app_user + WHERE email = $1`, + [email] + ); + const user = result.rows[0]; + if (!user || !user.is_active) throw Object.assign(new Error("Login fehlgeschlagen"), { status: 401, publicMessage: "Login fehlgeschlagen" }); + + const ok = await argon2.verify(user.password_hash, password); + if (!ok) { + await writeAudit({ actorId: user.id, action: "LOGIN_FAILED", entityType: "user", entityId: user.id, ipAddress }); + throw Object.assign(new Error("Login fehlgeschlagen"), { status: 401, publicMessage: "Login fehlgeschlagen" }); + } + + const credCount = await query(`SELECT COUNT(*)::int AS count FROM webauthn_credential WHERE user_id = $1 AND revoked_at IS NULL`, [user.id]); + console.log("MFA_DEBUG", { + email: user.email, + role: user.role, + two_factor_enabled: user.two_factor_enabled, + webauthnCount: credCount.rows[0].count, + forceMfaForPrivileged: env.forceMfaForPrivileged, + privileged: ["admin", "chair", "secretary"].includes(user.role), +}); + if (needsSecondFactor(user, credCount.rows[0].count)) { + const loginToken = makeLoginToken(); + await query( + `INSERT INTO auth_challenge(user_id, challenge_type, challenge_value, expires_at, metadata) + VALUES ($1, 'login', $2, NOW() + INTERVAL '10 minutes', $3)`, + [user.id, loginToken, JSON.stringify({ ipAddress })] + ); + await writeAudit({ actorId: user.id, action: "LOGIN_PASSWORD_OK_MFA_PENDING", entityType: "user", entityId: user.id, ipAddress }); + return { + mfaRequired: true, + loginToken, + userHint: { + id: user.id, + full_name: user.full_name, + email: user.email, + role: user.role, + committee_id: user.committee_id, + }, + }; + } + + const accessToken = signAccessToken({ ...user, mfa_verified: true, amr: ["pwd"] }); + const refreshToken = signRefreshToken({ ...user, mfa_verified: true }); + await storeRefresh(user.id, refreshToken); + await writeAudit({ actorId: user.id, action: "LOGIN_SUCCESS", entityType: "user", entityId: user.id, ipAddress }); + const access = await getUserAccess(user.id, user.role); + return { + accessToken, + refreshToken, + user: { id: user.id, full_name: user.full_name, email: user.email, role: user.role, committee_id: user.committee_id, mfa_verified: true, ...access } + }; +} + +async function storeRefresh(userId, refreshToken) { + await query( + `INSERT INTO refresh_token(user_id, token_hash, expires_at) + VALUES ($1, crypt($2, gen_salt('bf')), NOW() + INTERVAL '7 days')`, + [userId, refreshToken] + ); +} + +export async function finalizeSecondFactor({ loginToken, totpCode, ipAddress }) { + const pending = await query( + `SELECT ac.id, ac.user_id, u.full_name, u.email, u.role, u.committee_id, u.is_active + FROM auth_challenge ac + JOIN app_user u ON u.id = ac.user_id + WHERE ac.challenge_type = 'login' AND ac.challenge_value = $1 AND ac.used_at IS NULL AND ac.expires_at > NOW() + ORDER BY ac.created_at DESC LIMIT 1`, + [loginToken] + ); + const row = pending.rows[0]; + if (!row || !row.is_active) throw Object.assign(new Error("MFA-Login ungültig"), { status: 401, publicMessage: "MFA-Login ungültig" }); + + if (totpCode) { + const ok = await verifyTotpCode(row, totpCode); + if (!ok) { + await writeAudit({ actorId: row.user_id, action: "MFA_TOTP_FAILED", entityType: "user", entityId: row.user_id, ipAddress }); + throw Object.assign(new Error("Ungültiger TOTP-Code"), { status: 401, publicMessage: "Ungültiger TOTP-Code" }); + } + } + + await query(`UPDATE auth_challenge SET used_at = NOW() WHERE id = $1`, [row.id]); + + const accessToken = signAccessToken({ + id: row.user_id, + role: row.role, + committee_id: row.committee_id, + mfa_verified: true, + amr: totpCode ? ["pwd", "otp"] : ["pwd", "hwk"], + }); + const refreshToken = signRefreshToken({ id: row.user_id, mfa_verified: true }); + await storeRefresh(row.user_id, refreshToken); + await writeAudit({ actorId: row.user_id, action: "LOGIN_SUCCESS_MFA", entityType: "user", entityId: row.user_id, ipAddress }); + const access = await getUserAccess(row.user_id, row.role); + + return { + accessToken, + refreshToken, + user: { id: row.user_id, full_name: row.full_name, email: row.email, role: row.role, committee_id: row.committee_id, mfa_verified: true, ...access } + }; +} + +export async function register(payload, actorId, ipAddress) { + const hash = await argon2.hash(payload.password); + const result = await query( + `INSERT INTO app_user(full_name, email, password_hash, role, committee_id, is_active) + VALUES ($1,$2,$3,$4,$5,TRUE) + RETURNING id, full_name, email, role, committee_id, is_active`, + [payload.full_name, payload.email, hash, payload.role, payload.committee_id ?? null] + ); + const user = result.rows[0]; + await writeAudit({ actorId, action: "USER_CREATED", entityType: "user", entityId: user.id, metadata: { role: user.role }, ipAddress }); + return user; +} + +export async function refresh(oldToken, ipAddress) { + const payload = verifyRefreshToken(oldToken); + const userResult = await query( + `SELECT id, full_name, email, role, committee_id, is_active, true AS mfa_verified FROM app_user WHERE id = $1`, + [payload.sub] + ); + const user = userResult.rows[0]; + if (!user || !user.is_active) throw Object.assign(new Error("Refresh fehlgeschlagen"), { status: 401, publicMessage: "Refresh fehlgeschlagen" }); + + const accessToken = signAccessToken(user); + const refreshToken = signRefreshToken(user); + const access = await getUserAccess(user.id, user.role); + await writeAudit({ actorId: user.id, action: "TOKEN_REFRESH", entityType: "user", entityId: user.id, ipAddress }); + return { accessToken, refreshToken, user: { ...user, ...access } }; +} + +export async function userByLoginToken(loginToken) { + const result = await query( + `SELECT u.id, u.full_name, u.email, u.role, u.committee_id + FROM auth_challenge ac + JOIN app_user u ON u.id = ac.user_id + WHERE ac.challenge_type = 'login' AND ac.challenge_value = $1 AND ac.used_at IS NULL AND ac.expires_at > NOW() + ORDER BY ac.created_at DESC LIMIT 1`, + [loginToken] + ); + return result.rows[0] || null; +} diff --git a/backend/src/modules/calendar/ics.js b/backend/src/modules/calendar/ics.js new file mode 100644 index 0000000..ae7afa5 --- /dev/null +++ b/backend/src/modules/calendar/ics.js @@ -0,0 +1,244 @@ +import crypto from "crypto"; + +function pad(value) { + return String(value).padStart(2, "0"); +} + +function escapeText(value = "") { + return String(value) + .replace(/\\/g, "\\\\") + .replace(/\n/g, "\\n") + .replace(/,/g, "\\,") + .replace(/;/g, "\\;"); +} + +function unescapeText(value = "") { + return String(value) + .replace(/\\n/g, "\n") + .replace(/\\,/g, ",") + .replace(/\\;/g, ";") + .replace(/\\\\/g, "\\"); +} + +function foldLine(line) { + if (line.length <= 73) return line; + const parts = []; + for (let i = 0; i < line.length; i += 73) { + parts.push(i === 0 ? line.slice(i, i + 73) : ` ${line.slice(i, i + 73)}`); + } + return parts.join("\r\n"); +} + +function formatDateUtc(date) { + const d = new Date(date); + return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`; +} + +function formatDateTimeUtc(date) { + const d = new Date(date); + return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`; +} + +export function buildIcsCalendar(events, { name = "BR-Office Kalender" } = {}) { + const lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//BR-Office//Kalender//DE", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + foldLine(`X-WR-CALNAME:${escapeText(name)}`), + ]; + + for (const event of events) { + const uid = event.ical_uid || `${event.event_type || "calendar"}-${event.id || crypto.randomUUID()}@br-office.local`; + const createdAt = event.created_at || event.updated_at || event.starts_at || new Date().toISOString(); + const updatedAt = event.updated_at || event.created_at || event.starts_at || new Date().toISOString(); + const descriptionParts = []; + + if (event.description) descriptionParts.push(event.description); + if (event.reference) descriptionParts.push(`Referenz: ${event.reference}`); + if (event.committee_name) descriptionParts.push(`Ausschuss: ${event.committee_name}`); + if (event.primary_user_name) descriptionParts.push(`Verantwortlich: ${event.primary_user_name}`); + + lines.push("BEGIN:VEVENT"); + lines.push(`UID:${escapeText(uid)}`); + lines.push(`DTSTAMP:${formatDateTimeUtc(updatedAt)}`); + + if (event.all_day) { + lines.push(`DTSTART;VALUE=DATE:${formatDateUtc(event.starts_at)}`); + const endSource = event.ends_at || event.starts_at; + const endDate = new Date(endSource); + endDate.setUTCDate(endDate.getUTCDate() + 1); + lines.push(`DTEND;VALUE=DATE:${formatDateUtc(endDate)}`); + } else { + lines.push(`DTSTART:${formatDateTimeUtc(event.starts_at)}`); + if (event.ends_at) lines.push(`DTEND:${formatDateTimeUtc(event.ends_at)}`); + } + + lines.push(foldLine(`SUMMARY:${escapeText(event.title || "Termin")}`)); + if (event.location) lines.push(foldLine(`LOCATION:${escapeText(event.location)}`)); + if (descriptionParts.length) lines.push(foldLine(`DESCRIPTION:${escapeText(descriptionParts.join("\n"))}`)); + if (event.status) lines.push(`STATUS:${String(event.status).toUpperCase()}`); + if (event.priority) lines.push(foldLine(`CATEGORIES:${escapeText(String(event.priority))}`)); + lines.push("END:VEVENT"); + } + + lines.push("END:VCALENDAR"); + return `${lines.join("\r\n")}\r\n`; +} + +function parseLine(line) { + const colonIndex = line.indexOf(":"); + if (colonIndex === -1) return null; + const left = line.slice(0, colonIndex); + const value = line.slice(colonIndex + 1); + const [rawName, ...paramParts] = left.split(";"); + const params = {}; + + for (const part of paramParts) { + const [key, rawVal = ""] = part.split("="); + params[key.toUpperCase()] = rawVal; + } + + return { + name: rawName.toUpperCase(), + params, + value, + }; +} + +function parseDateValue(value, params = {}) { + if (!value) return { iso: null, allDay: false }; + + if (params.VALUE === "DATE" || /^\d{8}$/.test(value)) { + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(4, 6)) - 1; + const day = Number(value.slice(6, 8)); + const d = new Date(Date.UTC(year, month, day, 0, 0, 0)); + return { iso: d.toISOString(), allDay: true }; + } + + const match = value.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z)?$/); + if (!match) return { iso: null, allDay: false }; + + const [, y, mo, d, h, mi, s = "00", z] = match; + if (z === "Z") { + return { + iso: new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s))).toISOString(), + allDay: false, + }; + } + + return { + iso: `${y}-${mo}-${d}T${h}:${mi}:${s}`, + allDay: false, + }; +} + +export function parseIcsCalendar(icsText) { + const unfolded = String(icsText || "") + .replace(/\r\n[ \t]/g, "") + .replace(/\n[ \t]/g, ""); + + const lines = unfolded.split(/\r?\n/).filter(Boolean); + const events = []; + let calendarName = ""; + let current = null; + + for (const rawLine of lines) { + if (rawLine === "BEGIN:VEVENT") { + current = {}; + continue; + } + if (rawLine === "END:VEVENT") { + if (current) events.push(current); + current = null; + continue; + } + + const parsed = parseLine(rawLine); + if (!parsed) continue; + + if (!current && parsed.name === "X-WR-CALNAME") { + calendarName = unescapeText(parsed.value); + continue; + } + + if (!current) continue; + + switch (parsed.name) { + case "UID": + current.uid = unescapeText(parsed.value); + break; + case "SUMMARY": + current.title = unescapeText(parsed.value); + break; + case "DESCRIPTION": + current.description = unescapeText(parsed.value); + break; + case "LOCATION": + current.location = unescapeText(parsed.value); + break; + case "STATUS": + current.status = unescapeText(parsed.value).toLowerCase(); + break; + case "PRIORITY": + current.priority = unescapeText(parsed.value); + break; + case "DTSTART": { + const parsedDate = parseDateValue(parsed.value, parsed.params); + current.starts_at = parsedDate.iso; + if (parsedDate.allDay) current.all_day = true; + break; + } + case "DTEND": { + const parsedDate = parseDateValue(parsed.value, parsed.params); + current.ends_at = parsedDate.iso; + if (parsedDate.allDay) current.all_day = true; + break; + } + default: + break; + } + } + + return { calendarName, events }; +} + +function normalizePriority(priority) { + const raw = String(priority ?? "").trim().toLowerCase(); + if (!raw) return "mittel"; + if (["1", "2", "3", "hoch", "high", "sehr_hoch", "urgent"].includes(raw)) return "hoch"; + if (["7", "8", "9", "niedrig", "low"].includes(raw)) return "niedrig"; + return "mittel"; +} + +function normalizeStatus(status) { + const raw = String(status ?? "").trim().toLowerCase(); + if (!raw) return "geplant"; + if (["cancelled", "abgesagt", "canceled"].includes(raw)) return "abgesagt"; + if (["tentative", "vorbehaltlich"].includes(raw)) return "vorbehaltlich"; + if (["completed", "done", "erledigt"].includes(raw)) return "erledigt"; + return "geplant"; +} + +export function normalizeImportedEvent(rawEvent, defaults = {}) { + const uidBase = rawEvent.uid || `${rawEvent.title || "Termin"}|${rawEvent.starts_at || ""}|${rawEvent.location || ""}`; + return { + source_uid: rawEvent.uid || crypto.createHash("sha1").update(uidBase).digest("hex"), + title: rawEvent.title || "Importierter Termin", + description: rawEvent.description || null, + starts_at: rawEvent.starts_at, + ends_at: rawEvent.ends_at || null, + all_day: Boolean(rawEvent.all_day), + location: rawEvent.location || null, + status: normalizeStatus(rawEvent.status), + priority: normalizePriority(rawEvent.priority), + visibility: defaults.visibility || "intern", + committee_id: defaults.committee_id ?? null, + owner_user_id: defaults.owner_user_id ?? null, + source_name: defaults.source_name || null, + event_kind: defaults.event_kind || "imported", + source_type: defaults.source_type || "ical", + }; +} diff --git a/backend/src/modules/calendar/routes.js b/backend/src/modules/calendar/routes.js new file mode 100644 index 0000000..002b220 --- /dev/null +++ b/backend/src/modules/calendar/routes.js @@ -0,0 +1,759 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission, requireRoles } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { writeAudit } from "../../utils/audit.js"; +import { upload } from "../../middleware/upload.js"; +import { buildIcsCalendar, normalizeImportedEvent, parseIcsCalendar } from "./ics.js"; + +const router = express.Router(); +router.use(authenticate); + +const priorityEnum = z.enum(["sehr_hoch", "hoch", "mittel", "niedrig"]); +const visibilityEnum = z.enum(["intern", "vertraulich", "streng_vertraulich"]); + +const eventSchema = z.object({ + title: z.string().min(2).max(255), + description: z.string().max(8000).nullable().optional(), + starts_at: z.string().min(10), + ends_at: z.string().min(10).nullable().optional(), + all_day: z.boolean().optional().default(false), + location: z.string().max(255).nullable().optional(), + status: z.string().min(2).max(30).optional().default("geplant"), + priority: priorityEnum.optional().default("mittel"), + visibility: visibilityEnum.optional().default("intern"), + owner_user_id: z.number().int().positive().nullable().optional(), + committee_id: z.number().int().positive().nullable().optional(), + source_name: z.string().max(255).nullable().optional(), +}); + +const updateEventSchema = eventSchema.partial(); + +function normalizeRange(from, to) { + const start = from ? new Date(from) : new Date(); + const startIso = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate(), 0, 0, 0)).toISOString(); + const end = to ? new Date(to) : new Date(new Date(startIso).getTime() + 1000 * 60 * 60 * 24 * 31); + const endIso = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate(), 23, 59, 59)).toISOString(); + return { startIso, endIso }; +} + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function normalizeNullableNumber(value) { + if (value === undefined) return undefined; + if (value === null || value === "") return null; + return Number(value); +} + +async function assertReference(db, table, id, publicMessage) { + if (id === undefined || id === null) return; + const result = await db.query(`SELECT id FROM ${table} WHERE id = $1 LIMIT 1`, [id]); + if (!result.rows[0]) { + throw Object.assign(new Error(publicMessage), { status: 400, publicMessage }); + } +} + +async function assertEventReferences(db, payload) { + await assertReference(db, "committee", payload.committee_id, "Ausschuss nicht gefunden"); + if (payload.owner_user_id !== undefined && payload.owner_user_id !== null) { + const result = await db.query(`SELECT id FROM app_user WHERE id = $1 AND is_active = TRUE`, [payload.owner_user_id]); + if (!result.rows[0]) { + throw Object.assign(new Error("Benutzer nicht gefunden"), { status: 400, publicMessage: "Benutzer nicht gefunden" }); + } + } +} + +function mapEventRow(row) { + return { + id: row.id, + event_type: row.event_type, + title: row.title, + reference: row.reference, + status: row.status, + event_date: row.event_date, + starts_at: row.starts_at, + ends_at: row.ends_at, + location: row.location, + committee_id: row.committee_id, + committee_name: row.committee_name, + owner_user_id: row.owner_user_id, + owner_name: row.owner_name, + primary_user_id: row.primary_user_id, + primary_user_name: row.primary_user_name, + secondary_user_id: row.secondary_user_id, + secondary_user_name: row.secondary_user_name, + priority: row.priority, + linked_case_number: row.linked_case_number, + description: row.description, + all_day: Boolean(row.all_day), + visibility: row.visibility, + source_type: row.source_type, + source_name: row.source_name, + editable: Boolean(row.editable), + created_at: row.created_at, + updated_at: row.updated_at, + ical_uid: row.ical_uid, + }; +} + +async function listEvents(db, { from, to, committeeId, userId }) { + const { startIso, endIso } = normalizeRange(from, to); + const params = [startIso, endIso]; + const push = (value) => { + params.push(value); + return `$${params.length}`; + }; + + let meetingWhere = `m.meeting_date::timestamptz BETWEEN $1 AND $2`; + let deadlineWhere = `d.due_date BETWEEN $1 AND $2`; + let taskWhere = `t.due_date IS NOT NULL AND t.due_date BETWEEN $1 AND $2`; + let calendarWhere = `e.deleted_at IS NULL AND e.starts_at <= $2 AND COALESCE(e.ends_at, e.starts_at) >= $1`; + + if (committeeId) { + const p = push(committeeId); + meetingWhere += ` AND m.committee_id = ${p}`; + deadlineWhere += ` AND c.committee_id = ${p}`; + taskWhere += ` AND COALESCE(tm.committee_id, tc.committee_id) = ${p}`; + calendarWhere += ` AND e.committee_id = ${p}`; + } + + if (userId) { + const p = push(userId); + meetingWhere += ` AND (m.chair_user_id = ${p} OR m.secretary_user_id = ${p})`; + deadlineWhere += ` AND c.owner_user_id = ${p}`; + taskWhere += ` AND (t.assigned_user_id = ${p} OR t.backup_user_id = ${p})`; + calendarWhere += ` AND e.owner_user_id = ${p}`; + } + + const [meetings, deadlines, tasks, calendarItems] = await Promise.all([ + db.query( + `SELECT m.id, + 'meeting' AS event_type, + m.title, + m.meeting_number AS reference, + m.status, + m.meeting_date::text AS event_date, + (m.meeting_date::text || 'T' || COALESCE(m.start_time::text, '00:00:00'))::timestamptz AS starts_at, + CASE WHEN m.end_time IS NOT NULL THEN (m.meeting_date::text || 'T' || m.end_time::text)::timestamptz ELSE NULL END AS ends_at, + m.location, + m.committee_id, + c.name AS committee_name, + NULL::bigint AS owner_user_id, + NULL::text AS owner_name, + m.chair_user_id AS primary_user_id, + cu.full_name AS primary_user_name, + m.secretary_user_id AS secondary_user_id, + su.full_name AS secondary_user_name, + NULL::text AS priority, + NULL::text AS linked_case_number, + m.protocol_text AS description, + FALSE AS all_day, + 'intern'::text AS visibility, + 'meeting'::text AS source_type, + NULL::text AS source_name, + FALSE AS editable, + m.created_at, + m.updated_at, + ('meeting-' || m.id || '@br-office.local')::text AS ical_uid + FROM meeting m + LEFT JOIN committee c ON c.id = m.committee_id + LEFT JOIN app_user cu ON cu.id = m.chair_user_id + LEFT JOIN app_user su ON su.id = m.secretary_user_id + WHERE ${meetingWhere}`, + params + ), + db.query( + `SELECT d.id, + 'deadline' AS event_type, + d.title, + d.deadline_type AS reference, + d.status, + d.due_date::date::text AS event_date, + d.due_date AS starts_at, + NULL::timestamptz AS ends_at, + NULL::text AS location, + c.committee_id, + cm.name AS committee_name, + c.owner_user_id AS owner_user_id, + ou.full_name AS owner_name, + c.owner_user_id AS primary_user_id, + ou.full_name AS primary_user_name, + NULL::bigint AS secondary_user_id, + NULL::text AS secondary_user_name, + NULL::text AS priority, + c.case_number AS linked_case_number, + NULL::text AS description, + FALSE AS all_day, + 'intern'::text AS visibility, + 'deadline'::text AS source_type, + NULL::text AS source_name, + FALSE AS editable, + d.created_at, + d.updated_at, + ('deadline-' || d.id || '@br-office.local')::text AS ical_uid + FROM deadline d + LEFT JOIN br_case c ON c.id = d.case_id + LEFT JOIN committee cm ON cm.id = c.committee_id + LEFT JOIN app_user ou ON ou.id = c.owner_user_id + WHERE d.deleted_at IS NULL + AND ${deadlineWhere}`, + params + ), + db.query( + `SELECT t.id, + 'task' AS event_type, + t.title, + COALESCE(dc.decision_number, mc.meeting_number, bc.case_number, 'Aufgabe') AS reference, + t.status, + t.due_date::date::text AS event_date, + t.due_date AS starts_at, + NULL::timestamptz AS ends_at, + NULL::text AS location, + COALESCE(tm.committee_id, tc.committee_id) AS committee_id, + cm.name AS committee_name, + t.assigned_user_id AS owner_user_id, + au.full_name AS owner_name, + t.assigned_user_id AS primary_user_id, + au.full_name AS primary_user_name, + t.backup_user_id AS secondary_user_id, + bu.full_name AS secondary_user_name, + t.priority, + bc.case_number AS linked_case_number, + t.description, + FALSE AS all_day, + t.confidentiality_level::text AS visibility, + 'task'::text AS source_type, + NULL::text AS source_name, + FALSE AS editable, + t.created_at, + t.updated_at, + ('task-' || t.id || '@br-office.local')::text AS ical_uid + FROM task t + LEFT JOIN meeting tm ON tm.id = t.meeting_id + LEFT JOIN br_case tc ON tc.id = t.case_id + LEFT JOIN decision dc ON dc.id = t.decision_id + LEFT JOIN meeting mc ON mc.id = t.meeting_id + LEFT JOIN br_case bc ON bc.id = t.case_id + LEFT JOIN committee cm ON cm.id = COALESCE(tm.committee_id, tc.committee_id) + LEFT JOIN app_user au ON au.id = t.assigned_user_id + LEFT JOIN app_user bu ON bu.id = t.backup_user_id + WHERE t.deleted_at IS NULL + AND ${taskWhere}`, + params + ), + db.query( + `SELECT e.id, + 'calendar' AS event_type, + e.title, + COALESCE(e.source_name, CASE WHEN e.source_type = 'manual' THEN 'Eigener Termin' ELSE INITCAP(e.source_type) END) AS reference, + e.status, + e.starts_at::date::text AS event_date, + e.starts_at, + e.ends_at, + e.location, + e.committee_id, + c.name AS committee_name, + e.owner_user_id, + u.full_name AS owner_name, + e.owner_user_id AS primary_user_id, + u.full_name AS primary_user_name, + NULL::bigint AS secondary_user_id, + NULL::text AS secondary_user_name, + e.priority, + NULL::text AS linked_case_number, + e.description, + e.all_day, + e.visibility::text AS visibility, + e.source_type, + e.source_name, + TRUE AS editable, + e.created_at, + e.updated_at, + COALESCE(e.source_uid, ('calendar-' || e.id || '@br-office.local'))::text AS ical_uid + FROM calendar_event e + LEFT JOIN committee c ON c.id = e.committee_id + LEFT JOIN app_user u ON u.id = e.owner_user_id + WHERE ${calendarWhere}`, + params + ), + ]); + + return [...meetings.rows, ...deadlines.rows, ...tasks.rows, ...calendarItems.rows] + .map(mapEventRow) + .sort((a, b) => new Date(a.starts_at).getTime() - new Date(b.starts_at).getTime()); +} + +async function fetchTrustedProviderIcs(url, provider) { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + throw Object.assign(new Error("Nur HTTPS-URLs sind erlaubt"), { status: 400, publicMessage: "Nur HTTPS-URLs sind erlaubt" }); + } + + const hostname = parsed.hostname.toLowerCase(); + const allowed = provider === "google" + ? /(google|gmail|calendar)/i.test(hostname) + : /(outlook|office|live|microsoft)/i.test(hostname); + + if (!allowed) { + throw Object.assign(new Error("URL passt nicht zum Anbieter"), { status: 400, publicMessage: "URL passt nicht zum Anbieter" }); + } + + const response = await fetch(url, { method: "GET" }); + if (!response.ok) { + throw Object.assign(new Error(`Abruf fehlgeschlagen: ${response.status}`), { status: 400, publicMessage: `Abruf fehlgeschlagen: ${response.status}` }); + } + + return response.text(); +} + +async function importParsedCalendar(db, parsed, options) { + const stats = { imported: 0, updated: 0, skipped: 0, calendarName: parsed.calendarName || options.source_name || null }; + + for (const rawEvent of parsed.events) { + const event = normalizeImportedEvent(rawEvent, { + ...options, + source_name: options.source_name || parsed.calendarName || options.source_type?.toUpperCase(), + }); + + if (!event.title || !event.starts_at) { + stats.skipped += 1; + continue; + } + + const existing = await db.query( + `SELECT id + FROM calendar_event + WHERE source_type = $1 + AND source_uid = $2 + AND owner_user_id IS NOT DISTINCT FROM $3 + AND committee_id IS NOT DISTINCT FROM $4 + AND deleted_at IS NULL + LIMIT 1`, + [event.source_type, event.source_uid, event.owner_user_id, event.committee_id] + ); + + if (existing.rows[0]) { + await db.query( + `UPDATE calendar_event + SET title = $1, + description = $2, + starts_at = $3, + ends_at = $4, + all_day = $5, + location = $6, + status = $7, + priority = $8, + visibility = $9, + source_name = $10, + updated_by = $11, + updated_at = NOW() + WHERE id = $12`, + [ + event.title, + event.description, + event.starts_at, + event.ends_at, + event.all_day, + event.location, + event.status, + event.priority, + event.visibility, + event.source_name, + options.actorId, + existing.rows[0].id, + ] + ); + stats.updated += 1; + } else { + await db.query( + `INSERT INTO calendar_event( + title, + description, + event_kind, + source_type, + source_uid, + source_name, + starts_at, + ends_at, + all_day, + location, + status, + priority, + visibility, + owner_user_id, + committee_id, + created_by, + updated_by + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$16 + )`, + [ + event.title, + event.description, + event.event_kind, + event.source_type, + event.source_uid, + event.source_name, + event.starts_at, + event.ends_at, + event.all_day, + event.location, + event.status, + event.priority, + event.visibility, + event.owner_user_id, + event.committee_id, + options.actorId, + ] + ); + stats.imported += 1; + } + } + + return stats; +} + +router.get("/meta", async (req, res, next) => { + try { + const data = await withDbSession(req.user, async (db) => { + const [users, committees] = await Promise.all([ + db.query(`SELECT id, full_name, email, committee_id FROM app_user WHERE is_active = TRUE ORDER BY full_name`), + db.query(`SELECT id, name FROM committee ORDER BY name`), + ]); + return { + users: users.rows, + committees: committees.rows, + currentUser: { id: req.user.sub, role: req.user.role, committee_id: req.user.committee_id ?? null }, + }; + }); + res.json(data); + } catch (err) { + next(err); + } +}); + +router.get("/events", async (req, res, next) => { + try { + const data = await withDbSession(req.user, (db) => + listEvents(db, { + from: req.query.from, + to: req.query.to, + committeeId: req.query.committee_id ? Number(req.query.committee_id) : null, + userId: req.query.user_id ? Number(req.query.user_id) : null, + }) + ); + res.json(data); + } catch (err) { + next(err); + } +}); + +router.post( + "/events", + requireMfa, + requirePermission("calendar.write", ["admin", "chair", "secretary", "member", "advisor"]), + validate(eventSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + description: normalizeNullableText(req.body.description), + location: normalizeNullableText(req.body.location), + source_name: normalizeNullableText(req.body.source_name), + owner_user_id: normalizeNullableNumber(req.body.owner_user_id) ?? req.user.sub, + committee_id: normalizeNullableNumber(req.body.committee_id), + }; + + const row = await withDbSession(req.user, async (db) => { + await assertEventReferences(db, payload); + const result = await db.query( + `INSERT INTO calendar_event( + title, + description, + event_kind, + source_type, + source_name, + starts_at, + ends_at, + all_day, + location, + status, + priority, + visibility, + owner_user_id, + committee_id, + created_by, + updated_by + ) VALUES ( + $1,$2,'personal','manual',$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$13 + ) RETURNING id`, + [ + payload.title, + payload.description, + payload.source_name, + payload.starts_at, + payload.ends_at || null, + Boolean(payload.all_day), + payload.location, + payload.status || "geplant", + payload.priority || "mittel", + payload.visibility || "intern", + payload.owner_user_id, + payload.committee_id, + req.user.sub, + ] + ); + const items = await listEvents(db, { from: payload.starts_at, to: payload.ends_at || payload.starts_at }); + return items.find((item) => item.event_type === "calendar" && Number(item.id) === Number(result.rows[0].id)); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "CALENDAR_EVENT_CREATED", + entityType: "calendar_event", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload }, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +router.put( + "/events/:id", + requireMfa, + requirePermission("calendar.write", ["admin", "chair", "secretary", "member", "advisor"]), + validate(updateEventSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + description: normalizeNullableText(req.body.description), + location: normalizeNullableText(req.body.location), + source_name: normalizeNullableText(req.body.source_name), + owner_user_id: normalizeNullableNumber(req.body.owner_user_id), + committee_id: normalizeNullableNumber(req.body.committee_id), + }; + + const row = await withDbSession(req.user, async (db) => { + const existing = await db.query(`SELECT * FROM calendar_event WHERE id = $1 AND deleted_at IS NULL`, [req.params.id]); + if (!existing.rows[0]) return null; + + await assertEventReferences(db, payload); + + const fields = []; + const values = []; + let idx = 1; + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + [ + ["title", payload.title], + ["description", payload.description], + ["starts_at", payload.starts_at], + ["ends_at", payload.ends_at], + ["all_day", payload.all_day], + ["location", payload.location], + ["status", payload.status], + ["priority", payload.priority], + ["visibility", payload.visibility], + ["owner_user_id", payload.owner_user_id], + ["committee_id", payload.committee_id], + ["source_name", payload.source_name], + ].forEach(([column, value]) => { + if (value !== undefined) push(column, value); + }); + + if (!fields.length) { + throw Object.assign(new Error("Keine Felder zum Aktualisieren"), { status: 400, publicMessage: "Keine Felder zum Aktualisieren" }); + } + + push("updated_by", req.user.sub); + fields.push(`updated_at = NOW()`); + values.push(req.params.id); + + await db.query(`UPDATE calendar_event SET ${fields.join(", ")} WHERE id = $${idx}`, values); + const items = await listEvents(db, { + from: payload.starts_at || existing.rows[0].starts_at, + to: payload.ends_at || existing.rows[0].ends_at || payload.starts_at || existing.rows[0].starts_at, + }); + return items.find((item) => item.event_type === "calendar" && Number(item.id) === Number(req.params.id)); + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "CALENDAR_EVENT_UPDATED", + entityType: "calendar_event", + entityId: row.id, + ipAddress: req.ip, + metadata: { changes: payload }, + }); + + res.json(row); + } catch (err) { + next(err); + } + } +); + +router.delete( + "/events/:id", + requireMfa, + requirePermission("calendar.write", ["admin", "chair", "secretary", "member", "advisor"]), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await db.query(`SELECT id, title FROM calendar_event WHERE id = $1 AND deleted_at IS NULL`, [req.params.id]); + if (!existing.rows[0]) return null; + await db.query(`UPDATE calendar_event SET deleted_at = NOW(), updated_by = $2, updated_at = NOW() WHERE id = $1`, [req.params.id, req.user.sub]); + return existing.rows[0]; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "CALENDAR_EVENT_DELETED", + entityType: "calendar_event", + entityId: row.id, + ipAddress: req.ip, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } + } +); + +router.get("/export.ics", async (req, res, next) => { + try { + const events = await withDbSession(req.user, (db) => + listEvents(db, { + from: req.query.from, + to: req.query.to, + committeeId: req.query.committee_id ? Number(req.query.committee_id) : null, + userId: req.query.user_id ? Number(req.query.user_id) : null, + }) + ); + + const ics = buildIcsCalendar(events, { name: "BR-Office Kalender" }); + res.setHeader("Content-Type", "text/calendar; charset=utf-8"); + res.setHeader("Content-Disposition", 'attachment; filename="br-office-calendar.ics"'); + res.send(ics); + } catch (err) { + next(err); + } +}); + +router.post( + "/import/ics", + requireMfa, + requirePermission("calendar.write", ["admin", "chair", "secretary", "member", "advisor"]), + upload.single("file"), + async (req, res, next) => { + try { + const rawText = req.file?.buffer?.toString("utf8") || req.body?.ics || ""; + if (!rawText.trim()) { + return res.status(400).json({ error: "ICS-Datei oder ICS-Text fehlt" }); + } + + const ownerUserId = normalizeNullableNumber(req.body.owner_user_id) ?? req.user.sub; + const committeeId = normalizeNullableNumber(req.body.committee_id); + const sourceName = normalizeNullableText(req.body.source_name); + const visibility = req.body.visibility || "intern"; + + const result = await withDbSession(req.user, async (db) => { + await assertEventReferences(db, { owner_user_id: ownerUserId, committee_id: committeeId }); + const parsed = parseIcsCalendar(rawText); + return importParsedCalendar(db, parsed, { + actorId: req.user.sub, + owner_user_id: ownerUserId, + committee_id: committeeId, + source_name: sourceName, + visibility, + event_kind: "imported", + source_type: "ical", + }); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "CALENDAR_ICS_IMPORTED", + entityType: "calendar_event", + entityId: null, + ipAddress: req.ip, + metadata: result, + }); + + res.status(201).json(result); + } catch (err) { + next(err); + } + } +); + +for (const provider of ["outlook", "google"]) { + router.post( + `/sync/${provider}`, + requireMfa, + requirePermission("calendar.write", ["admin", "chair", "secretary", "member", "advisor"]), + async (req, res, next) => { + try { + if (!req.body?.url) return res.status(400).json({ error: "URL fehlt" }); + + const ownerUserId = normalizeNullableNumber(req.body.owner_user_id) ?? req.user.sub; + const committeeId = normalizeNullableNumber(req.body.committee_id); + const sourceName = normalizeNullableText(req.body.source_name) || provider; + const visibility = req.body.visibility || "intern"; + + const rawText = await fetchTrustedProviderIcs(req.body.url, provider); + + const result = await withDbSession(req.user, async (db) => { + await assertEventReferences(db, { owner_user_id: ownerUserId, committee_id: committeeId }); + const parsed = parseIcsCalendar(rawText); + return importParsedCalendar(db, parsed, { + actorId: req.user.sub, + owner_user_id: ownerUserId, + committee_id: committeeId, + source_name: sourceName, + visibility, + event_kind: "synced", + source_type: provider, + }); + }); + + await writeAudit({ + actorId: req.user.sub, + action: `CALENDAR_${provider.toUpperCase()}_SYNC`, + entityType: "calendar_event", + entityId: null, + ipAddress: req.ip, + metadata: { ...result, sourceName, url: req.body.url }, + }); + + res.status(201).json(result); + } catch (err) { + next(err); + } + } + ); +} + +export default router; diff --git a/backend/src/modules/cases/routes.js b/backend/src/modules/cases/routes.js new file mode 100644 index 0000000..0fb4890 --- /dev/null +++ b/backend/src/modules/cases/routes.js @@ -0,0 +1,998 @@ +import express from "express"; +import crypto from "crypto"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { writeAudit } from "../../utils/audit.js"; +import { upload } from "../../middleware/upload.js"; +import { scanBuffer } from "../../services/clamav.js"; +import { saveObject } from "../../services/storage.js"; +import { fileTypeFromBuffer } from "file-type"; +import { v4 as uuidv4 } from "uuid"; + +const router = express.Router(); +router.use(authenticate); + +const priorityEnum = z.enum(["sehr_hoch", "hoch", "mittel", "niedrig"]); +const confEnum = z.enum(["intern", "vertraulich", "streng_vertraulich"]); + +const createSchema = z.object({ + case_number: z.string().min(3).max(50).optional().nullable(), + title: z.string().min(3).max(255), + case_type_id: z.coerce.number().int().positive(), + status: z.string().min(2).max(50).default("neu"), + priority: priorityEnum.default("mittel"), + confidentiality_level: confEnum.default("intern"), + summary: z.string().max(8000).optional().nullable(), + affected_employee_name: z.string().max(120).optional().nullable(), + department: z.string().max(120).optional().nullable(), + committee_id: z.coerce.number().int().positive().nullable().optional(), + owner_user_id: z.coerce.number().int().positive().nullable().optional(), + legal_hold: z.boolean().optional().default(false), + team_role_ids: z.array(z.coerce.number().int().positive()).optional().default([]), +}); + +const updateSchema = z.object({ + title: z.string().min(3).max(255).optional(), + case_type_id: z.coerce.number().int().positive().optional(), + status: z.string().min(2).max(50).optional(), + priority: priorityEnum.optional(), + confidentiality_level: confEnum.optional(), + summary: z.string().max(8000).optional().nullable(), + affected_employee_name: z.string().max(120).optional().nullable(), + department: z.string().max(120).optional().nullable(), + committee_id: z.coerce.number().int().positive().nullable().optional(), + owner_user_id: z.coerce.number().int().positive().nullable().optional(), + legal_hold: z.boolean().optional(), + team_role_ids: z.array(z.coerce.number().int().positive()).optional(), +}); + +const addToAgendaSchema = z.object({ + meeting_id: z.coerce.number().int().positive(), + agenda_item_id: z.coerce.number().int().positive().optional().nullable(), + duration_minutes: z.coerce.number().int().min(0).max(600).optional().nullable(), +}); + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function normalizeOptionalText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed || null; +} + +function normalizeNullableNumber(value) { + if (value === undefined) return undefined; + if (value === null || value === "") return null; + return Number(value); +} + +function mapCaseRow(row) { + if (!row) return null; + return { + id: row.id, + case_number: row.case_number, + title: row.title, + case_type_id: row.case_type_id, + case_type: row.case_type, + status: row.status, + priority: row.priority, + confidentiality_level: row.confidentiality_level, + summary: row.summary, + affected_employee_name: row.affected_employee_name, + department: row.department, + department_label: row.department_label, + committee_id: row.committee_id, + committee_name: row.committee_name, + owner_user_id: row.owner_user_id, + owner_name: row.owner_name, + legal_hold: row.legal_hold, + attachment_count: row.attachment_count, + team_role_ids: Array.isArray(row.team_role_ids) ? row.team_role_ids.map(Number) : [], + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +function isMeetingOpen(row) { + if (!row) return false; + if (row.deleted_at) return false; + if (row.is_closed === true) return false; + if (row.closed === true) return false; + if (row.closed_at) return false; + + const status = String(row.status ?? "").trim().toLowerCase(); + if (["geschlossen", "closed", "archiviert", "archived", "beendet"].includes(status)) { + return false; + } + + return true; +} + +function mapMeetingMetaRow(row) { + return { + id: row.id, + meeting_number: row.meeting_number, + title: row.title, + meeting_date: row.meeting_date, + committee_id: row.committee_id, + status: row.status ?? null, + is_closed: row.is_closed ?? null, + closed: row.closed ?? null, + closed_at: row.closed_at ?? null, + }; +} + +function buildSafeStorageKey(originalname) { + const safeName = String(originalname || "datei") + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_+/g, "_"); + return `${new Date().toISOString().slice(0, 10)}/${uuidv4()}-${safeName}`; +} + +function generateDocumentNumber() { + return `D-${new Date().getFullYear()}-${Math.floor(Math.random() * 100000) + .toString() + .padStart(5, "0")}`; +} + +function collectUploadedFiles(req) { + const files = []; + + if (Array.isArray(req.files)) { + files.push(...req.files); + } else if (req.files && typeof req.files === "object") { + if (Array.isArray(req.files.files)) files.push(...req.files.files); + if (Array.isArray(req.files.file)) files.push(...req.files.file); + } + + if (req.file) files.push(req.file); + + return files; +} + +async function analyzeUpload(file) { + const mime = await fileTypeFromBuffer(file.buffer); + const detectedMime = mime?.mime || file.mimetype || "application/octet-stream"; + const sha256 = crypto.createHash("sha256").update(file.buffer).digest("hex"); + const scan = await scanBuffer(file.buffer); + + return { + file, + detectedMime, + sha256, + scan, + }; +} + +async function getCaseList(db) { + const result = await db.query( + `SELECT c.id, + c.case_number, + c.title, + c.case_type_id, + COALESCE(ct.label, c.case_type) AS case_type, + c.status, + c.priority, + c.confidentiality_level, + c.summary, + c.affected_employee_name, + c.department, + COALESCE(dc.label, c.department) AS department_label, + c.committee_id, + cm.name AS committee_name, + c.owner_user_id, + u.full_name AS owner_name, + c.legal_hold, + c.created_at, + c.updated_at, + ( + SELECT COUNT(*)::int + FROM document_item d + WHERE d.case_id = c.id + AND d.deleted_at IS NULL + ) AS attachment_count, + COALESCE(( + SELECT ARRAY_AGG(cta.team_role_id ORDER BY cta.team_role_id) + FROM br_case_team_access cta + WHERE cta.case_id = c.id + AND cta.can_read = TRUE + ), ARRAY[]::BIGINT[]) AS team_role_ids + FROM br_case c + LEFT JOIN case_type_catalog ct ON ct.id = c.case_type_id + LEFT JOIN department_catalog dc ON dc.code = c.department + LEFT JOIN committee cm ON cm.id = c.committee_id + LEFT JOIN app_user u ON u.id = c.owner_user_id + WHERE c.deleted_at IS NULL + ORDER BY c.created_at DESC` + ); + + return result.rows.map(mapCaseRow); +} + +async function getCaseById(db, id) { + const result = await db.query( + `SELECT c.id, + c.case_number, + c.title, + c.case_type_id, + COALESCE(ct.label, c.case_type) AS case_type, + c.status, + c.priority, + c.confidentiality_level, + c.summary, + c.affected_employee_name, + c.department, + COALESCE(dc.label, c.department) AS department_label, + c.committee_id, + cm.name AS committee_name, + c.owner_user_id, + u.full_name AS owner_name, + c.legal_hold, + c.created_at, + c.updated_at, + ( + SELECT COUNT(*)::int + FROM document_item d + WHERE d.case_id = c.id + AND d.deleted_at IS NULL + ) AS attachment_count, + COALESCE(( + SELECT ARRAY_AGG(cta.team_role_id ORDER BY cta.team_role_id) + FROM br_case_team_access cta + WHERE cta.case_id = c.id + AND cta.can_read = TRUE + ), ARRAY[]::BIGINT[]) AS team_role_ids + FROM br_case c + LEFT JOIN case_type_catalog ct ON ct.id = c.case_type_id + LEFT JOIN department_catalog dc ON dc.code = c.department + LEFT JOIN committee cm ON cm.id = c.committee_id + LEFT JOIN app_user u ON u.id = c.owner_user_id + WHERE c.id = $1 + AND c.deleted_at IS NULL`, + [id] + ); + + return mapCaseRow(result.rows[0]); +} + +async function getUnassignedAgendaItemForCase(db, caseId) { + const result = await db.query( + `SELECT id, meeting_id, case_id, title, description, duration_minutes + FROM meeting_agenda_item + WHERE case_id = $1 + AND meeting_id IS NULL + ORDER BY created_at ASC, id ASC + LIMIT 1`, + [caseId] + ); + + return result.rows[0] || null; +} + +async function ensureAgendaItemForCase(db, currentCase, durationMinutes = null) { + const existing = await getUnassignedAgendaItemForCase(db, currentCase.id); + if (existing) return existing; + + const created = await db.query( + `INSERT INTO meeting_agenda_item(meeting_id, agenda_number, title, description, duration_minutes, case_id) + VALUES (NULL, NULL, $1, $2, $3, $4) + RETURNING id, meeting_id, case_id, title, description, duration_minutes`, + [ + `${currentCase.case_number} – ${currentCase.title}`, + currentCase.summary || `Automatisch aus Vorgang ${currentCase.case_number} erzeugt`, + durationMinutes ?? null, + currentCase.id, + ] + ); + + return created.rows[0]; +} + +async function getCaseTypeById(db, caseTypeId) { + if (caseTypeId == null) return null; + const ref = await db.query( + `SELECT id, code, label + FROM case_type_catalog + WHERE id = $1 + AND is_active = TRUE`, + [caseTypeId] + ); + return ref.rows[0] || null; +} + +async function syncCaseTeamAccess(db, caseId, teamRoleIds, actorId) { + if (!Array.isArray(teamRoleIds)) return; + await db.query(`DELETE FROM br_case_team_access WHERE case_id = $1`, [caseId]); + if (!teamRoleIds.length) return; + await db.query( + `INSERT INTO br_case_team_access(case_id, team_role_id, can_read, can_write, created_by) + SELECT $1::bigint, unnest($2::bigint[]), TRUE, FALSE, $3::bigint + ON CONFLICT (case_id, team_role_id) DO UPDATE SET can_read = TRUE`, + [caseId, teamRoleIds.map(Number), actorId] + ); +} + +async function assertReferences(db, payload) { + let caseType = null; + + if (payload.case_type_id !== undefined) { + caseType = await getCaseTypeById(db, payload.case_type_id); + if (!caseType) { + throw Object.assign(new Error("Vorgangstyp nicht gefunden"), { + status: 400, + publicMessage: "Vorgangstyp nicht gefunden", + }); + } + } + + if (payload.owner_user_id !== undefined && payload.owner_user_id !== null) { + const ref = await db.query( + `SELECT id + FROM app_user + WHERE id = $1 + AND is_active = TRUE`, + [payload.owner_user_id] + ); + if (!ref.rows[0]) { + throw Object.assign(new Error("Verantwortliche Person nicht gefunden"), { + status: 400, + publicMessage: "Verantwortliche Person nicht gefunden", + }); + } + } + + if (payload.committee_id !== undefined && payload.committee_id !== null) { + const ref = await db.query(`SELECT id FROM committee WHERE id = $1`, [payload.committee_id]); + if (!ref.rows[0]) { + throw Object.assign(new Error("Ausschuss nicht gefunden"), { + status: 400, + publicMessage: "Ausschuss nicht gefunden", + }); + } + } + + if (payload.department !== undefined && payload.department !== null) { + const ref = await db.query( + `SELECT id + FROM department_catalog + WHERE code = $1 + AND is_active = TRUE`, + [payload.department] + ); + if (!ref.rows[0]) { + throw Object.assign(new Error("Abteilung nicht gefunden"), { + status: 400, + publicMessage: "Abteilung nicht gefunden oder nicht aktiv", + }); + } + } + + return { caseType }; +} + +router.get("/meta", async (req, res, next) => { + try { + const data = await withDbSession(req.user, async (db) => { + const [caseTypes, users, committees, meetings, teamRoles, departments] = await Promise.all([ + db.query(`SELECT id, code, label FROM case_type_catalog WHERE is_active = TRUE ORDER BY sort_order, label`), + db.query(`SELECT id, full_name, email, committee_id FROM app_user WHERE is_active = TRUE ORDER BY full_name`), + db.query(`SELECT id, name, committee_type FROM committee ORDER BY name`), + db.query(`SELECT * FROM meeting ORDER BY meeting_date DESC, id DESC LIMIT 500`), + db.query(`SELECT id, code, name, can_view_cases, can_write_cases, can_view_documents, can_write_documents FROM app_team_role WHERE is_active = TRUE ORDER BY name`), + db.query(`SELECT id, code, label FROM department_catalog WHERE is_active = TRUE ORDER BY sort_order, label`), + ]); + + return { + caseTypes: caseTypes.rows, + users: users.rows, + committees: committees.rows, + meetings: meetings.rows.filter(isMeetingOpen).map(mapMeetingMetaRow), + teamRoles: teamRoles.rows, + departments: departments.rows, + }; + }); + + res.json(data); + } catch (err) { + next(err); + } +}); + +router.get("/", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, getCaseList); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/:id", async (req, res, next) => { + try { + const row = await withDbSession(req.user, (db) => getCaseById(db, req.params.id)); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.post( + "/", + requireMfa, + requirePermission("cases.write", ["admin", "chair", "secretary", "member"]), + validate(createSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + case_number: normalizeOptionalText(req.body.case_number), + summary: normalizeNullableText(req.body.summary), + affected_employee_name: normalizeNullableText(req.body.affected_employee_name), + department: normalizeNullableText(req.body.department), + committee_id: normalizeNullableNumber(req.body.committee_id), + owner_user_id: normalizeNullableNumber(req.body.owner_user_id) ?? req.user.sub, + }; + + const row = await withDbSession(req.user, async (db) => { + const { caseType } = await assertReferences(db, payload); + + const insert = await db.query( + `INSERT INTO br_case( + case_number, + title, + case_type, + case_type_id, + status, + priority, + confidentiality_level, + summary, + affected_employee_name, + department, + committee_id, + owner_user_id, + legal_hold, + created_by, + updated_by + ) VALUES ( + COALESCE(NULLIF($1, ''), next_case_number()), + $2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$14 + ) + RETURNING id`, + [ + payload.case_number, + payload.title, + caseType.label, + payload.case_type_id, + payload.status, + payload.priority, + payload.confidentiality_level, + payload.summary, + payload.affected_employee_name, + payload.department, + payload.committee_id, + payload.owner_user_id, + payload.legal_hold ?? false, + req.user.sub, + ] + ); + + await syncCaseTeamAccess(db, insert.rows[0].id, payload.team_role_ids || [], req.user.sub); + + const row = await getCaseById(db, insert.rows[0].id); + + await ensureAgendaItemForCase(db, { + id: row.id, + case_number: row.case_number, + title: row.title, + summary: row.summary, + }); + + return row; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "CASE_CREATED", + entityType: "case", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload }, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +router.put( + "/:id", + requireMfa, + requirePermission("cases.write", ["admin", "chair", "secretary", "member"]), + validate(updateSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + summary: normalizeNullableText(req.body.summary), + affected_employee_name: normalizeNullableText(req.body.affected_employee_name), + department: normalizeNullableText(req.body.department), + committee_id: normalizeNullableNumber(req.body.committee_id), + owner_user_id: normalizeNullableNumber(req.body.owner_user_id), + }; + + const row = await withDbSession(req.user, async (db) => { + const existing = await getCaseById(db, req.params.id); + if (!existing) return null; + + const { caseType } = await assertReferences(db, payload); + + const fields = []; + const values = []; + let idx = 1; + + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + const allowed = [ + ["title", payload.title], + ["case_type_id", payload.case_type_id], + ["status", payload.status], + ["priority", payload.priority], + ["confidentiality_level", payload.confidentiality_level], + ["summary", payload.summary], + ["affected_employee_name", payload.affected_employee_name], + ["department", payload.department], + ["committee_id", payload.committee_id], + ["owner_user_id", payload.owner_user_id], + ["legal_hold", payload.legal_hold], + ]; + + for (const [column, value] of allowed) { + if (value !== undefined) push(column, value); + } + + if (caseType) { + push("case_type", caseType.label); + } + + if (!fields.length && payload.team_role_ids === undefined) { + throw Object.assign(new Error("Keine Felder zum Aktualisieren"), { + status: 400, + publicMessage: "Keine Felder zum Aktualisieren", + }); + } + + if (fields.length) { + push("updated_by", req.user.sub); + + values.push(req.params.id); + await db.query( + `UPDATE br_case + SET ${fields.join(", ")} + WHERE id = $${idx} + RETURNING id`, + values + ); + } + + if (payload.team_role_ids !== undefined) { + await syncCaseTeamAccess(db, req.params.id, payload.team_role_ids || [], req.user.sub); + } + + return getCaseById(db, req.params.id); + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "CASE_UPDATED", + entityType: "case", + entityId: row.id, + ipAddress: req.ip, + metadata: { changes: payload }, + }); + + res.json(row); + } catch (err) { + next(err); + } + } +); + +router.delete("/:id", requireMfa, requirePermission("cases.delete", ["admin", "chair"]), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getCaseById(db, req.params.id); + if (!existing) return null; + await db.query(`UPDATE br_case SET deleted_at = NOW(), updated_by = $2 WHERE id = $1`, [req.params.id, req.user.sub]); + return existing; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "CASE_DELETED", + entityType: "case", + entityId: row.id, + ipAddress: req.ip, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } +}); + +router.post( + "/:id/attachments", + requireMfa, + requirePermission("cases.write", ["admin", "chair", "secretary", "member"]), + upload.fields([ + { name: "files", maxCount: 20 }, + { name: "file", maxCount: 1 }, + ]), + async (req, res, next) => { + try { + const files = collectUploadedFiles(req); + if (!files.length) return res.status(400).json({ error: "Datei fehlt" }); + + const analyzedFiles = await Promise.all(files.map(analyzeUpload)); + const infectedFiles = analyzedFiles + .filter((item) => !item.scan.clean) + .map((item) => ({ + file_name: item.file.originalname, + scan_result: item.scan.raw, + })); + + if (infectedFiles.length > 0) { + return res.status(422).json({ + error: "Mindestens eine Datei wurde durch den Virenscan abgelehnt", + files: infectedFiles, + }); + } + + const result = await withDbSession(req.user, async (db) => { + const caseRow = await getCaseById(db, req.params.id); + if (!caseRow) { + throw Object.assign(new Error("Vorgang nicht gefunden"), { + status: 404, + publicMessage: "Vorgang nicht gefunden", + }); + } + + const insertedRows = []; + + for (const item of analyzedFiles) { + const storageKey = buildSafeStorageKey(item.file.originalname); + const docMeta = await saveObject(storageKey, item.file.buffer, item.detectedMime); + + const insert = await db.query( + `INSERT INTO document_item( + document_number, + title, + document_type, + description, + version_label, + file_name, + storage_path, + storage_provider, + mime_type, + size_bytes, + sha256_hash, + confidentiality_level, + case_id, + meeting_id, + decision_id, + uploaded_by, + scan_status, + scan_engine, + scan_result, + uploaded_at + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NULL,NULL,$14,$15,$16,$17,NOW() + ) RETURNING id, document_number, title, document_type, file_name, case_id, uploaded_at, scan_status`, + [ + req.body.document_number || generateDocumentNumber(), + req.body.title || item.file.originalname, + req.body.document_type || "anlage", + req.body.description || `Anlage zu Vorgang ${caseRow.case_number}`, + req.body.version_label || "v1", + item.file.originalname, + docMeta.storage_path, + docMeta.storage_provider, + item.detectedMime, + item.file.size, + item.sha256, + req.body.confidentiality_level || caseRow.confidentiality_level || "intern", + req.params.id, + req.user.sub, + "clean", + "clamav", + item.scan.raw, + ] + ); + + await db.query( + `INSERT INTO document_scan_event(document_id, scanned_by_user_id, status, engine, result_text, scanned_at) + VALUES ($1, $2, $3, $4, $5, NOW())`, + [insert.rows[0].id, req.user.sub, "clean", "clamav", item.scan.raw] + ); + + insertedRows.push(insert.rows[0]); + } + + return insertedRows; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "CASE_ATTACHMENT_UPLOADED", + entityType: "case", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { + count: result.length, + documentIds: result.map((item) => item.id), + files: analyzedFiles.map((item) => ({ + file_name: item.file.originalname, + sha256: item.sha256, + })), + }, + }); + + res.status(201).json({ + ok: true, + uploaded: result, + count: result.length, + }); + } catch (err) { + next(err); + } + } +); + +router.post( + "/:id/add-to-agenda", + requireMfa, + requirePermission("agenda.write", ["admin", "chair", "secretary"]), + validate(addToAgendaSchema), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const caseRow = await db.query( + `SELECT id, case_number, title, summary, committee_id + FROM br_case + WHERE id = $1 + AND deleted_at IS NULL`, + [req.params.id] + ); + const currentCase = caseRow.rows[0]; + if (!currentCase) return null; + + const meetingRow = await db.query( + `SELECT id, meeting_number, title, committee_id + FROM meeting + WHERE id = $1`, + [req.body.meeting_id] + ); + const meeting = meetingRow.rows[0]; + if (!meeting) { + throw Object.assign(new Error("Sitzung nicht gefunden"), { + status: 400, + publicMessage: "Sitzung nicht gefunden", + }); + } + + if (!isMeetingOpen(meeting)) { + throw Object.assign(new Error("Geschlossene Sitzungen können nicht ausgewählt werden"), { + status: 400, + publicMessage: "Geschlossene Sitzungen können nicht ausgewählt werden", + }); + } + + if (currentCase.committee_id && meeting.committee_id && Number(currentCase.committee_id) !== Number(meeting.committee_id)) { + throw Object.assign(new Error("Vorgang und Sitzung gehören zu unterschiedlichen Ausschüssen"), { + status: 400, + publicMessage: "Vorgang und Sitzung gehören zu unterschiedlichen Ausschüssen", + }); + } + + let agendaItem = null; + + if (req.body.agenda_item_id) { + const agendaItemResult = await db.query( + `SELECT id, meeting_id, case_id, title, description, duration_minutes + FROM meeting_agenda_item + WHERE id = $1`, + [req.body.agenda_item_id] + ); + agendaItem = agendaItemResult.rows[0]; + + if (!agendaItem) { + throw Object.assign(new Error("TOP nicht gefunden"), { + status: 404, + publicMessage: "TOP nicht gefunden", + }); + } + + if (agendaItem.meeting_id) { + throw Object.assign(new Error("TOP ist bereits einer Sitzung zugewiesen"), { + status: 400, + publicMessage: "TOP ist bereits einer Sitzung zugewiesen", + }); + } + + if (agendaItem.case_id && Number(agendaItem.case_id) !== Number(currentCase.id)) { + throw Object.assign(new Error("Der ausgewählte TOP gehört zu einem anderen Vorgang"), { + status: 400, + publicMessage: "Der ausgewählte TOP gehört zu einem anderen Vorgang", + }); + } + + if (!agendaItem.case_id) { + await db.query(`UPDATE meeting_agenda_item SET case_id = $1 WHERE id = $2`, [currentCase.id, agendaItem.id]); + agendaItem.case_id = currentCase.id; + } + } else { + agendaItem = await ensureAgendaItemForCase(db, currentCase, req.body.duration_minutes ?? null); + } + + await db.query( + `UPDATE meeting_agenda_item + SET meeting_id = $1, + agenda_number = next_agenda_number($1), + duration_minutes = COALESCE($3, duration_minutes), + case_id = COALESCE(case_id, $4) + WHERE id = $2`, + [req.body.meeting_id, agendaItem.id, req.body.duration_minutes ?? null, currentCase.id] + ); + + const assignedResult = await db.query( + `SELECT ai.*, m.meeting_number, m.title AS meeting_title + FROM meeting_agenda_item ai + JOIN meeting m ON m.id = ai.meeting_id + WHERE ai.id = $1`, + [agendaItem.id] + ); + + return assignedResult.rows[0]; + }); + + if (!row) return res.status(404).json({ error: "Vorgang nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "CASE_ADDED_TO_AGENDA", + entityType: "case", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { meeting_id: req.body.meeting_id, agenda_item_id: row.id }, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +async function getCaseAttachments(db, caseId) { + const result = await db.query( + `SELECT d.id, + d.title, + d.file_name, + d.mime_type, + d.size_bytes, + d.uploaded_at, + uploader.full_name AS uploaded_by_name, + uploader.email AS uploaded_by_email + FROM document_item d + LEFT JOIN app_user uploader ON uploader.id = d.uploaded_by + WHERE d.case_id = $1 + AND d.deleted_at IS NULL + ORDER BY d.uploaded_at DESC, d.id DESC`, + [caseId] + ); + + return result.rows; +} + +router.get("/:id/attachments", async (req, res, next) => { + try { + const result = await withDbSession(req.user, async (db) => { + const caseRow = await getCaseById(db, req.params.id); + if (!caseRow) return null; + + const attachments = await getCaseAttachments(db, req.params.id); + return attachments; + }); + + if (result === null) { + return res.status(404).json({ error: "Nicht gefunden" }); + } + + res.json(result); + } catch (err) { + next(err); + } +}); + +router.delete( + "/:id/attachments/:attachmentId", + requireMfa, + requirePermission("cases.write", ["admin", "chair", "secretary", "member"]), + async (req, res, next) => { + try { + const deleted = await withDbSession(req.user, async (db) => { + const caseRow = await getCaseById(db, req.params.id); + if (!caseRow) { + throw Object.assign(new Error("Vorgang nicht gefunden"), { + status: 404, + publicMessage: "Vorgang nicht gefunden", + }); + } + + const attachmentResult = await db.query( + `SELECT id, case_id, file_name + FROM document_item + WHERE id = $1 + AND case_id = $2 + AND deleted_at IS NULL`, + [req.params.attachmentId, req.params.id] + ); + + const attachment = attachmentResult.rows[0]; + if (!attachment) { + throw Object.assign(new Error("Datei nicht gefunden"), { + status: 404, + publicMessage: "Datei nicht gefunden", + }); + } + + await db.query( + `UPDATE document_item + SET deleted_at = NOW() + WHERE id = $1`, + [req.params.attachmentId] + ); + + return attachment; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "CASE_ATTACHMENT_DELETED", + entityType: "case", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { + attachmentId: Number(req.params.attachmentId), + file_name: deleted.file_name, + }, + }); + + res.json({ ok: true, deleted }); + } catch (err) { + next(err); + } + } +); + +export default router; diff --git a/backend/src/modules/comments/routes.js b/backend/src/modules/comments/routes.js new file mode 100644 index 0000000..9e5443e --- /dev/null +++ b/backend/src/modules/comments/routes.js @@ -0,0 +1,328 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { writeAudit } from "../../utils/audit.js"; + +const router = express.Router(); +router.use(authenticate); + +const writeGuards = [requireMfa, requirePermission("comments.write", ["admin", "chair", "secretary", "member"])]; + +const createSchema = z.object({ + entity_type: z.string().min(2).max(50).default("agenda_item"), + entity_id: z.coerce.number().int().positive(), + comment_text: z.string().min(1).max(10000), + visibility: z.enum(["intern", "vertraulich"]).default("intern"), + include_in_protocol: z.coerce.boolean().optional().default(false), +}); + +const updateSchema = createSchema.partial(); + +function mapComment(row) { + if (!row) return null; + return { + id: row.id, + entity_type: row.entity_type, + entity_id: row.entity_id, + comment_text: row.comment_text, + visibility: row.visibility, + include_in_protocol: Boolean(row.include_in_protocol), + author_user_id: row.author_user_id, + author_name: row.author_name, + created_at: row.created_at, + agenda_item_id: row.agenda_item_id, + agenda_item_title: row.agenda_item_title, + agenda_number: row.agenda_number, + meeting_id: row.meeting_id, + meeting_number: row.meeting_number, + meeting_date: row.meeting_date, + meeting_status: row.meeting_status, + case_number: row.case_number, + decision_number: row.decision_number, + decision_status: row.decision_status, + }; +} + +function mapAgendaItem(row) { + if (!row) return null; + return { + id: row.id, + title: row.title, + agenda_number: row.agenda_number, + meeting_id: row.meeting_id, + meeting_number: row.meeting_number, + meeting_date: row.meeting_date, + meeting_status: row.meeting_status, + case_number: row.case_number, + decision_number: row.decision_number, + decision_status: row.decision_status, + }; +} + +const commentListSql = ` + SELECT c.id, + c.entity_type, + c.entity_id, + c.comment_text, + c.visibility, + COALESCE(c.include_in_protocol, FALSE) AS include_in_protocol, + c.author_user_id, + au.full_name AS author_name, + c.created_at, + ai.id AS agenda_item_id, + ai.title AS agenda_item_title, + ai.agenda_number, + ai.meeting_id, + m.meeting_number, + m.meeting_date, + m.status AS meeting_status, + bc.case_number, + latest_decision.decision_number, + latest_decision.status AS decision_status + FROM comment_item c + LEFT JOIN app_user au ON au.id = c.author_user_id + LEFT JOIN meeting_agenda_item ai ON c.entity_type = 'agenda_item' AND ai.id = c.entity_id + LEFT JOIN meeting m ON m.id = ai.meeting_id + LEFT JOIN br_case bc ON bc.id = ai.case_id + LEFT JOIN LATERAL ( + SELECT d.decision_number, d.status + FROM decision d + WHERE d.agenda_item_id = ai.id + ORDER BY d.created_at DESC, d.id DESC + LIMIT 1 + ) latest_decision ON TRUE + WHERE c.deleted_at IS NULL + ORDER BY c.created_at DESC, c.id DESC`; + +const openAgendaItemsSql = ` + SELECT ai.id, + ai.title, + ai.agenda_number, + ai.meeting_id, + m.meeting_number, + m.meeting_date, + m.status AS meeting_status, + bc.case_number, + latest_decision.decision_number, + latest_decision.status AS decision_status + FROM meeting_agenda_item ai + LEFT JOIN meeting m ON m.id = ai.meeting_id + LEFT JOIN br_case bc ON bc.id = ai.case_id + LEFT JOIN LATERAL ( + SELECT d.decision_number, d.status + FROM decision d + WHERE d.agenda_item_id = ai.id + ORDER BY d.created_at DESC, d.id DESC + LIMIT 1 + ) latest_decision ON TRUE + WHERE ai.meeting_id IS NULL + AND (latest_decision.status IS NULL OR latest_decision.status <> 'abgeschlossen') + ORDER BY m.meeting_date DESC NULLS LAST, + COALESCE(NULLIF(regexp_replace(COALESCE(ai.agenda_number, ''), '[^0-9]', '', 'g'), ''), '999999')::int, + ai.created_at DESC, + ai.id DESC`; + +async function getCommentById(db, id) { + const result = await db.query(`${commentListSql.replace("ORDER BY c.created_at DESC, c.id DESC", "AND c.id = $1")}`, [id]); + return mapComment(result.rows[0]); +} + +async function assertCommentTarget(db, entityType, entityId, options = {}) { + if (entityType !== "agenda_item") return; + + const result = await db.query( + `SELECT ai.id, + ai.meeting_id, + latest_decision.status AS decision_status + FROM meeting_agenda_item ai + LEFT JOIN LATERAL ( + SELECT d.status + FROM decision d + WHERE d.agenda_item_id = ai.id + ORDER BY d.created_at DESC, d.id DESC + LIMIT 1 + ) latest_decision ON TRUE + WHERE ai.id = $1`, + [entityId] + ); + + const row = result.rows[0]; + if (!row) { + throw Object.assign(new Error("TOP nicht gefunden"), { + status: 400, + publicMessage: "Der ausgewählte TOP wurde nicht gefunden.", + }); + } + + if (options.allowExistingTarget) return; + + if (row.meeting_id !== null) { + throw Object.assign(new Error("TOP ist bereits einer Sitzung zugeordnet"), { + status: 400, + publicMessage: "Kommentare können nur mit offenen TOPs aus dem TOP-Bestand verknüpft werden.", + }); + } + + if (row.decision_status === "abgeschlossen") { + throw Object.assign(new Error("TOP ist erledigt"), { + status: 400, + publicMessage: "Kommentare können nur mit TOPs verknüpft werden, die noch nicht erledigt sind.", + }); + } +} + +router.get("/open-agenda-items", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query(openAgendaItemsSql); + return result.rows.map(mapAgendaItem); + }); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query(commentListSql); + return result.rows.map(mapComment); + }); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/:id", async (req, res, next) => { + try { + const row = await withDbSession(req.user, (db) => getCommentById(db, req.params.id)); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.post("/", ...writeGuards, validate(createSchema), async (req, res, next) => { + try { + const payload = { + entity_type: req.body.entity_type || "agenda_item", + entity_id: Number(req.body.entity_id), + comment_text: req.body.comment_text.trim(), + visibility: req.body.visibility || "intern", + author_user_id: Number(req.user.sub), + include_in_protocol: Boolean(req.body.include_in_protocol), + }; + + const row = await withDbSession(req.user, async (db) => { + await assertCommentTarget(db, payload.entity_type, payload.entity_id); + const result = await db.query( + `INSERT INTO comment_item(entity_type, entity_id, comment_text, visibility, author_user_id, include_in_protocol) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + [payload.entity_type, payload.entity_id, payload.comment_text, payload.visibility, payload.author_user_id, payload.include_in_protocol] + ); + return getCommentById(db, result.rows[0].id); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "COMMENT_CREATED", + entityType: "comment", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload: { ...payload, comment_text: undefined } }, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } +}); + +router.put("/:id", ...writeGuards, validate(updateSchema), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getCommentById(db, req.params.id); + if (!existing) return null; + + const nextEntityType = req.body.entity_type ?? existing.entity_type; + const nextEntityId = req.body.entity_id !== undefined ? Number(req.body.entity_id) : existing.entity_id; + const targetUnchanged = nextEntityType === existing.entity_type && Number(nextEntityId) === Number(existing.entity_id); + await assertCommentTarget(db, nextEntityType, nextEntityId, { allowExistingTarget: targetUnchanged }); + + const fields = []; + const values = []; + let idx = 1; + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + if (req.body.entity_type !== undefined) push("entity_type", req.body.entity_type); + if (req.body.entity_id !== undefined) push("entity_id", Number(req.body.entity_id)); + if (req.body.comment_text !== undefined) push("comment_text", req.body.comment_text.trim()); + if (req.body.visibility !== undefined) push("visibility", req.body.visibility); + if (req.body.include_in_protocol !== undefined) push("include_in_protocol", Boolean(req.body.include_in_protocol)); + + if (!fields.length) { + throw Object.assign(new Error("Keine Felder zum Aktualisieren"), { + status: 400, + publicMessage: "Keine Felder zum Aktualisieren.", + }); + } + + values.push(req.params.id); + await db.query(`UPDATE comment_item SET ${fields.join(", ")} WHERE id = $${idx}`, values); + return getCommentById(db, req.params.id); + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "COMMENT_UPDATED", + entityType: "comment", + entityId: row.id, + ipAddress: req.ip, + metadata: { changes: Object.keys(req.body || {}) }, + }); + + res.json(row); + } catch (err) { + next(err); + } +}); + +router.delete("/:id", ...writeGuards, async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getCommentById(db, req.params.id); + if (!existing) return null; + await db.query(`UPDATE comment_item SET deleted_at = NOW() WHERE id = $1`, [req.params.id]); + return existing; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "COMMENT_DELETED", + entityType: "comment", + entityId: row.id, + ipAddress: req.ip, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/modules/committees/routes.js b/backend/src/modules/committees/routes.js new file mode 100644 index 0000000..2fe5ac2 --- /dev/null +++ b/backend/src/modules/committees/routes.js @@ -0,0 +1,602 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission, requireRoles } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { writeAudit } from "../../utils/audit.js"; + +const router = express.Router(); +router.use(authenticate); + +const nullablePositiveInt = z.preprocess( + (value) => { + if (value === "" || value === null || value === undefined) return undefined; + return value; + }, + z.coerce.number().int().positive().optional() +); + +const createSchema = z.object({ + name: z.string().min(2).max(255), + committee_type: z.string().min(2).max(100).default("general"), + default_user_id: nullablePositiveInt, + chair_user_id: nullablePositiveInt, + secretary_user_id: nullablePositiveInt, +}); + +const updateSchema = createSchema.partial(); + +const assignMemberSchema = z.object({ + user_id: z.coerce.number().int().positive(), +}); + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function normalizeNullableNumber(value) { + if (value === undefined) return undefined; + if (value === null || value === "") return null; + return Number(value); +} + +function mapCommitteeRow(row) { + if (!row) return null; + return { + id: row.id, + name: row.name, + committee_type: row.committee_type, + default_user_id: row.default_user_id, + default_user_name: row.default_user_name, + chair_user_id: row.chair_user_id, + chair_user_name: row.chair_user_name, + secretary_user_id: row.secretary_user_id, + secretary_user_name: row.secretary_user_name, + member_count: row.member_count ?? 0, + created_at: row.created_at ?? null, + updated_at: row.updated_at ?? null, + }; +} + +async function getTableColumns(db, tableName) { + const result = await db.query( + ` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = $1 + `, + [tableName] + ); + + return new Set(result.rows.map((row) => row.column_name)); +} + +async function tableExists(db, tableName) { + const result = await db.query( + ` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = $1 + ) AS exists + `, + [tableName] + ); + + return Boolean(result.rows[0]?.exists); +} + +function buildCommitteeSelectFragments(committeeColumns, hasMemberTable) { + return { + createdAtExpr: committeeColumns.has("created_at") + ? "c.created_at" + : "NULL::timestamptz AS created_at", + updatedAtExpr: committeeColumns.has("updated_at") + ? "c.updated_at" + : "NULL::timestamptz AS updated_at", + memberCountExpr: hasMemberTable + ? `(SELECT COUNT(*)::int FROM committee_member cm WHERE cm.committee_id = c.id) AS member_count` + : `0::int AS member_count`, + }; +} + +async function assertUserReference(db, userId, label) { + if (userId === undefined || userId === null) return; + + const ref = await db.query( + ` + SELECT id + FROM app_user + WHERE id = $1 + AND is_active = TRUE + `, + [userId] + ); + + if (!ref.rows[0]) { + throw Object.assign(new Error(`${label} nicht gefunden`), { + status: 400, + publicMessage: `${label} nicht gefunden`, + }); + } +} + +async function assertReferences(db, payload) { + await assertUserReference(db, payload.default_user_id, "Standard-User"); + await assertUserReference(db, payload.chair_user_id, "Leitung"); + await assertUserReference(db, payload.secretary_user_id, "Schriftführung"); +} + +async function getCommitteeById(db, id) { + const committeeColumns = await getTableColumns(db, "committee"); + const hasMemberTable = await tableExists(db, "committee_member"); + const { createdAtExpr, updatedAtExpr, memberCountExpr } = buildCommitteeSelectFragments( + committeeColumns, + hasMemberTable + ); + + const result = await db.query( + ` + SELECT c.id, + c.name, + c.committee_type, + c.default_user_id, + du.full_name AS default_user_name, + c.chair_user_id, + cu.full_name AS chair_user_name, + c.secretary_user_id, + su.full_name AS secretary_user_name, + ${createdAtExpr}, + ${updatedAtExpr}, + ${memberCountExpr} + FROM committee c + LEFT JOIN app_user du ON du.id = c.default_user_id + LEFT JOIN app_user cu ON cu.id = c.chair_user_id + LEFT JOIN app_user su ON su.id = c.secretary_user_id + WHERE c.id = $1 + `, + [id] + ); + + return mapCommitteeRow(result.rows[0]); +} + +router.get("/", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, async (db) => { + const committeeColumns = await getTableColumns(db, "committee"); + const hasMemberTable = await tableExists(db, "committee_member"); + const { createdAtExpr, updatedAtExpr, memberCountExpr } = buildCommitteeSelectFragments( + committeeColumns, + hasMemberTable + ); + + const result = await db.query( + ` + SELECT c.id, + c.name, + c.committee_type, + c.default_user_id, + du.full_name AS default_user_name, + c.chair_user_id, + cu.full_name AS chair_user_name, + c.secretary_user_id, + su.full_name AS secretary_user_name, + ${createdAtExpr}, + ${updatedAtExpr}, + ${memberCountExpr} + FROM committee c + LEFT JOIN app_user du ON du.id = c.default_user_id + LEFT JOIN app_user cu ON cu.id = c.chair_user_id + LEFT JOIN app_user su ON su.id = c.secretary_user_id + ORDER BY c.name ASC, c.id ASC + ` + ); + + return result.rows.map(mapCommitteeRow); + }); + + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/:id", async (req, res, next) => { + try { + const row = await withDbSession(req.user, (db) => getCommitteeById(db, req.params.id)); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.get("/:id/members", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, async (db) => { + const committee = await getCommitteeById(db, req.params.id); + if (!committee) { + throw Object.assign(new Error("Ausschuss nicht gefunden"), { + status: 404, + publicMessage: "Ausschuss nicht gefunden", + }); + } + + const hasMemberTable = await tableExists(db, "committee_member"); + if (!hasMemberTable) { + return []; + } + + const result = await db.query( + ` + SELECT cm.committee_id, + u.id AS user_id, + u.full_name, + u.email, + u.role, + u.is_active, + u.security_classification, + cm.created_at + FROM committee_member cm + JOIN app_user u ON u.id = cm.user_id + WHERE cm.committee_id = $1 + ORDER BY u.full_name ASC, u.id ASC + `, + [req.params.id] + ); + + return result.rows; + }); + + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.post( + "/:id/members", + requireMfa, + requirePermission("committees.write", ["admin", "chair", "secretary"]), + validate(assignMemberSchema), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const committee = await getCommitteeById(db, req.params.id); + if (!committee) { + throw Object.assign(new Error("Ausschuss nicht gefunden"), { + status: 404, + publicMessage: "Ausschuss nicht gefunden", + }); + } + + const hasMemberTable = await tableExists(db, "committee_member"); + if (!hasMemberTable) { + throw Object.assign(new Error("Migration committee_member fehlt"), { + status: 500, + publicMessage: "Mitgliedschaftstabelle fehlt. Bitte Migration ausführen.", + }); + } + + await assertUserReference(db, req.body.user_id, "Benutzer"); + + const memberColumns = await getTableColumns(db, "committee_member"); + const insertColumns = ["committee_id", "user_id"]; + const insertValues = [req.params.id, req.body.user_id]; + const placeholders = ["$1", "$2"]; + + if (memberColumns.has("created_by")) { + insertColumns.push("created_by"); + insertValues.push(req.user.sub); + placeholders.push(`$${insertValues.length}`); + } + + await db.query( + ` + INSERT INTO committee_member( + ${insertColumns.join(", ")} + ) + VALUES (${placeholders.join(", ")}) + ON CONFLICT (committee_id, user_id) DO NOTHING + `, + insertValues + ); + + const result = await db.query( + ` + SELECT cm.committee_id, + u.id AS user_id, + u.full_name, + u.email, + u.role, + u.is_active, + u.security_classification, + cm.created_at + FROM committee_member cm + JOIN app_user u ON u.id = cm.user_id + WHERE cm.committee_id = $1 + AND cm.user_id = $2 + `, + [req.params.id, req.body.user_id] + ); + + return result.rows[0]; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "COMMITTEE_MEMBER_ASSIGNED", + entityType: "committee", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { user_id: req.body.user_id }, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +router.delete( + "/:id/members/:userId", + requireMfa, + requirePermission("committees.write", ["admin", "chair", "secretary"]), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const committee = await getCommitteeById(db, req.params.id); + if (!committee) { + throw Object.assign(new Error("Ausschuss nicht gefunden"), { + status: 404, + publicMessage: "Ausschuss nicht gefunden", + }); + } + + const hasMemberTable = await tableExists(db, "committee_member"); + if (!hasMemberTable) { + throw Object.assign(new Error("Migration committee_member fehlt"), { + status: 500, + publicMessage: "Mitgliedschaftstabelle fehlt. Bitte Migration ausführen.", + }); + } + + const result = await db.query( + ` + DELETE FROM committee_member + WHERE committee_id = $1 + AND user_id = $2 + RETURNING committee_id, user_id + `, + [req.params.id, req.params.userId] + ); + + if (!result.rows[0]) { + throw Object.assign(new Error("Mitgliedschaft nicht gefunden"), { + status: 404, + publicMessage: "Mitgliedschaft nicht gefunden", + }); + } + + return result.rows[0]; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "COMMITTEE_MEMBER_REMOVED", + entityType: "committee", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { user_id: Number(req.params.userId) }, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } + } +); + +router.post( + "/", + requireMfa, + requirePermission("committees.write", ["admin", "chair", "secretary"]), + validate(createSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + name: req.body.name.trim(), + committee_type: normalizeNullableText(req.body.committee_type) || "general", + default_user_id: normalizeNullableNumber(req.body.default_user_id), + chair_user_id: normalizeNullableNumber(req.body.chair_user_id), + secretary_user_id: normalizeNullableNumber(req.body.secretary_user_id), + }; + + const row = await withDbSession(req.user, async (db) => { + await assertReferences(db, payload); + + const committeeColumns = await getTableColumns(db, "committee"); + const insertColumns = [ + "name", + "committee_type", + "default_user_id", + "chair_user_id", + "secretary_user_id", + ]; + const insertValues = [ + payload.name, + payload.committee_type, + payload.default_user_id, + payload.chair_user_id, + payload.secretary_user_id, + ]; + const placeholders = insertValues.map((_, index) => `$${index + 1}`); + + if (committeeColumns.has("created_by")) { + insertColumns.push("created_by"); + insertValues.push(req.user.sub); + placeholders.push(`$${insertValues.length}`); + } + + if (committeeColumns.has("updated_by")) { + insertColumns.push("updated_by"); + insertValues.push(req.user.sub); + placeholders.push(`$${insertValues.length}`); + } + + const result = await db.query( + ` + INSERT INTO committee( + ${insertColumns.join(", ")} + ) + VALUES (${placeholders.join(", ")}) + RETURNING id + `, + insertValues + ); + + return getCommitteeById(db, result.rows[0].id); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "COMMITTEE_CREATED", + entityType: "committee", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload }, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +router.put( + "/:id", + requireMfa, + requirePermission("committees.write", ["admin", "chair", "secretary"]), + validate(updateSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + name: req.body.name !== undefined ? req.body.name.trim() : undefined, + committee_type: + req.body.committee_type !== undefined + ? normalizeNullableText(req.body.committee_type) + : undefined, + default_user_id: normalizeNullableNumber(req.body.default_user_id), + chair_user_id: normalizeNullableNumber(req.body.chair_user_id), + secretary_user_id: normalizeNullableNumber(req.body.secretary_user_id), + }; + + const row = await withDbSession(req.user, async (db) => { + const existing = await getCommitteeById(db, req.params.id); + if (!existing) return null; + + await assertReferences(db, payload); + + const committeeColumns = await getTableColumns(db, "committee"); + const fields = []; + const values = []; + let idx = 1; + + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + if (payload.name !== undefined) push("name", payload.name); + if (payload.committee_type !== undefined) push("committee_type", payload.committee_type || "general"); + if (payload.default_user_id !== undefined) push("default_user_id", payload.default_user_id); + if (payload.chair_user_id !== undefined) push("chair_user_id", payload.chair_user_id); + if (payload.secretary_user_id !== undefined) push("secretary_user_id", payload.secretary_user_id); + + if (!fields.length) { + throw Object.assign(new Error("Keine Felder zum Aktualisieren"), { + status: 400, + publicMessage: "Keine Felder zum Aktualisieren", + }); + } + + if (committeeColumns.has("updated_by")) { + push("updated_by", req.user.sub); + } + + values.push(req.params.id); + + await db.query( + ` + UPDATE committee + SET ${fields.join(", ")} + WHERE id = $${idx} + `, + values + ); + + return getCommitteeById(db, req.params.id); + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "COMMITTEE_UPDATED", + entityType: "committee", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload }, + }); + + res.json(row); + } catch (err) { + next(err); + } + } +); + +router.delete( + "/:id", + requireMfa, + requirePermission("committees.write", ["admin", "chair", "secretary"]), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getCommitteeById(db, req.params.id); + if (!existing) return null; + + await db.query(`DELETE FROM committee WHERE id = $1`, [req.params.id]); + return existing; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "COMMITTEE_DELETED", + entityType: "committee", + entityId: row.id, + ipAddress: req.ip, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } + } +); + +export default router; diff --git a/backend/src/modules/crudFactory.js b/backend/src/modules/crudFactory.js new file mode 100644 index 0000000..df56726 --- /dev/null +++ b/backend/src/modules/crudFactory.js @@ -0,0 +1,92 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../middleware/auth.js"; +import { requirePermission, requireRoles } from "../middleware/rbac.js"; +import { validate } from "../middleware/validate.js"; +import { withDbSession } from "../config/db.js"; +import { writeAudit } from "../utils/audit.js"; + +function buildInsert(table, columns) { + const names = columns.map((c) => c.column); + const placeholders = names.map((_, i) => `$${i + 1}`); + return `INSERT INTO ${table} (${names.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`; +} +function buildUpdate(table, columns, idColumn = "id") { + const sets = columns.map((c, i) => `${c.column} = $${i + 1}`); + return `UPDATE ${table} SET ${sets.join(", ")} WHERE ${idColumn} = $${columns.length + 1} RETURNING *`; +} + +export function createCrudRouter(config) { + const router = express.Router(); + const createSchema = z.object(Object.fromEntries(config.fields.map((f) => [f.name, f.schema]))); + const updateSchema = createSchema.partial(); + + router.use(authenticate); + + router.get("/", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query(config.listSql); + return result.rows; + }); + res.json(rows); + } catch (err) { next(err); } + }); + + router.get("/:id", async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query(config.detailSql, [req.params.id]); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + res.json(row); + } catch (err) { next(err); } + }); + + router.post("/", requireMfa, ...(config.writePermission ? [requirePermission(config.writePermission, config.writeRoles || [])] : config.writeRoles ? [requireRoles(...config.writeRoles)] : []), validate(createSchema), async (req, res, next) => { + try { + const payload = req.body; + const dbColumns = config.fields.map((f) => f.db || f.name); + const values = config.fields.map((f) => payload[f.name] ?? null); + const sql = buildInsert(config.table, config.fields.map((f) => ({ column: f.db || f.name }))); + const row = await withDbSession(req.user, async (db) => { + const result = await db.query(sql, values); + return result.rows[0]; + }); + await writeAudit({ actorId: req.user.sub, action: `${config.auditPrefix}_CREATED`, entityType: config.entityType, entityId: row.id, ipAddress: req.ip, metadata: { payload } }); + res.status(201).json(row); + } catch (err) { next(err); } + }); + + router.put("/:id", requireMfa, ...(config.writePermission ? [requirePermission(config.writePermission, config.writeRoles || [])] : config.writeRoles ? [requireRoles(...config.writeRoles)] : []), validate(updateSchema), async (req, res, next) => { + try { + const presentFields = config.fields.filter((f) => req.body[f.name] !== undefined); + if (!presentFields.length) return res.status(400).json({ error: "Keine Felder zum Aktualisieren" }); + const sql = buildUpdate(config.table, presentFields.map((f) => ({ column: f.db || f.name })), config.idColumn || "id"); + const values = presentFields.map((f) => req.body[f.name]); + values.push(req.params.id); + const row = await withDbSession(req.user, async (db) => { + const result = await db.query(sql, values); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: `${config.auditPrefix}_UPDATED`, entityType: config.entityType, entityId: row.id, ipAddress: req.ip, metadata: { changes: req.body } }); + res.json(row); + } catch (err) { next(err); } + }); + + router.delete("/:id", requireMfa, ...(config.writePermission ? [requirePermission(config.writePermission, config.writeRoles || [])] : config.writeRoles ? [requireRoles(...config.writeRoles)] : []), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query(config.deleteSql, [req.params.id]); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: `${config.auditPrefix}_DELETED`, entityType: config.entityType, entityId: row.id, ipAddress: req.ip }); + res.json({ ok: true, row }); + } catch (err) { next(err); } + }); + + return router; +} diff --git a/backend/src/modules/dashboard/routes.js b/backend/src/modules/dashboard/routes.js new file mode 100644 index 0000000..475983e --- /dev/null +++ b/backend/src/modules/dashboard/routes.js @@ -0,0 +1,32 @@ +import express from "express"; +import { authenticate } from "../../middleware/auth.js"; +import { withDbSession } from "../../config/db.js"; + +const router = express.Router(); +router.use(authenticate); + +router.get("/", async (req, res, next) => { + try { + const data = await withDbSession(req.user, async (db) => { + const [cases, deadlines, meetings, tasks, documents, notifications] = await Promise.all([ + db.query(`SELECT COUNT(*)::int AS count FROM br_case WHERE deleted_at IS NULL`), + db.query(`SELECT COUNT(*)::int AS count FROM deadline WHERE deleted_at IS NULL AND due_date <= NOW() + INTERVAL '3 days'`), + db.query(`SELECT COUNT(*)::int AS count FROM meeting WHERE meeting_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '7 days'`), + db.query(`SELECT COUNT(*)::int AS count FROM task WHERE deleted_at IS NULL AND status <> 'erledigt'`), + db.query(`SELECT COUNT(*)::int AS count FROM document_item WHERE deleted_at IS NULL`), + db.query(`SELECT COUNT(*)::int AS count FROM notification_item WHERE user_id = app_user_id() AND is_read = FALSE`) + ]); + return { + openCases: cases.rows[0].count, + urgentDeadlines: deadlines.rows[0].count, + meetingsThisWeek: meetings.rows[0].count, + openTasks: tasks.rows[0].count, + documents: documents.rows[0].count, + unreadNotifications: notifications.rows[0].count, + }; + }); + res.json(data); + } catch (err) { next(err); } +}); + +export default router; diff --git a/backend/src/modules/deadlines/routes.js b/backend/src/modules/deadlines/routes.js new file mode 100644 index 0000000..463734a --- /dev/null +++ b/backend/src/modules/deadlines/routes.js @@ -0,0 +1,23 @@ + import { z } from "zod"; + import { createCrudRouter } from "../crudFactory.js"; + + export default createCrudRouter({ + table: "deadline", + entityType: "deadline", + auditPrefix: "DEADLINE", + listSql: `SELECT d.id, d.case_id, c.case_number, d.title, d.deadline_type, d.due_date, d.status, d.remind_days_before, d.created_at FROM deadline d LEFT JOIN br_case c ON c.id = d.case_id WHERE d.deleted_at IS NULL ORDER BY d.due_date`, + detailSql: `SELECT * FROM deadline WHERE id = $1 AND deleted_at IS NULL`, + deleteSql: `UPDATE deadline SET deleted_at = NOW() WHERE id = $1 RETURNING *`, + writeRoles: ['admin', 'chair', 'secretary', 'member'], + writePermission: 'deadlines.write', + fields: [ + { name: 'case_id', schema: z.number().int().nullable().optional() }, +{ name: 'title', schema: z.string().min(2).max(255) }, +{ name: 'deadline_type', schema: z.string().min(2).max(50) }, +{ name: 'due_date', schema: z.string().datetime() }, +{ name: 'status', schema: z.string().min(2).max(30).default('offen') }, +{ name: 'remind_days_before', schema: z.number().int().min(0).max(90).default(2) }, +{ name: 'created_by', schema: z.number().int().optional().nullable() }, +{ name: 'updated_by', schema: z.number().int().optional().nullable() } + ] + }); diff --git a/backend/src/modules/decisions/routes.js b/backend/src/modules/decisions/routes.js new file mode 100644 index 0000000..7da7ded --- /dev/null +++ b/backend/src/modules/decisions/routes.js @@ -0,0 +1,989 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission, requireRoles } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { writeAudit } from "../../utils/audit.js"; +import { sendMail } from "../../services/mail.js"; + +const router = express.Router(); +router.use(authenticate); + +const decisionStatusSchema = z.enum(["in_bearbeitung", "rueckfrage", "abgeschlossen"]); +const voteValueSchema = z.enum(["yes", "no", "abstain"]); + +const nullablePositiveInt = z.preprocess( + (value) => { + if (value === "" || value === null || value === undefined) return undefined; + return value; + }, + z.coerce.number().int().positive().optional() +); + +const createSchema = z.object({ + decision_number: z.string().min(3).max(50).optional().nullable(), + meeting_id: nullablePositiveInt, + agenda_item_id: nullablePositiveInt, + case_id: nullablePositiveInt, + title: z.string().min(3).max(255), + decision_text: z.string().min(3).max(20000), + status: decisionStatusSchema.default("in_bearbeitung"), + votes_yes: z.coerce.number().int().min(0).default(0), + votes_no: z.coerce.number().int().min(0).default(0), + votes_abstain: z.coerce.number().int().min(0).default(0), + responsible_user_id: nullablePositiveInt, + due_date: z.string().optional().nullable(), +}); + +const updateSchema = z.object({ + decision_number: z.string().min(3).max(50).optional().nullable(), + meeting_id: nullablePositiveInt, + agenda_item_id: nullablePositiveInt, + case_id: nullablePositiveInt, + title: z.string().min(3).max(255).optional(), + decision_text: z.string().min(3).max(20000).optional(), + status: decisionStatusSchema.optional(), + votes_yes: z.coerce.number().int().min(0).optional(), + votes_no: z.coerce.number().int().min(0).optional(), + votes_abstain: z.coerce.number().int().min(0).optional(), + responsible_user_id: nullablePositiveInt, + due_date: z.string().optional().nullable(), +}); + +function normalizeNullableNumber(value) { + if (value === undefined) return undefined; + if (value === null || value === "") return null; + return Number(value); +} + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function normalizeOptionalText(value) { + if (value === undefined || value === null) return undefined; + const trimmed = String(value).trim(); + return trimmed ? trimmed : undefined; +} + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === "") return fallback; + if (typeof value === "boolean") return value; + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "ja", "on"].includes(normalized)) return true; + if (["0", "false", "no", "nein", "off"].includes(normalized)) return false; + return fallback; +} + +function mapDecisionRow(row) { + if (!row) return null; + return { + id: row.id, + decision_number: row.decision_number, + title: row.title, + decision_text: row.decision_text, + status: row.status, + meeting_id: row.meeting_id, + meeting_number: row.meeting_number, + agenda_item_id: row.agenda_item_id, + agenda_number: row.agenda_number, + agenda_title: row.agenda_title, + case_id: row.case_id, + case_number: row.case_number, + votes_yes: row.votes_yes, + votes_no: row.votes_no, + votes_abstain: row.votes_abstain, + voting_open: row.voting_open, + voting_started_at: row.voting_started_at, + voting_started_by: row.voting_started_by, + voting_closed_at: row.voting_closed_at, + voting_closed_by: row.voting_closed_by, + responsible_user_id: row.responsible_user_id, + responsible_name: row.responsible_name, + responsible_email: row.responsible_email, + due_date: row.due_date, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +async function getDecisionList(db, options = {}) { + const includeCompleted = parseBoolean(options.includeCompleted, false); + const result = await db.query( + `SELECT d.id, + d.decision_number, + d.title, + d.decision_text, + d.status, + d.meeting_id, + m.meeting_number, + d.agenda_item_id, + ai.agenda_number, + ai.title AS agenda_title, + d.case_id, + c.case_number, + d.votes_yes, + d.votes_no, + d.votes_abstain, + d.voting_open, + d.voting_started_at, + d.voting_started_by, + d.voting_closed_at, + d.voting_closed_by, + d.responsible_user_id, + u.full_name AS responsible_name, + u.email AS responsible_email, + d.due_date, + d.created_at, + d.updated_at + FROM decision d + LEFT JOIN meeting m ON m.id = d.meeting_id + LEFT JOIN meeting_agenda_item ai ON ai.id = d.agenda_item_id + LEFT JOIN br_case c ON c.id = d.case_id + LEFT JOIN app_user u ON u.id = d.responsible_user_id + WHERE ($1::boolean = TRUE OR d.status <> 'abgeschlossen') + ORDER BY d.created_at DESC, d.id DESC`, + [includeCompleted] + ); + + return result.rows.map(mapDecisionRow); +} + +async function getDecisionById(db, id) { + const result = await db.query( + `SELECT d.id, + d.decision_number, + d.title, + d.decision_text, + d.status, + d.meeting_id, + m.meeting_number, + d.agenda_item_id, + ai.agenda_number, + ai.title AS agenda_title, + d.case_id, + c.case_number, + d.votes_yes, + d.votes_no, + d.votes_abstain, + d.voting_open, + d.voting_started_at, + d.voting_started_by, + d.voting_closed_at, + d.voting_closed_by, + d.responsible_user_id, + u.full_name AS responsible_name, + u.email AS responsible_email, + d.due_date, + d.created_at, + d.updated_at + FROM decision d + LEFT JOIN meeting m ON m.id = d.meeting_id + LEFT JOIN meeting_agenda_item ai ON ai.id = d.agenda_item_id + LEFT JOIN br_case c ON c.id = d.case_id + LEFT JOIN app_user u ON u.id = d.responsible_user_id + WHERE d.id = $1`, + [id] + ); + + return result.rows[0] ? mapDecisionRow(result.rows[0]) : null; +} + +async function assertResponsibleUser(db, userId) { + if (userId == null) return; + const result = await db.query(`SELECT id FROM app_user WHERE id = $1 AND is_active = TRUE`, [userId]); + if (!result.rows[0]) { + throw Object.assign(new Error("Verantwortliche Person nicht gefunden"), { + status: 400, + publicMessage: "Verantwortliche Person nicht gefunden", + }); + } +} + +async function deriveFromAgendaItem(db, agendaItemId) { + if (agendaItemId == null) return null; + + const result = await db.query( + `SELECT ai.id, + ai.meeting_id, + ai.case_id, + ai.title, + ai.agenda_number, + m.status AS meeting_status, + c.case_number + FROM meeting_agenda_item ai + LEFT JOIN meeting m ON m.id = ai.meeting_id + LEFT JOIN br_case c ON c.id = ai.case_id + WHERE ai.id = $1`, + [agendaItemId] + ); + + const row = result.rows[0]; + if (!row) { + throw Object.assign(new Error("TOP nicht gefunden"), { + status: 400, + publicMessage: "TOP nicht gefunden", + }); + } + + if (!row.meeting_id) { + throw Object.assign(new Error("Der ausgewählte TOP ist keiner Sitzung zugeordnet"), { + status: 400, + publicMessage: "Der ausgewählte TOP ist keiner Sitzung zugeordnet", + }); + } + + return row; +} + +async function getCaseForRueckfrage(db, caseId) { + if (!caseId) return null; + + const result = await db.query( + `SELECT id, case_number, title, summary + FROM br_case + WHERE id = $1 + AND deleted_at IS NULL`, + [caseId] + ); + + return result.rows[0] || null; +} + +async function getUnassignedAgendaItemForCase(db, caseId) { + if (!caseId) return null; + + const result = await db.query( + `SELECT id + FROM meeting_agenda_item + WHERE case_id = $1 + AND meeting_id IS NULL + ORDER BY created_at ASC, id ASC + LIMIT 1`, + [caseId] + ); + + return result.rows[0] || null; +} + +async function ensureRueckfrageAgendaItem(db, decisionRow) { + if (!decisionRow?.case_id) return null; + + const existing = await getUnassignedAgendaItemForCase(db, decisionRow.case_id); + if (existing) return existing; + + const caseRow = await getCaseForRueckfrage(db, decisionRow.case_id); + if (!caseRow) return null; + + const title = `${caseRow.case_number} – ${caseRow.title}`; + const description = `Automatisch aus Rückfrage zu Beschluss ${decisionRow.decision_number} erzeugt.`; + + const insert = await db.query( + `INSERT INTO meeting_agenda_item(meeting_id, agenda_number, title, description, duration_minutes, case_id) + VALUES (NULL, NULL, $1, $2, NULL, $3) + RETURNING id`, + [title, description, caseRow.id] + ); + + return insert.rows[0] || null; +} + +async function applyRueckfrageWorkflow(db, decisionRow, actorId) { + if (!decisionRow || decisionRow.status !== "rueckfrage") return; + + if (decisionRow.case_id) { + await db.query( + `UPDATE br_case + SET status = 'rueckfrage', + updated_by = $2, + updated_at = NOW() + WHERE id = $1 + AND deleted_at IS NULL`, + [decisionRow.case_id, actorId] + ); + + await ensureRueckfrageAgendaItem(db, decisionRow); + } + + if (decisionRow.responsible_user_id) { + await db.query( + `INSERT INTO notification_item(user_id, notification_type, title, entity_type, entity_id) + VALUES ($1, $2, $3, $4, $5)`, + [ + decisionRow.responsible_user_id, + 'decision_rueckfrage', + `Rückfrage zu Beschluss ${decisionRow.decision_number}`, + 'decision', + decisionRow.id, + ] + ); + } +} + +async function sendRueckfrageMail(decisionRow) { + if (!decisionRow?.responsible_email) return; + + const subject = `Rückfrage zu Beschluss ${decisionRow.decision_number}`; + const lines = [ + `Hallo ${decisionRow.responsible_name || 'Kollegin/Kollege'},`, + '', + `zu folgendem Beschluss wurde eine Rückfrage markiert:`, + `${decisionRow.decision_number} – ${decisionRow.title}`, + ]; + + if (decisionRow.case_number) { + lines.push(`Vorgang: ${decisionRow.case_number}`); + } + + lines.push('', 'Beschlusstext:', decisionRow.decision_text || '', '', 'Bitte Rückmeldung im BR-Office eintragen.'); + + await sendMail({ + to: decisionRow.responsible_email, + subject, + text: lines.join('\n'), + }); +} + +function voteCountsFromRows(rows) { + return rows.reduce( + (acc, row) => { + if (row.vote_value === "yes") acc.yes += Number(row.count || 0); + if (row.vote_value === "no") acc.no += Number(row.count || 0); + if (row.vote_value === "abstain") acc.abstain += Number(row.count || 0); + return acc; + }, + { yes: 0, no: 0, abstain: 0 } + ); +} + +function mapVoteToDecisionColumns(counts) { + return { + votes_yes: counts.yes || 0, + votes_no: counts.no || 0, + votes_abstain: counts.abstain || 0, + }; +} + +async function synchronizeDecisionVoteCounts(db, decisionId) { + const result = await db.query(`SELECT * FROM sync_decision_vote_counts($1)`, [decisionId]); + const row = result.rows[0] || {}; + return { + yes: Number(row.votes_yes || 0), + no: Number(row.votes_no || 0), + abstain: Number(row.votes_abstain || 0), + }; +} + +async function getVotingContext(db, decisionId) { + const result = await db.query( + `SELECT d.id, + d.decision_number, + d.title, + d.voting_open, + d.voting_started_at, + d.voting_started_by, + d.voting_closed_at, + d.voting_closed_by, + d.meeting_id, + m.meeting_number, + m.title AS meeting_title, + m.status AS meeting_status, + m.committee_id, + c.name AS committee_name + FROM decision d + LEFT JOIN meeting m ON m.id = d.meeting_id + LEFT JOIN committee c ON c.id = m.committee_id + WHERE d.id = $1`, + [decisionId] + ); + + return result.rows[0] || null; +} + +async function getEligibleVoters(db, committeeId, decisionId) { + if (!committeeId) return []; + + const result = await db.query( + `SELECT u.id, + u.full_name, + u.email, + u.role, + v.vote_value, + v.updated_at AS voted_at + FROM committee_member cm + JOIN app_user u ON u.id = cm.user_id + LEFT JOIN decision_vote v ON v.decision_id = $2 AND v.user_id = u.id + WHERE cm.committee_id = $1 + AND u.is_active = TRUE + AND u.role <> 'advisor' + ORDER BY u.full_name ASC, u.id ASC`, + [committeeId, decisionId] + ); + + return result.rows; +} + +async function canUserVoteOnDecision(db, decisionId, userId) { + const result = await db.query( + `SELECT 1 + FROM decision d + JOIN meeting m ON m.id = d.meeting_id + JOIN committee_member cm ON cm.committee_id = m.committee_id + JOIN app_user u ON u.id = cm.user_id + WHERE d.id = $1 + AND cm.user_id = $2 + AND u.is_active = TRUE + AND u.role <> 'advisor' + LIMIT 1`, + [decisionId, userId] + ); + + return Boolean(result.rows[0]); +} + +async function userCanManageDecisionVoting(db) { + const result = await db.query(`SELECT app_has_permission('decisions.vote.manage') AS allowed`); + return Boolean(result.rows[0]?.allowed); +} + +async function getDecisionVotingState(db, decisionId, userId) { + await synchronizeDecisionVoteCounts(db, decisionId); + + const context = await getVotingContext(db, decisionId); + if (!context) return null; + + const countsResult = await db.query( + `SELECT vote_value, COUNT(*)::int AS count + FROM decision_vote + WHERE decision_id = $1 + GROUP BY vote_value`, + [decisionId] + ); + const ownVoteResult = await db.query( + `SELECT vote_value, updated_at + FROM decision_vote + WHERE decision_id = $1 + AND user_id = $2`, + [decisionId, userId] + ); + const eligibleVoters = await getEligibleVoters(db, context.committee_id, decisionId); + const canVote = await canUserVoteOnDecision(db, decisionId, userId); + const canManage = await userCanManageDecisionVoting(db); + + const counts = voteCountsFromRows(countsResult.rows); + const columns = mapVoteToDecisionColumns(counts); + + return { + decision: { + id: context.id, + decision_number: context.decision_number, + title: context.title, + meeting_id: context.meeting_id, + meeting_number: context.meeting_number, + meeting_title: context.meeting_title, + meeting_status: context.meeting_status, + committee_id: context.committee_id, + committee_name: context.committee_name, + voting_open: context.voting_open, + voting_started_at: context.voting_started_at, + voting_started_by: context.voting_started_by, + voting_closed_at: context.voting_closed_at, + voting_closed_by: context.voting_closed_by, + ...columns, + }, + counts, + total_votes: counts.yes + counts.no + counts.abstain, + eligible_voter_count: eligibleVoters.length, + eligible_voters: eligibleVoters.map((row) => ({ + id: row.id, + full_name: row.full_name, + email: row.email, + role: row.role, + vote_value: row.vote_value || null, + voted_at: row.voted_at || null, + has_voted: Boolean(row.vote_value), + })), + current_user_vote: ownVoteResult.rows[0]?.vote_value || null, + current_user_voted_at: ownVoteResult.rows[0]?.updated_at || null, + current_user_can_vote: canVote && context.meeting_status === "gestartet", + can_manage: canManage, + }; +} + +router.get("/meta", async (req, res, next) => { + try { + const data = await withDbSession(req.user, async (db) => { + const [agendaItems, users] = await Promise.all([ + db.query( + `SELECT ai.id, + ai.agenda_number, + ai.title, + ai.meeting_id, + ai.case_id, + m.committee_id, + m.meeting_number, + m.status AS meeting_status, + c.case_number + FROM meeting_agenda_item ai + JOIN meeting m ON m.id = ai.meeting_id + LEFT JOIN br_case c ON c.id = ai.case_id + WHERE ai.meeting_id IS NOT NULL + AND m.status = 'gestartet' + ORDER BY m.meeting_date DESC, + COALESCE(NULLIF(regexp_replace(COALESCE(ai.agenda_number, ''), '[^0-9]', '', 'g'), ''), '999999')::int, + ai.id` + ), + db.query(`SELECT u.id, + u.full_name, + u.email, + u.role, + u.committee_id, + COALESCE(ARRAY_AGG(cm.committee_id ORDER BY cm.committee_id) FILTER (WHERE cm.committee_id IS NOT NULL), ARRAY[]::BIGINT[]) AS committee_ids + FROM app_user u + LEFT JOIN committee_member cm ON cm.user_id = u.id + WHERE u.is_active = TRUE + GROUP BY u.id + ORDER BY u.full_name`), + ]); + + return { agendaItems: agendaItems.rows, users: users.rows }; + }); + + res.json(data); + } catch (err) { + next(err); + } +}); + +router.get("/", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, (db) => + getDecisionList(db, { includeCompleted: req.query.include_completed }) + ); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/:id/voting", async (req, res, next) => { + try { + const state = await withDbSession(req.user, (db) => getDecisionVotingState(db, req.params.id, req.user.sub)); + if (!state) return res.status(404).json({ error: "Beschluss nicht gefunden" }); + res.json(state); + } catch (err) { + next(err); + } +}); + +router.post( + "/:id/voting/start", + requireMfa, + requirePermission("decisions.vote.manage", ["admin", "chair", "secretary"]), + async (req, res, next) => { + try { + const state = await withDbSession(req.user, async (db) => { + const context = await getVotingContext(db, req.params.id); + if (!context) return null; + if (!context.meeting_id) { + throw Object.assign(new Error("Für eine Abstimmung muss der Beschluss einer Sitzung zugeordnet sein."), { + status: 400, + publicMessage: "Für eine Abstimmung muss der Beschluss einer Sitzung zugeordnet sein.", + }); + } + if (!context.committee_id) { + throw Object.assign(new Error("Die Sitzung hat kein Gremium/Ausschuss zugeordnet."), { + status: 400, + publicMessage: "Die Sitzung hat kein Gremium/Ausschuss zugeordnet.", + }); + } + if (context.meeting_status !== "gestartet") { + throw Object.assign(new Error("Abstimmungen sind nur in gestarteten Sitzungen möglich."), { + status: 400, + publicMessage: "Abstimmungen sind nur in gestarteten Sitzungen möglich.", + }); + } + + await db.query( + `UPDATE decision + SET voting_open = TRUE, + voting_started_at = COALESCE(voting_started_at, NOW()), + voting_started_by = COALESCE(voting_started_by, $2), + voting_closed_at = NULL, + voting_closed_by = NULL, + updated_by = $2, + updated_at = NOW() + WHERE id = $1`, + [req.params.id, req.user.sub] + ); + + return getDecisionVotingState(db, req.params.id, req.user.sub); + }); + + if (!state) return res.status(404).json({ error: "Beschluss nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "DECISION_VOTING_STARTED", + entityType: "decision", + entityId: Number(req.params.id), + ipAddress: req.ip, + }); + + res.json(state); + } catch (err) { + next(err); + } + } +); + +router.post( + "/:id/voting/close", + requireMfa, + requirePermission("decisions.vote.manage", ["admin", "chair", "secretary"]), + async (req, res, next) => { + try { + const state = await withDbSession(req.user, async (db) => { + const context = await getVotingContext(db, req.params.id); + if (!context) return null; + + await synchronizeDecisionVoteCounts(db, req.params.id); + await db.query( + `UPDATE decision + SET voting_open = FALSE, + voting_closed_at = NOW(), + voting_closed_by = $2, + updated_by = $2, + updated_at = NOW() + WHERE id = $1`, + [req.params.id, req.user.sub] + ); + + return getDecisionVotingState(db, req.params.id, req.user.sub); + }); + + if (!state) return res.status(404).json({ error: "Beschluss nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "DECISION_VOTING_CLOSED", + entityType: "decision", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { counts: state.counts }, + }); + + res.json(state); + } catch (err) { + next(err); + } + } +); + +router.put("/:id/vote", validate(z.object({ vote_value: voteValueSchema })), async (req, res, next) => { + try { + const state = await withDbSession(req.user, async (db) => { + const context = await getVotingContext(db, req.params.id); + if (!context) return null; + if (!context.voting_open) { + throw Object.assign(new Error("Die Abstimmung ist nicht geöffnet."), { + status: 400, + publicMessage: "Die Abstimmung ist nicht geöffnet.", + }); + } + if (context.meeting_status !== "gestartet") { + throw Object.assign(new Error("Abstimmungen sind nur in gestarteten Sitzungen möglich."), { + status: 400, + publicMessage: "Abstimmungen sind nur in gestarteten Sitzungen möglich.", + }); + } + if (!context.committee_id) { + throw Object.assign(new Error("Die Sitzung hat kein Gremium/Ausschuss zugeordnet."), { + status: 400, + publicMessage: "Die Sitzung hat kein Gremium/Ausschuss zugeordnet.", + }); + } + + const canVote = await canUserVoteOnDecision(db, req.params.id, req.user.sub); + if (!canVote) { + throw Object.assign(new Error("Keine Stimmberechtigung für dieses Gremium."), { + status: 403, + publicMessage: "Keine Stimmberechtigung für dieses Gremium.", + }); + } + + await db.query( + `INSERT INTO decision_vote(decision_id, user_id, vote_value) + VALUES ($1, $2, $3) + ON CONFLICT (decision_id, user_id) + DO UPDATE SET vote_value = EXCLUDED.vote_value, + updated_at = NOW()`, + [req.params.id, req.user.sub, req.body.vote_value] + ); + + await synchronizeDecisionVoteCounts(db, req.params.id); + return getDecisionVotingState(db, req.params.id, req.user.sub); + }); + + if (!state) return res.status(404).json({ error: "Beschluss nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "DECISION_VOTE_CAST", + entityType: "decision", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { vote_value: req.body.vote_value }, + }); + + res.json(state); + } catch (err) { + next(err); + } +}); + +router.get("/:id", async (req, res, next) => { + try { + const row = await withDbSession(req.user, (db) => getDecisionById(db, req.params.id)); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.post( + "/", + requireMfa, + requirePermission("decisions.write", ["admin", "chair", "secretary", "member"]), + validate(createSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + decision_number: normalizeOptionalText(req.body.decision_number), + title: req.body.title.trim(), + decision_text: req.body.decision_text.trim(), + meeting_id: normalizeNullableNumber(req.body.meeting_id), + agenda_item_id: normalizeNullableNumber(req.body.agenda_item_id), + case_id: normalizeNullableNumber(req.body.case_id), + responsible_user_id: normalizeNullableNumber(req.body.responsible_user_id), + due_date: normalizeNullableText(req.body.due_date), + }; + + const becameRueckfrage = payload.status === "rueckfrage"; + + const row = await withDbSession(req.user, async (db) => { + await assertResponsibleUser(db, payload.responsible_user_id); + + if (payload.agenda_item_id) { + const agenda = await deriveFromAgendaItem(db, payload.agenda_item_id); + payload.meeting_id = Number(agenda.meeting_id); + payload.case_id = agenda.case_id ? Number(agenda.case_id) : null; + } + + const result = await db.query( + `INSERT INTO decision( + decision_number, + meeting_id, + agenda_item_id, + case_id, + title, + decision_text, + status, + votes_yes, + votes_no, + votes_abstain, + responsible_user_id, + due_date, + created_by, + updated_by + ) VALUES ( + COALESCE(NULLIF($1, ''), next_decision_number()), + $2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$13 + ) RETURNING id`, + [ + payload.decision_number ?? "", + payload.meeting_id, + payload.agenda_item_id, + payload.case_id, + payload.title, + payload.decision_text, + payload.status, + payload.votes_yes ?? 0, + payload.votes_no ?? 0, + payload.votes_abstain ?? 0, + payload.responsible_user_id, + payload.due_date, + req.user.sub, + ] + ); + + const row = await getDecisionById(db, result.rows[0].id); + if (becameRueckfrage) { + await applyRueckfrageWorkflow(db, row, req.user.sub); + } + + return getDecisionById(db, result.rows[0].id); + }); + + if (becameRueckfrage) { + sendRueckfrageMail(row).catch((error) => { + console.error("[mail] rueckfrage email failed", error); + }); + } + + await writeAudit({ + actorId: req.user.sub, + action: "DECISION_CREATED", + entityType: "decision", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload }, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +router.put( + "/:id", + requireMfa, + requirePermission("decisions.write", ["admin", "chair", "secretary", "member"]), + validate(updateSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + decision_number: normalizeOptionalText(req.body.decision_number), + title: req.body.title !== undefined ? req.body.title.trim() : undefined, + decision_text: req.body.decision_text !== undefined ? req.body.decision_text.trim() : undefined, + meeting_id: normalizeNullableNumber(req.body.meeting_id), + agenda_item_id: normalizeNullableNumber(req.body.agenda_item_id), + case_id: normalizeNullableNumber(req.body.case_id), + responsible_user_id: normalizeNullableNumber(req.body.responsible_user_id), + due_date: normalizeNullableText(req.body.due_date), + }; + + let becameRueckfrage = false; + + const row = await withDbSession(req.user, async (db) => { + const existing = await getDecisionById(db, req.params.id); + if (!existing) return null; + + const nextStatus = payload.status ?? existing.status; + becameRueckfrage = nextStatus === "rueckfrage" && existing.status !== "rueckfrage"; + + await assertResponsibleUser(db, payload.responsible_user_id); + + if (payload.agenda_item_id) { + const agenda = await deriveFromAgendaItem(db, payload.agenda_item_id); + payload.meeting_id = Number(agenda.meeting_id); + payload.case_id = agenda.case_id ? Number(agenda.case_id) : null; + } + + const fields = []; + const values = []; + let idx = 1; + + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + for (const [column, value] of [ + ["decision_number", payload.decision_number], + ["meeting_id", payload.meeting_id], + ["agenda_item_id", payload.agenda_item_id], + ["case_id", payload.case_id], + ["title", payload.title], + ["decision_text", payload.decision_text], + ["status", payload.status], + ["votes_yes", payload.votes_yes], + ["votes_no", payload.votes_no], + ["votes_abstain", payload.votes_abstain], + ["responsible_user_id", payload.responsible_user_id], + ["due_date", payload.due_date], + ]) { + if (value !== undefined) push(column, value); + } + + if (!fields.length) { + throw Object.assign(new Error("Keine Felder zum Aktualisieren"), { + status: 400, + publicMessage: "Keine Felder zum Aktualisieren", + }); + } + + push("updated_by", req.user.sub); + values.push(req.params.id); + + await db.query(`UPDATE decision SET ${fields.join(", ")} WHERE id = $${idx} RETURNING id`, values); + let row = await getDecisionById(db, req.params.id); + + if (becameRueckfrage) { + await applyRueckfrageWorkflow(db, row, req.user.sub); + row = await getDecisionById(db, req.params.id); + } + + return row; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + if (becameRueckfrage) { + sendRueckfrageMail(row).catch((error) => { + console.error("[mail] rueckfrage email failed", error); + }); + } + + await writeAudit({ + actorId: req.user.sub, + action: "DECISION_UPDATED", + entityType: "decision", + entityId: row.id, + ipAddress: req.ip, + metadata: { changes: payload }, + }); + + res.json(row); + } catch (err) { + next(err); + } + } +); + +router.delete("/:id", requireMfa, requirePermission("decisions.write", ["admin", "chair", "secretary", "member"]), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getDecisionById(db, req.params.id); + if (!existing) return null; + await db.query(`DELETE FROM decision WHERE id = $1`, [req.params.id]); + return existing; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "DECISION_DELETED", + entityType: "decision", + entityId: row.id, + ipAddress: req.ip, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/modules/departments/routes.js b/backend/src/modules/departments/routes.js new file mode 100644 index 0000000..58ca66f --- /dev/null +++ b/backend/src/modules/departments/routes.js @@ -0,0 +1,276 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { writeAudit } from "../../utils/audit.js"; + +const router = express.Router(); +router.use(authenticate); + +const departmentSchema = z.object({ + code: z.string().min(1).max(80), + label: z.string().min(2).max(120), + description: z.string().max(2000).optional().nullable(), + sort_order: z.coerce.number().int().min(0).max(999999).optional().default(1000), + is_active: z.boolean().optional().default(true), +}); + +const updateDepartmentSchema = departmentSchema.partial(); + +function normalizeCode(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 80); +} + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function mapDepartment(row) { + if (!row) return null; + return { + id: row.id, + code: row.code, + label: row.label, + description: row.description, + sort_order: row.sort_order, + is_active: row.is_active, + usage_count: row.usage_count ?? 0, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +async function getDepartmentById(db, id) { + const result = await db.query( + `SELECT d.id, + d.code, + d.label, + d.description, + d.sort_order, + d.is_active, + d.created_at, + d.updated_at, + (SELECT COUNT(*)::int FROM br_case c WHERE c.department = d.code AND c.deleted_at IS NULL) AS usage_count + FROM department_catalog d + WHERE d.id = $1`, + [id] + ); + return mapDepartment(result.rows[0]); +} + +router.get("/", async (req, res, next) => { + try { + const activeOnly = String(req.query.active || "").toLowerCase() === "true"; + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query( + `SELECT d.id, + d.code, + d.label, + d.description, + d.sort_order, + d.is_active, + d.created_at, + d.updated_at, + (SELECT COUNT(*)::int FROM br_case c WHERE c.department = d.code AND c.deleted_at IS NULL) AS usage_count + FROM department_catalog d + WHERE ($1::boolean = FALSE OR d.is_active = TRUE) + ORDER BY d.sort_order, d.label`, + [activeOnly] + ); + return result.rows.map(mapDepartment); + }); + + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.post( + "/", + requireMfa, + requirePermission("departments.manage", ["admin", "chair", "secretary"]), + validate(departmentSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + code: normalizeCode(req.body.code || req.body.label), + label: String(req.body.label || "").trim(), + description: normalizeNullableText(req.body.description), + sort_order: Number(req.body.sort_order ?? 1000), + is_active: req.body.is_active !== false, + }; + + if (!payload.code) { + return res.status(400).json({ error: "Code ist erforderlich" }); + } + + const row = await withDbSession(req.user, async (db) => { + const inserted = await db.query( + `INSERT INTO department_catalog(code, label, description, sort_order, is_active, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $6) + RETURNING id`, + [payload.code, payload.label, payload.description, payload.sort_order, payload.is_active, req.user.sub] + ); + return getDepartmentById(db, inserted.rows[0].id); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "DEPARTMENT_CREATED", + entityType: "department", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload }, + }); + + res.status(201).json(row); + } catch (err) { + if (err?.code === "23505") { + err.status = 409; + err.publicMessage = "Eine Abteilung mit diesem Code existiert bereits."; + } + next(err); + } + } +); + +router.put( + "/:id", + requireMfa, + requirePermission("departments.manage", ["admin", "chair", "secretary"]), + validate(updateDepartmentSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + code: req.body.code === undefined ? undefined : normalizeCode(req.body.code || req.body.label), + label: req.body.label === undefined ? undefined : String(req.body.label || "").trim(), + description: normalizeNullableText(req.body.description), + sort_order: req.body.sort_order === undefined ? undefined : Number(req.body.sort_order ?? 1000), + is_active: req.body.is_active, + }; + + if (payload.code === "") { + return res.status(400).json({ error: "Code ist erforderlich" }); + } + if (payload.label === "") { + return res.status(400).json({ error: "Bezeichnung ist erforderlich" }); + } + + const row = await withDbSession(req.user, async (db) => { + const existing = await getDepartmentById(db, req.params.id); + if (!existing) return null; + + const fields = []; + const values = []; + let idx = 1; + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + for (const [column, value] of [ + ["code", payload.code], + ["label", payload.label], + ["description", payload.description], + ["sort_order", payload.sort_order], + ["is_active", payload.is_active], + ]) { + if (value !== undefined) push(column, value); + } + + if (!fields.length) return existing; + push("updated_by", req.user.sub); + values.push(req.params.id); + + const oldCode = existing.code; + await db.query(`UPDATE department_catalog SET ${fields.join(", ")} WHERE id = $${idx}`, values); + + if (payload.code && payload.code !== oldCode) { + await db.query(`UPDATE br_case SET department = $1 WHERE department = $2`, [payload.code, oldCode]); + } + + return getDepartmentById(db, req.params.id); + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "DEPARTMENT_UPDATED", + entityType: "department", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload }, + }); + + res.json(row); + } catch (err) { + if (err?.code === "23505") { + err.status = 409; + err.publicMessage = "Eine Abteilung mit diesem Code existiert bereits."; + } + next(err); + } + } +); + +router.delete( + "/:id", + requireMfa, + requirePermission("departments.manage", ["admin", "chair", "secretary"]), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getDepartmentById(db, req.params.id); + if (!existing) return null; + + if (Number(existing.usage_count || 0) > 0) { + await db.query( + `UPDATE department_catalog + SET is_active = FALSE, updated_by = $2 + WHERE id = $1`, + [req.params.id, req.user.sub] + ); + } else { + await db.query(`DELETE FROM department_catalog WHERE id = $1`, [req.params.id]); + } + + return existing; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "DEPARTMENT_DELETED", + entityType: "department", + entityId: row.id, + ipAddress: req.ip, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } + } +); + +export default router; diff --git a/backend/src/modules/document-categories/routes.js b/backend/src/modules/document-categories/routes.js new file mode 100644 index 0000000..5a0609f --- /dev/null +++ b/backend/src/modules/document-categories/routes.js @@ -0,0 +1,264 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { writeAudit } from "../../utils/audit.js"; + +const router = express.Router(); +router.use(authenticate); + +const categorySchema = z.object({ + code: z.string().min(1).max(80), + label: z.string().min(2).max(120), + description: z.string().max(2000).optional().nullable(), + sort_order: z.coerce.number().int().min(0).max(999999).optional().default(1000), + is_active: z.boolean().optional().default(true), +}); + +const updateCategorySchema = categorySchema.partial(); + +function normalizeCode(value) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 80); +} + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function mapCategory(row) { + if (!row) return null; + return { + id: row.id, + code: row.code, + label: row.label, + description: row.description, + sort_order: row.sort_order, + is_active: row.is_active, + usage_count: row.usage_count ?? 0, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +async function getCategoryById(db, id) { + const result = await db.query( + `SELECT c.id, + c.code, + c.label, + c.description, + c.sort_order, + c.is_active, + c.created_at, + c.updated_at, + (SELECT COUNT(*)::int FROM document_item d WHERE d.document_category_id = c.id AND d.deleted_at IS NULL) AS usage_count + FROM document_category_catalog c + WHERE c.id = $1`, + [id] + ); + return mapCategory(result.rows[0]); +} + +router.get("/", async (req, res, next) => { + try { + const activeOnly = String(req.query.active || "").toLowerCase() === "true"; + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query( + `SELECT c.id, + c.code, + c.label, + c.description, + c.sort_order, + c.is_active, + c.created_at, + c.updated_at, + (SELECT COUNT(*)::int FROM document_item d WHERE d.document_category_id = c.id AND d.deleted_at IS NULL) AS usage_count + FROM document_category_catalog c + WHERE ($1::boolean = FALSE OR c.is_active = TRUE) + ORDER BY c.sort_order, c.label`, + [activeOnly] + ); + return result.rows.map(mapCategory); + }); + + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.post( + "/", + requireMfa, + requirePermission("document_categories.manage", ["admin", "chair"]), + validate(categorySchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + code: normalizeCode(req.body.code || req.body.label), + label: String(req.body.label || "").trim(), + description: normalizeNullableText(req.body.description), + sort_order: Number(req.body.sort_order ?? 1000), + is_active: req.body.is_active !== false, + }; + + if (!payload.code) return res.status(400).json({ error: "Code ist erforderlich" }); + + const row = await withDbSession(req.user, async (db) => { + const inserted = await db.query( + `INSERT INTO document_category_catalog(code, label, description, sort_order, is_active, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $6) + RETURNING id`, + [payload.code, payload.label, payload.description, payload.sort_order, payload.is_active, req.user.sub] + ); + return getCategoryById(db, inserted.rows[0].id); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "DOCUMENT_CATEGORY_CREATED", + entityType: "document_category", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload }, + }); + + res.status(201).json(row); + } catch (err) { + if (err?.code === "23505") { + err.status = 409; + err.publicMessage = "Eine Dokumentenkategorie mit diesem Code existiert bereits."; + } + next(err); + } + } +); + +router.put( + "/:id", + requireMfa, + requirePermission("document_categories.manage", ["admin", "chair"]), + validate(updateCategorySchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + code: req.body.code === undefined ? undefined : normalizeCode(req.body.code || req.body.label), + label: req.body.label === undefined ? undefined : String(req.body.label || "").trim(), + description: normalizeNullableText(req.body.description), + sort_order: req.body.sort_order === undefined ? undefined : Number(req.body.sort_order ?? 1000), + is_active: req.body.is_active, + }; + + if (payload.code === "") return res.status(400).json({ error: "Code ist erforderlich" }); + if (payload.label === "") return res.status(400).json({ error: "Bezeichnung ist erforderlich" }); + + const row = await withDbSession(req.user, async (db) => { + const existing = await getCategoryById(db, req.params.id); + if (!existing) return null; + + const fields = []; + const values = []; + let idx = 1; + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + for (const [column, value] of [ + ["code", payload.code], + ["label", payload.label], + ["description", payload.description], + ["sort_order", payload.sort_order], + ["is_active", payload.is_active], + ]) { + if (value !== undefined) push(column, value); + } + + if (!fields.length) return existing; + push("updated_by", req.user.sub); + values.push(req.params.id); + + await db.query(`UPDATE document_category_catalog SET ${fields.join(", ")} WHERE id = $${idx}`, values); + return getCategoryById(db, req.params.id); + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "DOCUMENT_CATEGORY_UPDATED", + entityType: "document_category", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload }, + }); + + res.json(row); + } catch (err) { + if (err?.code === "23505") { + err.status = 409; + err.publicMessage = "Eine Dokumentenkategorie mit diesem Code existiert bereits."; + } + next(err); + } + } +); + +router.delete( + "/:id", + requireMfa, + requirePermission("document_categories.manage", ["admin", "chair"]), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getCategoryById(db, req.params.id); + if (!existing) return null; + + if (Number(existing.usage_count || 0) > 0) { + await db.query( + `UPDATE document_category_catalog + SET is_active = FALSE, updated_by = $2 + WHERE id = $1`, + [req.params.id, req.user.sub] + ); + } else { + await db.query(`DELETE FROM document_category_catalog WHERE id = $1`, [req.params.id]); + } + + return existing; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: Number(row.usage_count || 0) > 0 ? "DOCUMENT_CATEGORY_DEACTIVATED" : "DOCUMENT_CATEGORY_DELETED", + entityType: "document_category", + entityId: row.id, + ipAddress: req.ip, + }); + + res.json({ ok: true }); + } catch (err) { + next(err); + } + } +); + +export default router; diff --git a/backend/src/modules/documents/routes.js b/backend/src/modules/documents/routes.js new file mode 100644 index 0000000..04e2e56 --- /dev/null +++ b/backend/src/modules/documents/routes.js @@ -0,0 +1,897 @@ +import express from "express"; +import crypto from "crypto"; +import argon2 from "argon2"; +import jwt from "jsonwebtoken"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/rbac.js"; +import { query, withDbSession } from "../../config/db.js"; +import { upload } from "../../middleware/upload.js"; +import { scanBuffer } from "../../services/clamav.js"; +import { saveObject, getObjectStream, deleteObject } from "../../services/storage.js"; +import { writeAudit } from "../../utils/audit.js"; +import { fileTypeFromBuffer } from "file-type"; +import { v4 as uuidv4 } from "uuid"; +import { validate } from "../../middleware/validate.js"; +import { env } from "../../config/env.js"; + +export const publicDocumentsRouter = express.Router(); +const router = express.Router(); + +const PUBLIC_DOCUMENTS_DEFAULT_INTRO = "Vom Betriebsrat freigegebene Dokumente"; +const PUBLIC_DOCUMENTS_TOKEN_SCOPE = "public_documents"; +const PUBLIC_DOCUMENTS_TOKEN_EXPIRES_IN = "4h"; + +router.use(authenticate); + +const confEnum = z.enum(["oeffentlich", "intern", "vertraulich", "streng_vertraulich"]); +const nullablePositiveInt = z.preprocess( + (value) => { + if (value === "" || value === null || value === undefined) return undefined; + return value; + }, + z.coerce.number().int().positive().optional().nullable() +); + +const publicDocumentAccessSchema = z.object({ + password: z.string().min(1).max(200), +}); + +const publicDocumentSettingsSchema = z.object({ + enabled: z.boolean().optional(), + intro_text: z.string().max(8000).optional().nullable(), + access_password: z.string().max(200).optional().or(z.literal("")), + clear_password: z.boolean().optional(), +}); + +const updateSchema = z.object({ + document_number: z.string().min(1).max(50).optional(), + title: z.string().min(1).max(255).optional(), + document_type: z.string().min(1).max(50).optional(), + description: z.string().max(30000).optional().nullable(), + confidentiality_level: confEnum.optional(), + document_category_id: nullablePositiveInt, + meeting_id: nullablePositiveInt, + team_role_ids: z.array(z.coerce.number().int().positive()).optional(), +}); + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed || null; +} + +function normalizeNullableNumber(value) { + if (value === undefined) return undefined; + if (value === null || value === "") return null; + return Number(value); +} + +function generateDocumentNumber() { + return `D-${new Date().getFullYear()}-${Math.floor(Math.random() * 100000) + .toString() + .padStart(5, "0")}`; +} + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === "") return fallback; + if (typeof value === "boolean") return value; + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "ja", "on"].includes(normalized)) return true; + if (["0", "false", "no", "nein", "off"].includes(normalized)) return false; + return fallback; +} + +function parseTeamRoleIds(value) { + if (Array.isArray(value)) return value.map(Number).filter(Boolean); + if (value === undefined || value === null || value === "") return []; + try { + const parsed = JSON.parse(String(value)); + if (Array.isArray(parsed)) return parsed.map(Number).filter(Boolean); + } catch { + // Fallback: comma-separated form value. + } + return String(value) + .split(",") + .map((item) => Number(item.trim())) + .filter(Boolean); +} + +function signPublicDocumentToken() { + return jwt.sign( + { scope: PUBLIC_DOCUMENTS_TOKEN_SCOPE, setting_id: 1 }, + env.jwtAccessSecret, + { expiresIn: PUBLIC_DOCUMENTS_TOKEN_EXPIRES_IN } + ); +} + +function verifyPublicDocumentToken(token) { + try { + const decoded = jwt.verify(token, env.jwtAccessSecret); + return decoded?.scope === PUBLIC_DOCUMENTS_TOKEN_SCOPE && Number(decoded?.setting_id) === 1; + } catch { + return false; + } +} + +function getPublicAccessToken(req) { + const auth = req.headers.authorization || ""; + if (auth.toLowerCase().startsWith("bearer ")) return auth.slice(7).trim(); + return String(req.query.access_token || req.body?.public_access_token || "").trim(); +} + +function mapCategory(row) { + if (!row) return null; + return { + id: row.id, + code: row.code, + label: row.label, + description: row.description, + sort_order: row.sort_order, + is_active: row.is_active, + }; +} + +async function getPublicDocumentSetting(db = null) { + const executor = db || { query }; + const result = await executor.query( + `SELECT id, enabled, intro_text, access_password_hash, updated_at + FROM public_document_setting + WHERE id = 1` + ); + return result.rows[0] || { enabled: false, intro_text: PUBLIC_DOCUMENTS_DEFAULT_INTRO, access_password_hash: null }; +} + +function mapPublicDocument(row) { + if (!row) return null; + return { + id: row.id, + document_number: row.document_number, + title: row.title, + description: row.description, + document_type: row.document_type, + category_label: row.category_label, + file_name: row.file_name, + uploaded_at: row.uploaded_at, + }; +} + +async function sendDocumentFile(row, res, disposition = "attachment") { + const { stream, contentType } = await getObjectStream(row.storage_path); + const safeFilename = encodeURIComponent(row.file_name || row.title || `dokument-${row.id}`); + res.setHeader("Content-Type", contentType || row.mime_type || "application/octet-stream"); + res.setHeader("Content-Disposition", `${disposition}; filename="${safeFilename}"`); + res.setHeader("Cache-Control", "no-store"); + stream.pipe(res); +} + +publicDocumentsRouter.get("/status", async (req, res, next) => { + try { + const setting = await getPublicDocumentSetting(); + const enabled = Boolean(setting?.enabled && setting?.access_password_hash); + const authenticated = enabled && verifyPublicDocumentToken(getPublicAccessToken(req)); + + const documents = authenticated + ? await query( + `SELECT d.id, + d.document_number, + d.title, + d.description, + d.document_type, + d.file_name, + d.uploaded_at, + dc.label AS category_label + FROM document_item d + LEFT JOIN document_category_catalog dc ON dc.id = d.document_category_id + WHERE d.deleted_at IS NULL + AND d.scan_status = 'clean' + AND d.confidentiality_level = 'oeffentlich' + ORDER BY COALESCE(dc.sort_order, 999999), d.uploaded_at DESC, d.id DESC` + ).then((result) => result.rows.map(mapPublicDocument)) + : []; + + res.json({ + enabled, + password_required: true, + authenticated, + intro_text: setting?.intro_text || PUBLIC_DOCUMENTS_DEFAULT_INTRO, + documents, + }); + } catch (err) { + next(err); + } +}); + +publicDocumentsRouter.post("/access", validate(publicDocumentAccessSchema), async (req, res, next) => { + try { + const setting = await getPublicDocumentSetting(); + if (!setting?.enabled) { + return res.status(404).json({ error: "Die öffentliche Dokumentenseite ist derzeit nicht freigeschaltet." }); + } + if (!setting.access_password_hash) { + return res.status(403).json({ error: "Für die öffentliche Dokumentenseite ist noch kein Kennwort gesetzt." }); + } + + const ok = await argon2.verify(setting.access_password_hash, req.body.password); + if (!ok) { + return res.status(401).json({ error: "Kennwort ist nicht korrekt." }); + } + + res.json({ accessToken: signPublicDocumentToken(), expiresIn: PUBLIC_DOCUMENTS_TOKEN_EXPIRES_IN }); + } catch (err) { + next(err); + } +}); + +async function streamPublicDocument(req, res, next, disposition = "attachment") { + try { + const setting = await getPublicDocumentSetting(); + if (!setting?.enabled || !setting?.access_password_hash) { + return res.status(404).json({ error: "Die öffentliche Dokumentenseite ist derzeit nicht freigeschaltet." }); + } + if (!verifyPublicDocumentToken(getPublicAccessToken(req))) { + return res.status(401).json({ error: "Zugriff abgelaufen. Bitte Kennwort erneut eingeben." }); + } + + const result = await query( + `SELECT id, title, file_name, storage_path, mime_type, scan_status + FROM document_item + WHERE id = $1::bigint + AND deleted_at IS NULL + AND scan_status = 'clean' + AND confidentiality_level = 'oeffentlich'`, + [req.params.id] + ); + const row = result.rows[0]; + if (!row) return res.status(404).json({ error: "Dokument nicht gefunden" }); + + await writeAudit({ + actorId: null, + action: disposition === "inline" ? "PUBLIC_DOCUMENT_VIEWED" : "PUBLIC_DOCUMENT_DOWNLOADED", + entityType: "document", + entityId: row.id, + ipAddress: req.ip, + }); + + await sendDocumentFile(row, res, disposition); + } catch (err) { + next(err); + } +} + +publicDocumentsRouter.get("/:id/view", async (req, res, next) => { + await streamPublicDocument(req, res, next, "inline"); +}); + +publicDocumentsRouter.get("/:id/download", async (req, res, next) => { + await streamPublicDocument(req, res, next, "attachment"); +}); + +async function syncDocumentTeamAccess(db, documentId, teamRoleIds, actorId) { + if (!Array.isArray(teamRoleIds)) return; + await db.query(`DELETE FROM document_team_access WHERE document_id = $1`, [documentId]); + if (!teamRoleIds.length) return; + + await db.query( + `INSERT INTO document_team_access(document_id, team_role_id, can_read, can_write, created_by) + SELECT $1::bigint, unnest($2::bigint[]), TRUE, FALSE, $3::bigint + ON CONFLICT (document_id, team_role_id) DO UPDATE SET can_read = TRUE`, + [documentId, teamRoleIds.map(Number), actorId] + ); +} + +async function getBaseDocumentForVersioning(db, requestedId) { + if (!requestedId) return null; + + const result = await db.query( + `SELECT id, + document_number, + title, + document_type, + description, + confidentiality_level, + document_category_id, + meeting_id, + COALESCE(version_group_id, id) AS version_root_id + FROM document_item + WHERE id = $1::bigint + AND deleted_at IS NULL`, + [requestedId] + ); + + return result.rows[0] || null; +} + +async function getNextVersionNumber(db, versionRootId) { + const result = await db.query( + `SELECT COALESCE(MAX(COALESCE(version_number, 1)), 0) + 1 AS next_version + FROM document_item + WHERE COALESCE(version_group_id, id) = $1::bigint`, + [versionRootId] + ); + + return Number(result.rows[0]?.next_version || 1); +} + +router.get("/meta", async (req, res, next) => { + try { + const data = await withDbSession(req.user, async (db) => { + const [activeMeetings, teamRoles, categories] = await Promise.all([ + db.query( + `SELECT id, meeting_number, title, meeting_date + FROM meeting + WHERE status = 'gestartet' + ORDER BY meeting_date DESC, created_at DESC` + ), + db.query( + `SELECT id, code, name, can_view_cases, can_write_cases, can_view_documents, can_write_documents + FROM app_team_role + WHERE is_active = TRUE + ORDER BY name` + ), + db.query( + `SELECT id, code, label, description, sort_order, is_active + FROM document_category_catalog + WHERE is_active = TRUE + ORDER BY sort_order, label` + ), + ]); + + return { + activeMeeting: activeMeetings.rows.length === 1 ? activeMeetings.rows[0] : null, + teamRoles: teamRoles.rows, + categories: categories.rows.map(mapCategory), + }; + }); + + res.json(data); + } catch (err) { + next(err); + } +}); + +router.get("/", async (req, res, next) => { + try { + const internalOnly = parseBoolean(req.query.internal_only, false); + + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query( + `WITH ranked_documents AS ( + SELECT d.id, + d.document_number, + d.title, + d.document_type, + d.description, + d.version_label, + COALESCE(d.version_number, 1) AS version_number, + COALESCE(d.version_group_id, d.id) AS version_root_id, + COUNT(*) OVER (PARTITION BY COALESCE(d.version_group_id, d.id))::int AS version_count, + d.confidentiality_level, + d.document_category_id, + dc.code AS category_code, + dc.label AS category_label, + d.case_id, + d.meeting_id, + d.decision_id, + d.storage_provider, + d.scan_status, + d.uploaded_at, + d.file_name, + m.meeting_number, + uploader.full_name AS uploaded_by_name, + uploader.email AS uploaded_by_email, + COALESCE(( + SELECT ARRAY_AGG(dta.team_role_id ORDER BY dta.team_role_id) + FROM document_team_access dta + WHERE dta.document_id = d.id + AND dta.can_read = TRUE + ), ARRAY[]::BIGINT[]) AS team_role_ids, + ROW_NUMBER() OVER ( + PARTITION BY COALESCE(d.version_group_id, d.id) + ORDER BY COALESCE(d.version_number, 1) DESC, d.uploaded_at DESC, d.id DESC + ) AS version_rank + FROM document_item d + LEFT JOIN meeting m ON m.id = d.meeting_id + LEFT JOIN document_category_catalog dc ON dc.id = d.document_category_id + LEFT JOIN app_user uploader ON uploader.id = d.uploaded_by + WHERE d.deleted_at IS NULL + AND ($1::boolean = FALSE OR (d.case_id IS NULL AND d.decision_id IS NULL)) + ) + SELECT id, + document_number, + title, + document_type, + description, + version_label, + version_number, + version_root_id, + version_count, + confidentiality_level, + document_category_id, + category_code, + category_label, + case_id, + meeting_id, + decision_id, + storage_provider, + scan_status, + uploaded_at, + file_name, + meeting_number, + uploaded_by_name, + uploaded_by_email, + team_role_ids + FROM ranked_documents + WHERE version_rank = 1 + ORDER BY uploaded_at DESC, id DESC`, + [internalOnly] + ); + return result.rows; + }); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/:id/versions", async (req, res, next) => { + try { + const payload = await withDbSession(req.user, async (db) => { + const current = await db.query( + `SELECT id, + document_number, + title, + COALESCE(version_group_id, id) AS version_root_id + FROM document_item + WHERE id = $1::bigint + AND deleted_at IS NULL`, + [req.params.id] + ); + + const row = current.rows[0]; + if (!row) return null; + + const versions = await db.query( + `SELECT d.id, + d.document_number, + d.title, + d.document_type, + d.description, + d.version_label, + COALESCE(d.version_number, 1) AS version_number, + d.file_name, + d.mime_type, + d.size_bytes, + d.scan_status, + d.uploaded_at, + d.meeting_id, + m.meeting_number, + uploader.full_name AS uploaded_by_name, + uploader.email AS uploaded_by_email + FROM document_item d + LEFT JOIN meeting m ON m.id = d.meeting_id + LEFT JOIN app_user uploader ON uploader.id = d.uploaded_by + WHERE COALESCE(d.version_group_id, d.id) = $1::bigint + AND d.deleted_at IS NULL + ORDER BY COALESCE(d.version_number, 1) DESC, d.uploaded_at DESC, d.id DESC`, + [row.version_root_id] + ); + + return { + document_number: row.document_number, + title: row.title, + version_root_id: row.version_root_id, + items: versions.rows, + }; + }); + + if (!payload) return res.status(404).json({ error: "Dokument nicht gefunden" }); + res.json(payload); + } catch (err) { + next(err); + } +}); + +router.post( + "/upload", + requireMfa, + requirePermission("documents.write", ["admin", "chair", "secretary", "member"]), + upload.single("file"), + async (req, res, next) => { + try { + if (!req.file) return res.status(400).json({ error: "Datei fehlt" }); + + const mime = await fileTypeFromBuffer(req.file.buffer); + const detectedMime = mime?.mime || req.file.mimetype || "application/octet-stream"; + const sha256 = crypto.createHash("sha256").update(req.file.buffer).digest("hex"); + + const scan = await scanBuffer(req.file.buffer); + const storageKey = `${new Date().toISOString().slice(0, 10)}/${uuidv4()}-${req.file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_")}`; + + const docMeta = await saveObject(storageKey, req.file.buffer, detectedMime); + const versionOfId = normalizeNullableNumber(req.body.version_of_id); + const teamRoleIds = parseTeamRoleIds(req.body.team_role_ids); + + const result = await withDbSession(req.user, async (db) => { + let baseDocument = null; + let documentNumber = normalizeNullableText(req.body.document_number) || generateDocumentNumber(); + let versionNumber = 1; + let versionLabel = "v1"; + let versionRootId = null; + + if (versionOfId) { + baseDocument = await getBaseDocumentForVersioning(db, versionOfId); + if (!baseDocument) { + throw Object.assign(new Error("Basisdokument für Versionierung nicht gefunden"), { + status: 400, + publicMessage: "Basisdokument für Versionierung nicht gefunden", + }); + } + + versionRootId = Number(baseDocument.version_root_id || baseDocument.id); + versionNumber = await getNextVersionNumber(db, versionRootId); + versionLabel = `v${versionNumber}`; + documentNumber = baseDocument.document_number; + } + + const insert = await db.query( + `INSERT INTO document_item( + document_number, title, document_type, description, version_label, version_number, version_group_id, + file_name, storage_path, storage_provider, mime_type, size_bytes, sha256_hash, + confidentiality_level, document_category_id, case_id, meeting_id, decision_id, uploaded_by, + scan_status, scan_engine, scan_result, uploaded_at + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,NOW() + ) RETURNING *`, + [ + documentNumber, + normalizeNullableText(req.body.title) || baseDocument?.title || req.file.originalname, + normalizeNullableText(req.body.document_type) || baseDocument?.document_type || "intern", + normalizeNullableText(req.body.description) ?? baseDocument?.description ?? null, + versionLabel, + versionNumber, + versionRootId, + req.file.originalname, + docMeta.storage_path, + docMeta.storage_provider, + detectedMime, + req.file.size, + sha256, + req.body.confidentiality_level || baseDocument?.confidentiality_level || "intern", + normalizeNullableNumber(req.body.document_category_id) ?? baseDocument?.document_category_id ?? null, + req.body.case_id || null, + normalizeNullableNumber(req.body.meeting_id) ?? baseDocument?.meeting_id ?? null, + req.body.decision_id || null, + req.user.sub, + scan.clean ? "clean" : "infected", + "clamav", + scan.raw, + ] + ); + + const inserted = insert.rows[0]; + await syncDocumentTeamAccess(db, inserted.id, teamRoleIds, req.user.sub); + + if (!versionRootId) { + await db.query( + `UPDATE document_item + SET version_group_id = $2::bigint + WHERE id = $1::bigint`, + [inserted.id, inserted.id] + ); + inserted.version_group_id = inserted.id; + } + + await db.query( + `INSERT INTO document_scan_event(document_id, scanned_by_user_id, status, engine, result_text, scanned_at) + VALUES ($1,$2,$3,$4,$5,NOW())`, + [inserted.id, req.user.sub, scan.clean ? "clean" : "infected", "clamav", scan.raw] + ); + + return inserted; + }); + + await writeAudit({ + actorId: req.user.sub, + action: versionOfId ? "DOCUMENT_VERSION_UPLOADED" : "DOCUMENT_UPLOADED", + entityType: "document", + entityId: result.id, + metadata: { sha256, scan: scan.raw, storage_provider: docMeta.storage_provider, version_of_id: versionOfId, team_role_ids: teamRoleIds }, + ipAddress: req.ip, + }); + + if (!scan.clean) { + return res.status(422).json({ error: "Datei durch Virenscan abgelehnt", scan_result: scan.raw }); + } + + res.status(201).json(result); + } catch (err) { + next(err); + } + } +); + +router.get( + "/public-settings", + requirePermission("public_documents.configure", ["admin", "chair"]), + async (req, res, next) => { + try { + const data = await withDbSession(req.user, async (db) => { + const setting = await getPublicDocumentSetting(db); + const documentsResult = await db.query( + `SELECT d.id, + d.document_number, + d.title, + d.description, + d.document_type, + d.confidentiality_level, + d.scan_status, + d.file_name, + d.uploaded_at, + dc.label AS category_label + FROM document_item d + LEFT JOIN document_category_catalog dc ON dc.id = d.document_category_id + WHERE d.deleted_at IS NULL + AND d.confidentiality_level = 'oeffentlich' + ORDER BY COALESCE(dc.sort_order, 999999), d.uploaded_at DESC, d.id DESC` + ); + + return { + settings: { + enabled: Boolean(setting?.enabled), + password_set: Boolean(setting?.access_password_hash), + intro_text: setting?.intro_text || PUBLIC_DOCUMENTS_DEFAULT_INTRO, + updated_at: setting?.updated_at || null, + }, + public_path: "/public-documents", + documents: documentsResult.rows, + }; + }); + res.json(data); + } catch (err) { + next(err); + } + } +); + +router.put( + "/public-settings", + requireMfa, + requirePermission("public_documents.configure", ["admin", "chair"]), + validate(publicDocumentSettingsSchema), + async (req, res, next) => { + try { + const passwordInput = typeof req.body.access_password === "string" ? req.body.access_password.trim() : ""; + const payload = { + enabled: Boolean(req.body.enabled), + intro_text: normalizeNullableText(req.body.intro_text) || PUBLIC_DOCUMENTS_DEFAULT_INTRO, + access_password: passwordInput, + clear_password: Boolean(req.body.clear_password), + }; + + if (payload.access_password && payload.access_password.length < 4) { + throw Object.assign(new Error("Kennwort zu kurz"), { + status: 400, + publicMessage: "Das globale Kennwort muss mindestens 4 Zeichen lang sein.", + }); + } + + const data = await withDbSession(req.user, async (db) => { + const current = await getPublicDocumentSetting(db); + let passwordHash = current?.access_password_hash || null; + + if (payload.clear_password && !payload.access_password) { + passwordHash = null; + } + if (payload.access_password) { + passwordHash = await argon2.hash(payload.access_password); + } + + if (payload.enabled && !passwordHash) { + throw Object.assign(new Error("Kennwort erforderlich"), { + status: 400, + publicMessage: "Zum Einschalten der öffentlichen Dokumentenseite muss zuerst ein Kennwort eingegeben und gespeichert werden.", + }); + } + + await db.query( + `INSERT INTO public_document_setting(id, enabled, intro_text, access_password_hash, created_by, updated_by) + VALUES (1, $1, $2, $3, $4, $4) + ON CONFLICT (id) DO UPDATE + SET enabled = EXCLUDED.enabled, + intro_text = EXCLUDED.intro_text, + access_password_hash = EXCLUDED.access_password_hash, + updated_by = EXCLUDED.updated_by`, + [payload.enabled, payload.intro_text, passwordHash, req.user.sub] + ); + const setting = await getPublicDocumentSetting(db); + return { + settings: { + enabled: Boolean(setting?.enabled), + password_set: Boolean(setting?.access_password_hash), + intro_text: setting?.intro_text || PUBLIC_DOCUMENTS_DEFAULT_INTRO, + updated_at: setting?.updated_at || null, + }, + public_path: "/public-documents", + }; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "PUBLIC_DOCUMENT_SETTINGS_UPDATED", + entityType: "public_document_setting", + entityId: 1, + metadata: { enabled: data.settings.enabled, password_set: data.settings.password_set }, + ipAddress: req.ip, + }); + + res.json(data); + } catch (err) { + next(err); + } + } +); + +router.put( + "/:id", + requireMfa, + requirePermission("documents.write", ["admin", "chair", "secretary", "member"]), + validate(updateSchema), + async (req, res, next) => { + try { + const payload = { + document_number: normalizeNullableText(req.body.document_number), + title: normalizeNullableText(req.body.title), + document_type: normalizeNullableText(req.body.document_type), + description: normalizeNullableText(req.body.description), + confidentiality_level: req.body.confidentiality_level, + document_category_id: normalizeNullableNumber(req.body.document_category_id), + meeting_id: normalizeNullableNumber(req.body.meeting_id), + team_role_ids: Array.isArray(req.body.team_role_ids) ? req.body.team_role_ids.map(Number).filter(Boolean) : undefined, + }; + + const row = await withDbSession(req.user, async (db) => { + const current = await db.query( + `SELECT id, storage_path FROM document_item WHERE id = $1::bigint AND deleted_at IS NULL`, + [req.params.id] + ); + + if (!current.rows[0]) return null; + + const result = await db.query( + `UPDATE document_item + SET document_number = COALESCE($2, document_number), + title = COALESCE($3, title), + document_type = COALESCE($4, document_type), + description = $5, + confidentiality_level = COALESCE($6, confidentiality_level), + document_category_id = $7, + meeting_id = $8, + case_id = NULL, + decision_id = NULL, + updated_at = NOW() + WHERE id = $1::bigint + RETURNING id, document_number, title, document_type, description, version_label, COALESCE(version_number, 1) AS version_number, confidentiality_level, document_category_id, meeting_id, file_name, scan_status, uploaded_at`, + [ + req.params.id, + payload.document_number, + payload.title, + payload.document_type, + payload.description, + payload.confidentiality_level, + payload.document_category_id, + payload.meeting_id, + ] + ); + + const updated = result.rows[0]; + if (payload.team_role_ids !== undefined) { + await syncDocumentTeamAccess(db, req.params.id, payload.team_role_ids || [], req.user.sub); + } + return updated; + }); + + if (!row) return res.status(404).json({ error: "Dokument nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "DOCUMENT_UPDATED", + entityType: "document", + entityId: row.id, + metadata: { meeting_id: row.meeting_id, team_role_ids: payload.team_role_ids }, + ipAddress: req.ip, + }); + + res.json(row); + } catch (err) { + next(err); + } + } +); + + +router.delete("/:id", requireMfa, requirePermission("documents.write", ["admin", "chair", "secretary", "member"]), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const current = await db.query( + `SELECT id, storage_path, file_name + FROM document_item + WHERE id = $1::bigint AND deleted_at IS NULL`, + [req.params.id] + ); + + if (!current.rows[0]) return null; + + await db.query( + `UPDATE document_item + SET deleted_at = NOW(), + updated_at = NOW() + WHERE id = $1::bigint`, + [req.params.id] + ); + + return current.rows[0]; + }); + + if (!row) return res.status(404).json({ error: "Dokument nicht gefunden" }); + + await deleteObject(row.storage_path).catch(() => undefined); + + await writeAudit({ + actorId: req.user.sub, + action: "DOCUMENT_DELETED", + entityType: "document", + entityId: row.id, + metadata: { file_name: row.file_name }, + ipAddress: req.ip, + }); + + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +async function getDocumentForTransfer(user, documentId) { + return withDbSession(user, async (db) => { + const result = await db.query( + `SELECT id, title, file_name, storage_path, mime_type, scan_status + FROM document_item + WHERE id = $1::bigint AND deleted_at IS NULL`, + [documentId] + ); + return result.rows[0]; + }); +} + +async function streamDocument(req, res, next, disposition = "attachment") { + try { + const row = await getDocumentForTransfer(req.user, req.params.id); + + if (!row) return res.status(404).json({ error: "Dokument nicht gefunden" }); + if (row.scan_status !== "clean") return res.status(409).json({ error: "Dokument nicht freigegeben" }); + + await writeAudit({ + actorId: req.user.sub, + action: disposition === "inline" ? "DOCUMENT_VIEWED" : "DOCUMENT_DOWNLOADED", + entityType: "document", + entityId: row.id, + ipAddress: req.ip, + }); + + await sendDocumentFile(row, res, disposition); + } catch (err) { + next(err); + } +} + +router.get("/:id/download", requireMfa, async (req, res, next) => { + await streamDocument(req, res, next, "attachment"); +}); + +router.get("/:id/view", requireMfa, async (req, res, next) => { + await streamDocument(req, res, next, "inline"); +}); + +export default router; diff --git a/backend/src/modules/finance/routes.js b/backend/src/modules/finance/routes.js new file mode 100644 index 0000000..23dca34 --- /dev/null +++ b/backend/src/modules/finance/routes.js @@ -0,0 +1,937 @@ +import express from "express"; +import crypto from "crypto"; +import { z } from "zod"; +import { v4 as uuidv4 } from "uuid"; +import { fileTypeFromBuffer } from "file-type"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { upload } from "../../middleware/upload.js"; +import { withDbSession } from "../../config/db.js"; +import { scanBuffer } from "../../services/clamav.js"; +import { saveObject, getObjectStream } from "../../services/storage.js"; +import { writeAudit } from "../../utils/audit.js"; + +const router = express.Router(); +router.use(authenticate); +router.use(requirePermission("finance.read", ["admin", "chair"])); + +const nullablePositiveInt = z.preprocess( + (value) => { + if (value === "" || value === null || value === undefined) return undefined; + return value; + }, + z.coerce.number().int().positive().optional().nullable() +); + +const money = z.coerce.number().min(0).max(999999999999.99); +const taxRate = z.coerce.number().min(0).max(100); + +const projectSchema = z.object({ + project_code: z.string().max(50).optional().nullable(), + name: z.string().min(1).max(180), + description: z.string().max(30000).optional().nullable(), + status: z.enum(["aktiv", "pausiert", "abgeschlossen", "archiviert"]).optional().default("aktiv"), + calculation_mode: z.enum(["sum_only", "project_budget", "annual_budget"]).optional().default("sum_only"), + committee_id: nullablePositiveInt, + project_budget_amount: money.optional().nullable(), + annual_budget_amount: money.optional().nullable(), + annual_budget_year: z.coerce.number().int().min(2000).max(2100).optional().nullable(), +}); + +const expenseLineSchema = z.object({ + id: nullablePositiveInt, + description: z.string().max(255).optional().nullable(), + tax_code_id: nullablePositiveInt, + tax_code_key: z.string().max(160).optional().nullable(), + tax_rate_percent: taxRate.optional().nullable(), + net_amount: money, +}); + +const expenseSchema = z.object({ + project_id: z.coerce.number().int().positive(), + expense_date: z.string().min(10).max(10), + title: z.string().min(1).max(255), + vendor: z.string().max(180).optional().nullable(), + cost_category: z.string().max(120).optional().nullable(), + net_amount: money.optional().default(0), + tax_rate_percent: taxRate.optional().default(19), + currency: z.string().length(3).optional().default("EUR"), + payment_status: z.enum(["geplant", "offen", "bezahlt", "storniert"]).optional().default("offen"), + notes: z.string().max(30000).optional().nullable(), + lines: z.array(expenseLineSchema).optional().default([]), +}); + +const taxCodeSchema = z.object({ + code: z.string().min(1).max(160), + label: z.string().min(1).max(220), + tax_rate_percent: taxRate, + is_active: z.boolean().optional().default(true), + sort_order: z.coerce.number().int().min(0).max(999999).optional().default(1000), +}); + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed || null; +} + +function normalizeNullableNumber(value) { + if (value === undefined) return undefined; + if (value === null || value === "") return null; + return Number(value); +} + +function normalizeYear(value) { + const parsed = Number(value || new Date().getFullYear()); + if (!Number.isInteger(parsed) || parsed < 2000 || parsed > 2100) return new Date().getFullYear(); + return parsed; +} + +function normalizeTaxPercent(value) { + const raw = Number(value || 0); + // Akzeptiert sowohl 19 als auch 0.19, falls Daten aus einer externen Liste kommen. + const percent = raw > 0 && raw <= 1 ? raw * 100 : raw; + return Math.round(percent * 1000) / 1000; +} + +function calculateAmounts(netAmount, taxRatePercent) { + const net = Math.round(Number(netAmount || 0) * 100) / 100; + const rate = normalizeTaxPercent(taxRatePercent); + const tax = Math.round((net * rate / 100) * 100) / 100; + const gross = Math.round((net + tax) * 100) / 100; + return { net, rate, tax, gross }; +} + +function sumAmounts(lines) { + return lines.reduce( + (acc, line) => { + acc.net = Math.round((acc.net + Number(line.net_amount || 0)) * 100) / 100; + acc.tax = Math.round((acc.tax + Number(line.tax_amount || 0)) * 100) / 100; + acc.gross = Math.round((acc.gross + Number(line.gross_amount || 0)) * 100) / 100; + return acc; + }, + { net: 0, tax: 0, gross: 0 } + ); +} + +function normalizeDateString(value) { + if (!value) return new Date().toISOString().slice(0, 10); + if (value instanceof Date) return value.toISOString().slice(0, 10); + return String(value).slice(0, 10); +} + +function yearFromDate(dateString) { + const normalized = normalizeDateString(dateString); + const date = new Date(`${normalized}T00:00:00Z`); + if (Number.isNaN(date.getTime())) return new Date().getFullYear(); + return date.getUTCFullYear(); +} + +function generateDocumentNumber() { + return `FIN-D-${new Date().getFullYear()}-${Math.floor(Math.random() * 100000) + .toString() + .padStart(5, "0")}`; +} + +function formatProjectCode(year, sequence) { + return `FIN-${year}-${String(sequence).padStart(4, "0")}`; +} + +function parseGeneratedProjectCodeYear(projectCode) { + const match = String(projectCode || "").trim().match(/^FIN-(\d{4})-\d+$/); + return match ? Number(match[1]) : null; +} + +async function getNextProjectCode(db, year = new Date().getFullYear()) { + const normalizedYear = normalizeYear(year); + await db.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`finance_project_code:${normalizedYear}`]); + const result = await db.query( + `SELECT COALESCE(MAX((regexp_match(project_code, $1))[1]::int), 0) + 1 AS next_sequence + FROM finance_project + WHERE project_code ~ $2`, + [`^FIN-${normalizedYear}-([0-9]+)$`, `^FIN-${normalizedYear}-[0-9]+$`] + ); + return formatProjectCode(normalizedYear, result.rows[0]?.next_sequence || 1); +} + +async function syncProjectBudgets(db, projectId, body, actorId) { + const projectBudgetAmount = normalizeNullableNumber(body.project_budget_amount); + if (projectBudgetAmount !== undefined) { + await db.query( + `INSERT INTO finance_project_budget(project_id, budget_type, budget_year, amount, currency, created_by, updated_by) + VALUES ($1, 'project', NULL, $2, 'EUR', $3, $3) + ON CONFLICT (project_id) WHERE budget_type = 'project' + DO UPDATE SET amount = EXCLUDED.amount, updated_by = EXCLUDED.updated_by, updated_at = NOW()`, + [projectId, projectBudgetAmount || 0, actorId] + ); + } + + const annualBudgetAmount = normalizeNullableNumber(body.annual_budget_amount); + const annualBudgetYear = normalizeYear(body.annual_budget_year); + if (annualBudgetAmount !== undefined) { + await db.query( + `INSERT INTO finance_project_budget(project_id, budget_type, budget_year, amount, currency, created_by, updated_by) + VALUES ($1, 'annual', $2, $3, 'EUR', $4, $4) + ON CONFLICT (project_id, budget_year) WHERE budget_type = 'annual' + DO UPDATE SET amount = EXCLUDED.amount, updated_by = EXCLUDED.updated_by, updated_at = NOW()`, + [projectId, annualBudgetYear, annualBudgetAmount || 0, actorId] + ); + } +} + +async function getTaxCodeRows(db, activeOnly = false) { + const result = await db.query( + `SELECT id, code, label, tax_rate_percent, is_active, sort_order, created_at, updated_at + FROM finance_tax_code + WHERE ($1::boolean = FALSE OR is_active = TRUE) + ORDER BY sort_order ASC, label ASC, code ASC`, + [activeOnly] + ); + return result.rows; +} + +async function normalizeExpenseLines(db, body, old = null) { + let sourceLines = Array.isArray(body.lines) ? body.lines : []; + + if (!sourceLines.length) { + const fallbackNet = body.net_amount !== undefined ? body.net_amount : old?.net_amount || 0; + const fallbackRate = body.tax_rate_percent !== undefined ? body.tax_rate_percent : old?.tax_rate_percent || 0; + const fallbackDescription = normalizeNullableText(body.cost_category) || normalizeNullableText(body.title) || old?.cost_category || old?.title || "Rechnungsposition"; + sourceLines = [{ description: fallbackDescription, tax_code_key: fallbackDescription, net_amount: fallbackNet, tax_rate_percent: fallbackRate }]; + } + + const taxCodeIds = sourceLines.map((line) => Number(line.tax_code_id || 0)).filter(Boolean); + const taxCodeKeys = sourceLines.map((line) => normalizeNullableText(line.tax_code_key)).filter(Boolean); + const params = []; + const conditions = []; + + if (taxCodeIds.length) { + params.push(taxCodeIds); + conditions.push(`id = ANY($${params.length}::bigint[])`); + } + if (taxCodeKeys.length) { + params.push(taxCodeKeys); + conditions.push(`code = ANY($${params.length}::text[])`); + } + + const taxCodes = conditions.length + ? await db.query(`SELECT id, code, label, tax_rate_percent FROM finance_tax_code WHERE ${conditions.join(" OR ")}`, params) + : { rows: [] }; + const byId = new Map(taxCodes.rows.map((row) => [String(row.id), row])); + const byCode = new Map(taxCodes.rows.map((row) => [row.code, row])); + + const normalized = sourceLines.map((line, index) => { + const taxCode = byId.get(String(line.tax_code_id || "")) || byCode.get(normalizeNullableText(line.tax_code_key)); + const description = normalizeNullableText(line.description) || taxCode?.label || taxCode?.code || `Position ${index + 1}`; + const amounts = calculateAmounts(line.net_amount, taxCode?.tax_rate_percent ?? line.tax_rate_percent ?? body.tax_rate_percent ?? 0); + return { + line_no: index + 1, + description, + tax_code_id: taxCode?.id || null, + tax_code_key: taxCode?.code || normalizeNullableText(line.tax_code_key), + net_amount: amounts.net, + tax_rate_percent: amounts.rate, + tax_amount: amounts.tax, + gross_amount: amounts.gross, + }; + }); + + return { lines: normalized, totals: sumAmounts(normalized) }; +} + +async function replaceExpenseLines(db, expenseId, lines) { + await db.query(`DELETE FROM finance_expense_line WHERE expense_id = $1::bigint`, [expenseId]); + for (const line of lines) { + await db.query( + `INSERT INTO finance_expense_line( + expense_id, line_no, description, tax_code_id, tax_code_key, + net_amount, tax_rate_percent, tax_amount, gross_amount + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [ + expenseId, + line.line_no, + line.description, + line.tax_code_id, + line.tax_code_key, + line.net_amount, + line.tax_rate_percent, + line.tax_amount, + line.gross_amount, + ] + ); + } +} + +async function getProjects(db, year) { + const result = await db.query( + `WITH expense_totals AS ( + SELECT project_id, + COUNT(*) FILTER (WHERE deleted_at IS NULL)::int AS expense_count, + COALESCE(SUM(net_amount) FILTER (WHERE deleted_at IS NULL AND payment_status <> 'storniert'), 0)::numeric(14,2) AS net_total, + COALESCE(SUM(tax_amount) FILTER (WHERE deleted_at IS NULL AND payment_status <> 'storniert'), 0)::numeric(14,2) AS tax_total, + COALESCE(SUM(gross_amount) FILTER (WHERE deleted_at IS NULL AND payment_status <> 'storniert'), 0)::numeric(14,2) AS gross_total, + COALESCE(SUM(gross_amount) FILTER (WHERE deleted_at IS NULL AND payment_status <> 'storniert' AND budget_year = $1::int), 0)::numeric(14,2) AS gross_year_total + FROM finance_expense + GROUP BY project_id + ) + SELECT p.id, + p.project_code, + p.name, + p.description, + p.status, + p.calculation_mode, + p.committee_id, + c.name AS committee_name, + p.created_at, + p.updated_at, + COALESCE(pb.amount, 0)::numeric(14,2) AS project_budget_amount, + COALESCE(ab.amount, 0)::numeric(14,2) AS annual_budget_amount, + $1::int AS annual_budget_year, + COALESCE(et.expense_count, 0)::int AS expense_count, + COALESCE(et.net_total, 0)::numeric(14,2) AS net_total, + COALESCE(et.tax_total, 0)::numeric(14,2) AS tax_total, + COALESCE(et.gross_total, 0)::numeric(14,2) AS gross_total, + COALESCE(et.gross_year_total, 0)::numeric(14,2) AS gross_year_total, + CASE + WHEN p.calculation_mode = 'project_budget' THEN COALESCE(pb.amount, 0) + WHEN p.calculation_mode = 'annual_budget' THEN COALESCE(ab.amount, 0) + ELSE NULL + END::numeric(14,2) AS active_budget_amount, + CASE + WHEN p.calculation_mode = 'project_budget' THEN COALESCE(et.gross_total, 0) + WHEN p.calculation_mode = 'annual_budget' THEN COALESCE(et.gross_year_total, 0) + ELSE COALESCE(et.gross_total, 0) + END::numeric(14,2) AS active_spent_amount, + CASE + WHEN p.calculation_mode = 'project_budget' THEN COALESCE(pb.amount, 0) - COALESCE(et.gross_total, 0) + WHEN p.calculation_mode = 'annual_budget' THEN COALESCE(ab.amount, 0) - COALESCE(et.gross_year_total, 0) + ELSE NULL + END::numeric(14,2) AS active_remaining_amount + FROM finance_project p + LEFT JOIN committee c ON c.id = p.committee_id + LEFT JOIN expense_totals et ON et.project_id = p.id + LEFT JOIN finance_project_budget pb ON pb.project_id = p.id AND pb.budget_type = 'project' + LEFT JOIN finance_project_budget ab ON ab.project_id = p.id AND ab.budget_type = 'annual' AND ab.budget_year = $1::int + WHERE p.deleted_at IS NULL + ORDER BY p.status, p.name`, + [year] + ); + return result.rows; +} + +async function getExpenseRows(db, { year, projectId }) { + const params = [year, projectId || null]; + const result = await db.query( + `SELECT e.id, + e.project_id, + p.name AS project_name, + e.expense_date, + e.budget_year, + e.title, + e.vendor, + e.cost_category, + e.net_amount, + e.tax_rate_percent, + e.tax_amount, + e.gross_amount, + e.currency, + e.payment_status, + e.notes, + e.created_at, + e.updated_at, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'id', l.id, + 'line_no', l.line_no, + 'description', l.description, + 'tax_code_id', l.tax_code_id, + 'tax_code_key', l.tax_code_key, + 'tax_code_label', tc.label, + 'net_amount', l.net_amount, + 'tax_rate_percent', l.tax_rate_percent, + 'tax_amount', l.tax_amount, + 'gross_amount', l.gross_amount + ) ORDER BY l.line_no ASC, l.id ASC) + FROM finance_expense_line l + LEFT JOIN finance_tax_code tc ON tc.id = l.tax_code_id + WHERE l.expense_id = e.id + ), '[]'::jsonb) AS lines, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'id', d.id, + 'document_number', d.document_number, + 'title', d.title, + 'file_name', d.file_name, + 'mime_type', d.mime_type, + 'size_bytes', d.size_bytes, + 'scan_status', d.scan_status, + 'uploaded_at', d.uploaded_at + ) ORDER BY d.uploaded_at DESC, d.id DESC) + FROM finance_expense_document fed + JOIN document_item d ON d.id = fed.document_id + WHERE fed.expense_id = e.id + AND d.deleted_at IS NULL + ), '[]'::jsonb) AS documents + FROM finance_expense e + JOIN finance_project p ON p.id = e.project_id + WHERE e.deleted_at IS NULL + AND ($1::int IS NULL OR e.budget_year = $1::int) + AND ($2::bigint IS NULL OR e.project_id = $2::bigint) + ORDER BY e.expense_date DESC, e.id DESC`, + params + ); + return result.rows; +} + +router.get("/meta", async (req, res, next) => { + try { + const data = await withDbSession(req.user, async (db) => { + const [committees, years, taxCodes] = await Promise.all([ + db.query(`SELECT id, name FROM committee ORDER BY name`), + db.query( + `SELECT DISTINCT budget_year AS year + FROM finance_expense + WHERE deleted_at IS NULL + UNION + SELECT DISTINCT budget_year AS year + FROM finance_project_budget + WHERE budget_year IS NOT NULL + ORDER BY year DESC` + ), + getTaxCodeRows(db, true), + ]); + + return { + tax_rates: [0, 7, 19], + tax_codes: taxCodes, + payment_statuses: ["geplant", "offen", "bezahlt", "storniert"], + calculation_modes: [ + { value: "sum_only", label: "Nur aufsummieren" }, + { value: "project_budget", label: "Vom Projektbudget abziehen" }, + { value: "annual_budget", label: "Vom Jahresbudget abziehen" }, + ], + committees: committees.rows, + years: years.rows.map((row) => row.year), + }; + }); + res.json(data); + } catch (err) { + next(err); + } +}); + +router.get("/tax-codes", requirePermission("finance.tax_codes", ["admin", "chair"]), async (req, res, next) => { + try { + const activeOnly = req.query.active === "true"; + const rows = await withDbSession(req.user, (db) => getTaxCodeRows(db, activeOnly)); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.post("/tax-codes", requireMfa, requirePermission("finance.tax_codes", ["admin", "chair"]), validate(taxCodeSchema), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `INSERT INTO finance_tax_code(code, label, tax_rate_percent, is_active, sort_order, created_by, updated_by) + VALUES ($1,$2,$3,$4,$5,$6,$6) + RETURNING *`, + [ + req.body.code.trim(), + req.body.label.trim(), + normalizeTaxPercent(req.body.tax_rate_percent), + req.body.is_active, + req.body.sort_order, + req.user.sub, + ] + ); + return result.rows[0]; + }); + await writeAudit({ actorId: req.user.sub, action: "FINANCE_TAX_CODE_CREATED", entityType: "finance_tax_code", entityId: row.id, metadata: req.body, ipAddress: req.ip }); + res.status(201).json(row); + } catch (err) { + if (err.code === "23505") return res.status(409).json({ error: "Diese MwSt.-/Kostenart existiert bereits." }); + next(err); + } +}); + +router.put("/tax-codes/:id", requireMfa, requirePermission("finance.tax_codes", ["admin", "chair"]), validate(taxCodeSchema.partial()), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const current = await db.query(`SELECT * FROM finance_tax_code WHERE id = $1::bigint`, [req.params.id]); + if (!current.rows[0]) return null; + const old = current.rows[0]; + const result = await db.query( + `UPDATE finance_tax_code + SET code = $2, + label = $3, + tax_rate_percent = $4, + is_active = $5, + sort_order = $6, + updated_by = $7, + updated_at = NOW() + WHERE id = $1::bigint + RETURNING *`, + [ + req.params.id, + req.body.code !== undefined ? req.body.code.trim() : old.code, + req.body.label !== undefined ? req.body.label.trim() : old.label, + req.body.tax_rate_percent !== undefined ? normalizeTaxPercent(req.body.tax_rate_percent) : old.tax_rate_percent, + req.body.is_active !== undefined ? req.body.is_active : old.is_active, + req.body.sort_order !== undefined ? req.body.sort_order : old.sort_order, + req.user.sub, + ] + ); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "MwSt.-/Kostenart nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "FINANCE_TAX_CODE_UPDATED", entityType: "finance_tax_code", entityId: row.id, metadata: req.body, ipAddress: req.ip }); + res.json(row); + } catch (err) { + if (err.code === "23505") return res.status(409).json({ error: "Diese MwSt.-/Kostenart existiert bereits." }); + next(err); + } +}); + +router.delete("/tax-codes/:id", requireMfa, requirePermission("finance.tax_codes", ["admin", "chair"]), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `UPDATE finance_tax_code + SET is_active = FALSE, updated_by = $2, updated_at = NOW() + WHERE id = $1::bigint + RETURNING id, code, label`, + [req.params.id, req.user.sub] + ); + return result.rows[0] || null; + }); + if (!row) return res.status(404).json({ error: "MwSt.-/Kostenart nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "FINANCE_TAX_CODE_DISABLED", entityType: "finance_tax_code", entityId: row.id, metadata: row, ipAddress: req.ip }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +router.get("/projects", async (req, res, next) => { + try { + const year = normalizeYear(req.query.year); + const rows = await withDbSession(req.user, (db) => getProjects(db, year)); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/projects/next-code", requirePermission("finance.write", ["admin", "chair"]), async (req, res, next) => { + try { + const year = normalizeYear(req.query.year); + const projectCode = await withDbSession(req.user, (db) => getNextProjectCode(db, year)); + res.json({ project_code: projectCode, year }); + } catch (err) { + next(err); + } +}); + +router.post("/projects", requireMfa, requirePermission("finance.write", ["admin", "chair"]), validate(projectSchema), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const requestedProjectCode = normalizeNullableText(req.body.project_code); + const requestedProjectCodeYear = parseGeneratedProjectCodeYear(requestedProjectCode); + let projectCode = requestedProjectCode; + + if (requestedProjectCodeYear) { + await db.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`finance_project_code:${requestedProjectCodeYear}`]); + const duplicate = await db.query(`SELECT 1 FROM finance_project WHERE project_code = $1`, [requestedProjectCode]); + if (duplicate.rows[0]) { + projectCode = await getNextProjectCode(db, requestedProjectCodeYear); + } + } + + if (!projectCode) { + projectCode = await getNextProjectCode(db, req.body.annual_budget_year); + } + + const insert = await db.query( + `INSERT INTO finance_project(project_code, name, description, status, calculation_mode, committee_id, created_by, updated_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$7) + RETURNING *`, + [ + projectCode, + req.body.name, + normalizeNullableText(req.body.description), + req.body.status, + req.body.calculation_mode, + normalizeNullableNumber(req.body.committee_id), + req.user.sub, + ] + ); + const project = insert.rows[0]; + await syncProjectBudgets(db, project.id, req.body, req.user.sub); + return project; + }); + + await writeAudit({ actorId: req.user.sub, action: "FINANCE_PROJECT_CREATED", entityType: "finance_project", entityId: row.id, metadata: req.body, ipAddress: req.ip }); + res.status(201).json(row); + } catch (err) { + if (err.code === "23505") return res.status(409).json({ error: "Die Projekt-Nr. ist bereits vergeben." }); + next(err); + } +}); + +router.put("/projects/:id", requireMfa, requirePermission("finance.write", ["admin", "chair"]), validate(projectSchema.partial()), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const current = await db.query(`SELECT id FROM finance_project WHERE id = $1::bigint AND deleted_at IS NULL`, [req.params.id]); + if (!current.rows[0]) return null; + + const result = await db.query( + `UPDATE finance_project + SET project_code = COALESCE($2, project_code), + name = COALESCE($3, name), + description = $4, + status = COALESCE($5, status), + calculation_mode = COALESCE($6, calculation_mode), + committee_id = $7, + updated_by = $8, + updated_at = NOW() + WHERE id = $1::bigint + RETURNING *`, + [ + req.params.id, + normalizeNullableText(req.body.project_code), + req.body.name, + normalizeNullableText(req.body.description), + req.body.status, + req.body.calculation_mode, + normalizeNullableNumber(req.body.committee_id), + req.user.sub, + ] + ); + await syncProjectBudgets(db, req.params.id, req.body, req.user.sub); + return result.rows[0]; + }); + + if (!row) return res.status(404).json({ error: "Finanzprojekt nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "FINANCE_PROJECT_UPDATED", entityType: "finance_project", entityId: row.id, metadata: req.body, ipAddress: req.ip }); + res.json(row); + } catch (err) { + if (err.code === "23505") return res.status(409).json({ error: "Die Projekt-Nr. ist bereits vergeben." }); + next(err); + } +}); + +router.delete("/projects/:id", requireMfa, requirePermission("finance.delete", ["admin", "chair"]), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `UPDATE finance_project + SET deleted_at = NOW(), updated_by = $2, updated_at = NOW() + WHERE id = $1::bigint AND deleted_at IS NULL + RETURNING id, name`, + [req.params.id, req.user.sub] + ); + return result.rows[0] || null; + }); + if (!row) return res.status(404).json({ error: "Finanzprojekt nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "FINANCE_PROJECT_DELETED", entityType: "finance_project", entityId: row.id, metadata: row, ipAddress: req.ip }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +router.get("/expenses", async (req, res, next) => { + try { + const year = req.query.year ? normalizeYear(req.query.year) : null; + const projectId = req.query.project_id ? Number(req.query.project_id) : null; + const rows = await withDbSession(req.user, (db) => getExpenseRows(db, { year, projectId })); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.post("/expenses", requireMfa, requirePermission("finance.write", ["admin", "chair"]), validate(expenseSchema), async (req, res, next) => { + try { + const budgetYear = yearFromDate(req.body.expense_date); + + const row = await withDbSession(req.user, async (db) => { + const { lines, totals } = await normalizeExpenseLines(db, req.body); + const primaryLine = lines[0]; + const result = await db.query( + `INSERT INTO finance_expense( + project_id, expense_date, budget_year, title, vendor, cost_category, + net_amount, tax_rate_percent, tax_amount, gross_amount, currency, payment_status, notes, + created_by, updated_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$14) + RETURNING *`, + [ + req.body.project_id, + req.body.expense_date, + budgetYear, + req.body.title, + normalizeNullableText(req.body.vendor), + normalizeNullableText(req.body.cost_category) || primaryLine?.tax_code_key || primaryLine?.description || null, + totals.net, + primaryLine?.tax_rate_percent || 0, + totals.tax, + totals.gross, + (req.body.currency || "EUR").toUpperCase(), + req.body.payment_status, + normalizeNullableText(req.body.notes), + req.user.sub, + ] + ); + await replaceExpenseLines(db, result.rows[0].id, lines); + return result.rows[0]; + }); + + await writeAudit({ actorId: req.user.sub, action: "FINANCE_EXPENSE_CREATED", entityType: "finance_expense", entityId: row.id, metadata: req.body, ipAddress: req.ip }); + res.status(201).json(row); + } catch (err) { + next(err); + } +}); + +router.put("/expenses/:id", requireMfa, requirePermission("finance.write", ["admin", "chair"]), validate(expenseSchema.partial()), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const current = await db.query(`SELECT * FROM finance_expense WHERE id = $1::bigint AND deleted_at IS NULL`, [req.params.id]); + const old = current.rows[0]; + if (!old) return null; + + const nextBody = { + project_id: req.body.project_id ?? old.project_id, + expense_date: req.body.expense_date || normalizeDateString(old.expense_date), + title: req.body.title ?? old.title, + vendor: req.body.vendor !== undefined ? req.body.vendor : old.vendor, + cost_category: req.body.cost_category !== undefined ? req.body.cost_category : old.cost_category, + net_amount: req.body.net_amount !== undefined ? req.body.net_amount : old.net_amount, + tax_rate_percent: req.body.tax_rate_percent !== undefined ? req.body.tax_rate_percent : old.tax_rate_percent, + currency: req.body.currency || old.currency, + payment_status: req.body.payment_status || old.payment_status, + notes: req.body.notes !== undefined ? req.body.notes : old.notes, + lines: req.body.lines, + }; + const { lines, totals } = await normalizeExpenseLines(db, nextBody, old); + const primaryLine = lines[0]; + const budgetYear = yearFromDate(nextBody.expense_date); + + const result = await db.query( + `UPDATE finance_expense + SET project_id = $2, + expense_date = $3, + budget_year = $4, + title = $5, + vendor = $6, + cost_category = $7, + net_amount = $8, + tax_rate_percent = $9, + tax_amount = $10, + gross_amount = $11, + currency = $12, + payment_status = $13, + notes = $14, + updated_by = $15, + updated_at = NOW() + WHERE id = $1::bigint + RETURNING *`, + [ + req.params.id, + nextBody.project_id, + nextBody.expense_date, + budgetYear, + nextBody.title, + normalizeNullableText(nextBody.vendor), + normalizeNullableText(nextBody.cost_category) || primaryLine?.tax_code_key || primaryLine?.description || null, + totals.net, + primaryLine?.tax_rate_percent || 0, + totals.tax, + totals.gross, + nextBody.currency.toUpperCase(), + nextBody.payment_status, + normalizeNullableText(nextBody.notes), + req.user.sub, + ] + ); + await replaceExpenseLines(db, req.params.id, lines); + return result.rows[0]; + }); + + if (!row) return res.status(404).json({ error: "Ausgabe nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "FINANCE_EXPENSE_UPDATED", entityType: "finance_expense", entityId: row.id, metadata: req.body, ipAddress: req.ip }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.delete("/expenses/:id", requireMfa, requirePermission("finance.delete", ["admin", "chair"]), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `UPDATE finance_expense + SET deleted_at = NOW(), updated_by = $2, updated_at = NOW() + WHERE id = $1::bigint AND deleted_at IS NULL + RETURNING id, title`, + [req.params.id, req.user.sub] + ); + return result.rows[0] || null; + }); + if (!row) return res.status(404).json({ error: "Ausgabe nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "FINANCE_EXPENSE_DELETED", entityType: "finance_expense", entityId: row.id, metadata: row, ipAddress: req.ip }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +router.post( + "/expenses/:id/documents/upload", + requireMfa, + requirePermission("finance.write", ["admin", "chair"]), + upload.single("file"), + async (req, res, next) => { + try { + if (!req.file) return res.status(400).json({ error: "Datei fehlt" }); + + const scan = await scanBuffer(req.file.buffer); + if (!scan.clean) { + await writeAudit({ actorId: req.user.sub, action: "FINANCE_DOCUMENT_REJECTED", entityType: "finance_expense", entityId: Number(req.params.id), metadata: { scan: scan.raw, file_name: req.file.originalname }, ipAddress: req.ip }); + return res.status(422).json({ error: "Datei durch Virenscan abgelehnt", scan_result: scan.raw }); + } + + const mime = await fileTypeFromBuffer(req.file.buffer); + const detectedMime = mime?.mime || req.file.mimetype || "application/octet-stream"; + const sha256 = crypto.createHash("sha256").update(req.file.buffer).digest("hex"); + const storageKey = `finance/${new Date().toISOString().slice(0, 10)}/${uuidv4()}-${req.file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_")}`; + const docMeta = await saveObject(storageKey, req.file.buffer, detectedMime); + + const document = await withDbSession(req.user, async (db) => { + const expense = await db.query( + `SELECT e.id, e.title, e.project_id, p.name AS project_name + FROM finance_expense e + JOIN finance_project p ON p.id = e.project_id + WHERE e.id = $1::bigint + AND e.deleted_at IS NULL`, + [req.params.id] + ); + if (!expense.rows[0]) return null; + + const insert = await db.query( + `INSERT INTO document_item( + document_number, title, document_type, description, version_label, + file_name, storage_path, storage_provider, mime_type, size_bytes, sha256_hash, + confidentiality_level, uploaded_by, scan_status, scan_engine, scan_result, uploaded_at + ) VALUES ($1,$2,'finance_receipt',$3,'v1',$4,$5,$6,$7,$8,$9,'intern',$10,'clean','clamav',$11,NOW()) + RETURNING id, document_number, title, file_name, mime_type, size_bytes, scan_status, uploaded_at`, + [ + generateDocumentNumber(), + normalizeNullableText(req.body.title) || `Beleg: ${expense.rows[0].title}`, + normalizeNullableText(req.body.description) || `Finanzbeleg zu Ausgabe ${expense.rows[0].title} im Projekt ${expense.rows[0].project_name}`, + req.file.originalname, + docMeta.storage_path, + docMeta.storage_provider, + detectedMime, + req.file.size, + sha256, + req.user.sub, + scan.raw, + ] + ); + + const row = insert.rows[0]; + await db.query( + `UPDATE document_item + SET version_number = 1, + version_group_id = id + WHERE id = $1::bigint`, + [row.id] + ); + await db.query( + `INSERT INTO finance_expense_document(expense_id, document_id, created_by) + VALUES ($1,$2,$3) + ON CONFLICT DO NOTHING`, + [req.params.id, row.id, req.user.sub] + ); + await db.query( + `INSERT INTO document_scan_event(document_id, scanned_by_user_id, status, engine, result_text, scanned_at) + VALUES ($1,$2,'clean','clamav',$3,NOW())`, + [row.id, req.user.sub, scan.raw] + ); + return row; + }); + + if (!document) return res.status(404).json({ error: "Ausgabe nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "FINANCE_DOCUMENT_UPLOADED", + entityType: "document", + entityId: document.id, + metadata: { expense_id: Number(req.params.id), sha256, storage_provider: docMeta.storage_provider }, + ipAddress: req.ip, + }); + + res.status(201).json(document); + } catch (err) { + next(err); + } + } +); + +async function getFinanceDocument(user, documentId) { + return withDbSession(user, async (db) => { + const result = await db.query( + `SELECT d.id, d.title, d.file_name, d.storage_path, d.mime_type, d.scan_status + FROM document_item d + JOIN finance_expense_document fed ON fed.document_id = d.id + JOIN finance_expense fe ON fe.id = fed.expense_id AND fe.deleted_at IS NULL + WHERE d.id = $1::bigint + AND d.deleted_at IS NULL + LIMIT 1`, + [documentId] + ); + return result.rows[0] || null; + }); +} + +async function streamFinanceDocument(req, res, next, disposition = "attachment") { + try { + const row = await getFinanceDocument(req.user, req.params.id); + if (!row) return res.status(404).json({ error: "Beleg nicht gefunden" }); + if (row.scan_status !== "clean") return res.status(409).json({ error: "Beleg nicht freigegeben" }); + + const { stream, contentType } = await getObjectStream(row.storage_path); + const safeFilename = encodeURIComponent(row.file_name || row.title || `beleg-${row.id}`); + res.setHeader("Content-Type", contentType || row.mime_type || "application/octet-stream"); + res.setHeader("Content-Disposition", `${disposition}; filename="${safeFilename}"`); + res.setHeader("Cache-Control", "no-store"); + + await writeAudit({ + actorId: req.user.sub, + action: disposition === "inline" ? "FINANCE_DOCUMENT_VIEWED" : "FINANCE_DOCUMENT_DOWNLOADED", + entityType: "document", + entityId: row.id, + ipAddress: req.ip, + }); + + stream.pipe(res); + } catch (err) { + next(err); + } +} + +router.get("/documents/:id/download", requireMfa, async (req, res, next) => { + await streamFinanceDocument(req, res, next, "attachment"); +}); + +router.get("/documents/:id/view", requireMfa, async (req, res, next) => { + await streamFinanceDocument(req, res, next, "inline"); +}); + +export default router; diff --git a/backend/src/modules/kummerkasten/routes.js b/backend/src/modules/kummerkasten/routes.js new file mode 100644 index 0000000..05fb857 --- /dev/null +++ b/backend/src/modules/kummerkasten/routes.js @@ -0,0 +1,375 @@ +import express from "express"; +import argon2 from "argon2"; +import jwt from "jsonwebtoken"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { pool, query, withDbSession } from "../../config/db.js"; +import { env } from "../../config/env.js"; +import { writeAudit } from "../../utils/audit.js"; + +export const publicKummerkastenRouter = express.Router(); +const adminRouter = express.Router(); + +const PUBLIC_TOKEN_SCOPE = "kummerkasten"; +const PUBLIC_TOKEN_EXPIRES_IN = "4h"; + +const settingsSchema = z.object({ + enabled: z.boolean().optional(), + access_password: z.string().max(200).optional().or(z.literal("")), + clear_password: z.boolean().optional(), + intro_text: z.string().max(4000).optional().nullable(), + target_committee_id: z.coerce.number().int().positive().optional().nullable(), +}); + +const accessSchema = z.object({ + password: z.string().min(1).max(200), +}); + +const submitSchema = z.object({ + public_access_token: z.string().min(10), + title: z.string().min(3).max(220), + description: z.string().min(3).max(8000), + submitter_name: z.string().max(255).optional().nullable(), + submitter_contact: z.string().max(255).optional().nullable(), +}); + +function normalizeText(value) { + if (value === undefined || value === null) return null; + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function shorten(value, maxLength) { + const text = String(value || "").trim(); + if (text.length <= maxLength) return text; + return `${text.slice(0, Math.max(0, maxLength - 1))}…`; +} + +function signPublicToken() { + return jwt.sign( + { scope: PUBLIC_TOKEN_SCOPE, setting_id: 1 }, + env.jwtAccessSecret, + { expiresIn: PUBLIC_TOKEN_EXPIRES_IN } + ); +} + +function verifyPublicToken(token) { + try { + const decoded = jwt.verify(token, env.jwtAccessSecret); + return decoded?.scope === PUBLIC_TOKEN_SCOPE && Number(decoded?.setting_id) === 1; + } catch { + return false; + } +} + +async function getSetting(db = null) { + const executor = db || { query }; + const result = await executor.query( + `SELECT s.id, + s.enabled, + s.access_password_hash, + s.intro_text, + s.target_committee_id, + c.name AS target_committee_name, + s.updated_at + FROM kummerkasten_setting s + LEFT JOIN committee c ON c.id = s.target_committee_id + WHERE s.id = 1` + ); + return result.rows[0] || null; +} + +function publicStatus(setting) { + return { + enabled: Boolean(setting?.enabled), + password_required: Boolean(setting?.access_password_hash), + intro_text: setting?.intro_text || "Reichen Sie hier vertraulich einen Vorschlag oder ein Anliegen für die Tagesordnung ein.", + target_committee_name: setting?.target_committee_name || null, + }; +} + +function adminStatus(setting) { + return { + enabled: Boolean(setting?.enabled), + password_set: Boolean(setting?.access_password_hash), + intro_text: setting?.intro_text || "", + target_committee_id: setting?.target_committee_id || "", + target_committee_name: setting?.target_committee_name || null, + updated_at: setting?.updated_at || null, + }; +} + +function buildAgendaDescription({ description, submitterName, submitterContact, targetCommitteeName }) { + const parts = [ + `Kummerkasten-Einreichung vom ${new Date().toLocaleDateString("de-DE")}`, + ]; + + if (targetCommitteeName) parts.push(`Ziel-Gremium: ${targetCommitteeName}`); + if (submitterName) parts.push(`Name: ${submitterName}`); + if (submitterContact) parts.push(`Kontakt: ${submitterContact}`); + + parts.push("", "Beschreibung:", description); + + return parts.join("\n"); +} + +publicKummerkastenRouter.get("/status", async (req, res, next) => { + try { + const setting = await getSetting(); + res.json(publicStatus(setting)); + } catch (err) { + next(err); + } +}); + +publicKummerkastenRouter.post("/access", validate(accessSchema), async (req, res, next) => { + try { + const setting = await getSetting(); + if (!setting?.enabled) { + return res.status(404).json({ error: "Der Kummerkasten ist derzeit nicht aktiv." }); + } + if (!setting.access_password_hash) { + return res.status(403).json({ error: "Für den Kummerkasten ist noch kein Kennwort gesetzt." }); + } + + const ok = await argon2.verify(setting.access_password_hash, req.body.password); + if (!ok) { + return res.status(401).json({ error: "Kennwort ist nicht korrekt." }); + } + + res.json({ accessToken: signPublicToken(), expiresIn: PUBLIC_TOKEN_EXPIRES_IN }); + } catch (err) { + next(err); + } +}); + +publicKummerkastenRouter.post("/submit", validate(submitSchema), async (req, res, next) => { + try { + const setting = await getSetting(); + if (!setting?.enabled) { + return res.status(404).json({ error: "Der Kummerkasten ist derzeit nicht aktiv." }); + } + if (!setting.access_password_hash) { + return res.status(403).json({ error: "Für den Kummerkasten ist noch kein Kennwort gesetzt." }); + } + if (!verifyPublicToken(req.body.public_access_token)) { + return res.status(401).json({ error: "Zugriff abgelaufen. Bitte Kennwort erneut eingeben." }); + } + + const payload = { + title: req.body.title.trim(), + description: req.body.description.trim(), + submitter_name: normalizeText(req.body.submitter_name), + submitter_contact: normalizeText(req.body.submitter_contact), + }; + + const agendaTitle = shorten(`Kummerkasten: ${payload.title}`, 255); + const agendaDescription = buildAgendaDescription({ + description: payload.description, + submitterName: payload.submitter_name, + submitterContact: payload.submitter_contact, + targetCommitteeName: setting.target_committee_name, + }); + + const client = await pool.connect(); + let result; + try { + await client.query("BEGIN"); + const agendaResult = await client.query( + `INSERT INTO meeting_agenda_item(meeting_id, agenda_number, title, description) + VALUES (NULL, NULL, $1, $2) + RETURNING id, title, description, created_at`, + [agendaTitle, agendaDescription] + ); + + const agendaItem = agendaResult.rows[0]; + const submissionResult = await client.query( + `INSERT INTO kummerkasten_submission( + agenda_item_id, + title, + description, + submitter_name, + submitter_contact, + target_committee_id + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at`, + [ + agendaItem.id, + payload.title, + payload.description, + payload.submitter_name, + payload.submitter_contact, + setting.target_committee_id || null, + ] + ); + + await client.query("COMMIT"); + result = { agendaItem, submission: submissionResult.rows[0] }; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + + await writeAudit({ + actorId: null, + action: "KUMMERKASTEN_SUBMITTED", + entityType: "agenda_item", + entityId: result.agendaItem.id, + metadata: { submission_id: result.submission.id }, + ipAddress: req.ip, + }); + + res.status(201).json({ + ok: true, + submission_id: result.submission.id, + agenda_item_id: result.agendaItem.id, + title: result.agendaItem.title, + }); + } catch (err) { + next(err); + } +}); + +adminRouter.use(authenticate); + +adminRouter.get( + "/settings", + requirePermission("kummerkasten.configure", ["admin"]), + async (req, res, next) => { + try { + const data = await withDbSession(req.user, async (db) => { + const setting = await getSetting(db); + const committeeResult = await db.query( + `SELECT id, name + FROM committee + ORDER BY name ASC` + ); + const submissionsResult = await db.query( + `SELECT s.id, + s.agenda_item_id, + s.title, + s.submitter_name, + s.submitter_contact, + s.status, + s.created_at, + ai.title AS agenda_title, + c.name AS target_committee_name + FROM kummerkasten_submission s + LEFT JOIN meeting_agenda_item ai ON ai.id = s.agenda_item_id + LEFT JOIN committee c ON c.id = s.target_committee_id + ORDER BY s.created_at DESC + LIMIT 25` + ); + + return { + settings: adminStatus(setting), + committees: committeeResult.rows, + submissions: submissionsResult.rows, + public_path: "/kummerkasten", + }; + }); + + res.json(data); + } catch (err) { + next(err); + } + } +); + +adminRouter.put( + "/settings", + requireMfa, + requirePermission("kummerkasten.configure", ["admin"]), + validate(settingsSchema), + async (req, res, next) => { + try { + const passwordInput = typeof req.body.access_password === "string" ? req.body.access_password.trim() : ""; + const payload = { + enabled: req.body.enabled, + intro_text: normalizeText(req.body.intro_text) || "Reichen Sie hier vertraulich einen Vorschlag oder ein Anliegen für die Tagesordnung ein.", + target_committee_id: req.body.target_committee_id || null, + access_password: passwordInput, + clear_password: Boolean(req.body.clear_password), + }; + + if (payload.access_password && payload.access_password.length < 4) { + throw Object.assign(new Error("Kennwort zu kurz"), { + status: 400, + publicMessage: "Das globale Kennwort muss mindestens 4 Zeichen lang sein.", + }); + } + + const data = await withDbSession(req.user, async (db) => { + const current = await getSetting(db); + let passwordHash = current?.access_password_hash || null; + + // Ein neu eingegebenes Kennwort gewinnt immer vor "Kennwort entfernen". + // Dadurch kann ein Admin in einem Schritt "einschalten + Kennwort setzen" speichern, + // auch wenn die Checkbox versehentlich noch aktiv ist. + if (payload.clear_password && !payload.access_password) { + passwordHash = null; + } + if (payload.access_password) { + passwordHash = await argon2.hash(payload.access_password); + } + + const nextEnabled = payload.enabled ?? Boolean(current?.enabled); + if (nextEnabled && !passwordHash) { + throw Object.assign(new Error("Kennwort erforderlich"), { + status: 400, + publicMessage: "Zum Einschalten des Kummerkastens muss zuerst ein Kennwort eingegeben und gespeichert werden.", + }); + } + + await db.query( + `INSERT INTO kummerkasten_setting( + id, + enabled, + access_password_hash, + intro_text, + target_committee_id, + created_by, + updated_by + ) + VALUES (1, $1, $2, $3, $4, $5, $5) + ON CONFLICT (id) DO UPDATE + SET enabled = EXCLUDED.enabled, + access_password_hash = EXCLUDED.access_password_hash, + intro_text = EXCLUDED.intro_text, + target_committee_id = EXCLUDED.target_committee_id, + updated_by = EXCLUDED.updated_by`, + [ + nextEnabled, + passwordHash, + payload.intro_text, + payload.target_committee_id, + req.user.sub, + ] + ); + + const setting = await getSetting(db); + return { settings: adminStatus(setting) }; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "KUMMERKASTEN_SETTINGS_UPDATED", + entityType: "kummerkasten_setting", + entityId: 1, + metadata: { enabled: data.settings.enabled, password_set: data.settings.password_set }, + ipAddress: req.ip, + }); + + res.json(data); + } catch (err) { + next(err); + } + } +); + +export default adminRouter; diff --git a/backend/src/modules/meetings/routes.js b/backend/src/modules/meetings/routes.js new file mode 100644 index 0000000..63c4ca6 --- /dev/null +++ b/backend/src/modules/meetings/routes.js @@ -0,0 +1,1754 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission, requireRoles } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { writeAudit } from "../../utils/audit.js"; +import { ensureMeetingProtocolDocument } from "../../services/meetingProtocol.js"; +import { ensureMeetingDecisionReportDocument } from "../../services/meetingDecisionReport.js"; + +const router = express.Router(); +router.use(authenticate); + +const meetingTypeEnum = z.enum(["ordentlich", "ausserordentlich"]); +const statusEnum = z.enum(["geplant", "ungeplant", "gestartet", "beendet"]); + +const nullablePositiveInt = z.preprocess( + (value) => { + if (value === "" || value === null || value === undefined) return undefined; + return value; + }, + z.coerce.number().int().positive().optional() +); + +function normalizeBooleanInput(value) { + if (value === "" || value === null || value === undefined) return undefined; + if (typeof value === "boolean") return value; + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "ja", "on"].includes(normalized)) return true; + if (["0", "false", "no", "nein", "off"].includes(normalized)) return false; + return value; +} + +const requiredBooleanAnswer = z.preprocess( + normalizeBooleanInput, + z.boolean({ + required_error: "Bitte Beschlussfähigkeit mit Ja oder Nein bestätigen", + invalid_type_error: "Bitte Beschlussfähigkeit mit Ja oder Nein bestätigen", + }) +); + +const optionalBooleanAnswer = z.preprocess(normalizeBooleanInput, z.boolean().optional()); + +const dateString = z + .string() + .min(1, "Sitzungsdatum ist erforderlich") + .regex(/^\d{4}-\d{2}-\d{2}$/, "Ungültiges Sitzungsdatum"); + +const createSchema = z.object({ + meeting_number: z.string().min(3).max(50).optional().nullable(), + title: z.string().min(3).max(255), + meeting_type: meetingTypeEnum.default("ordentlich"), + meeting_date: dateString, + start_time: z.string().optional().nullable(), + end_time: z.string().optional().nullable(), + location: z.string().max(255).optional().nullable(), + status: statusEnum.default("geplant"), + is_quorate: optionalBooleanAnswer, + protocol_text: z.string().max(30000).optional().nullable(), + committee_id: nullablePositiveInt, + chair_user_id: nullablePositiveInt, + secretary_user_id: nullablePositiveInt, +}); + +const updateSchema = z.object({ + title: z.string().min(3).max(255).optional(), + meeting_type: meetingTypeEnum.optional(), + meeting_date: dateString.optional(), + start_time: z.string().optional().nullable(), + end_time: z.string().optional().nullable(), + location: z.string().max(255).optional().nullable(), + status: statusEnum.optional(), + is_quorate: optionalBooleanAnswer, + protocol_text: z.string().max(30000).optional().nullable(), + committee_id: nullablePositiveInt, + chair_user_id: nullablePositiveInt, + secretary_user_id: nullablePositiveInt, +}); + +const assignAgendaSchema = z.object({ + agenda_item_id: z.coerce.number().int().positive(), +}); + +const startMeetingSchema = z.object({ + is_quorate: requiredBooleanAnswer, +}); + +const createAndAssignAgendaSchema = z.object({ + title: z.string().min(3).max(255), + description: z.string().max(8000).optional().nullable(), + duration_minutes: z.coerce.number().int().min(0).max(600).optional().nullable(), + case_id: nullablePositiveInt, +}); + +const agendaCommentSchema = z.object({ + comment_text: z.string().min(1).max(10000), + visibility: z.enum(["intern", "vertraulich"]).default("intern"), + include_in_protocol: z.coerce.boolean().optional().default(false), +}); + +const agendaCommentUpdateSchema = agendaCommentSchema.partial(); + +const agendaReturnSchema = z.object({ + remark: z.string().min(1).max(10000), + return_type: z.enum(["rueckfrage", "klaerung", "bemerkung"]).default("rueckfrage"), + include_in_protocol: z.coerce.boolean().optional().default(true), +}); + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed ? trimmed : null; +} + +function normalizeOptionalText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed || null; +} + +function normalizeNullableNumber(value) { + if (value === undefined) return undefined; + if (value === null || value === "") return null; + return Number(value); +} + +function parsePositiveInt(value, fallback) { + const num = Number(value); + if (!Number.isInteger(num) || num <= 0) return fallback; + return num; +} + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === "") return fallback; + if (typeof value === "boolean") return value; + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "ja", "on"].includes(normalized)) return true; + if (["0", "false", "no", "nein", "off"].includes(normalized)) return false; + return fallback; +} + +function assertQuorumAnswer(value) { + if (typeof value === "boolean") return; + throw Object.assign(new Error("Beschlussfähigkeit muss bestätigt werden"), { + status: 400, + publicMessage: "Bitte vor dem Starten bestätigen, ob die Sitzung beschlussfähig ist.", + }); +} + +function buildMeetingSearchPattern(raw) { + const value = String(raw ?? "").trim(); + if (!value) return null; + + const escaped = value.replace(/\\/g, "\\\\"); + const hasWildcard = /[%_]/.test(escaped); + + return hasWildcard ? escaped : `%${escaped}%`; +} + +function mapMeetingRow(row) { + if (!row) return null; + return { + id: row.id, + meeting_number: row.meeting_number, + title: row.title, + meeting_type: row.meeting_type, + meeting_date: row.meeting_date, + start_time: row.start_time, + end_time: row.end_time, + location: row.location, + status: row.status, + is_quorate: row.is_quorate, + quorum_recorded_at: row.quorum_recorded_at, + quorum_recorded_by: row.quorum_recorded_by, + quorum_recorded_by_name: row.quorum_recorded_by_name, + protocol_text: row.protocol_text, + committee_id: row.committee_id, + committee_name: row.committee_name, + chair_user_id: row.chair_user_id, + chair_name: row.chair_name, + secretary_user_id: row.secretary_user_id, + secretary_name: row.secretary_name, + agenda_item_count: row.agenda_item_count ?? 0, + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +async function getMeetingList(db, options = {}) { + const page = parsePositiveInt(options.page, 1); + const pageSize = Math.min(parsePositiveInt(options.pageSize, 25), 100); + const showEnded = parseBoolean(options.showEnded, false); + const searchPattern = buildMeetingSearchPattern(options.q); + + const where = []; + const values = []; + + if (!showEnded) { + where.push(`m.status <> 'beendet'`); + } + + if (searchPattern) { + values.push(searchPattern); + const idx = values.length; + + where.push(`( + m.meeting_number ILIKE $${idx} ESCAPE '\' + OR m.title ILIKE $${idx} ESCAPE '\' + OR COALESCE(m.location, '') ILIKE $${idx} ESCAPE '\' + OR COALESCE(m.meeting_type, '') ILIKE $${idx} ESCAPE '\' + OR COALESCE(m.status, '') ILIKE $${idx} ESCAPE '\' + OR COALESCE(c.name, '') ILIKE $${idx} ESCAPE '\' + OR COALESCE(cu.full_name, '') ILIKE $${idx} ESCAPE '\' + OR COALESCE(su.full_name, '') ILIKE $${idx} ESCAPE '\' + OR EXISTS ( + SELECT 1 + FROM meeting_agenda_item ai_search + LEFT JOIN br_case case_search ON case_search.id = ai_search.case_id + WHERE ai_search.meeting_id = m.id + AND ( + COALESCE(ai_search.agenda_number, '') ILIKE $${idx} ESCAPE '\' + OR ai_search.title ILIKE $${idx} ESCAPE '\' + OR COALESCE(ai_search.description, '') ILIKE $${idx} ESCAPE '\' + OR COALESCE(case_search.case_number, '') ILIKE $${idx} ESCAPE '\' + OR COALESCE(case_search.title, '') ILIKE $${idx} ESCAPE '\' + OR COALESCE(case_search.summary, '') ILIKE $${idx} ESCAPE '\' + ) + ) + )`); + } + + const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; + + const countResult = await db.query( + ` + SELECT COUNT(*)::int AS total + FROM meeting m + LEFT JOIN committee c ON c.id = m.committee_id + LEFT JOIN app_user cu ON cu.id = m.chair_user_id + LEFT JOIN app_user su ON su.id = m.secretary_user_id + LEFT JOIN app_user qru ON qru.id = m.quorum_recorded_by + ${whereSql} + `, + values + ); + + const total = countResult.rows[0]?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const offset = (page - 1) * pageSize; + + const listValues = [...values, pageSize, offset]; + const limitIdx = listValues.length - 1; + const offsetIdx = listValues.length; + + const listResult = await db.query( + ` + SELECT m.id, + m.meeting_number, + m.title, + m.meeting_type, + m.meeting_date, + m.start_time, + m.end_time, + m.location, + m.status, + m.is_quorate, + m.quorum_recorded_at, + m.quorum_recorded_by, + qru.full_name AS quorum_recorded_by_name, + m.protocol_text, + m.committee_id, + c.name AS committee_name, + m.chair_user_id, + cu.full_name AS chair_name, + m.secretary_user_id, + su.full_name AS secretary_name, + m.created_at, + m.updated_at, + ( + SELECT COUNT(*)::int + FROM meeting_agenda_item mai + WHERE mai.meeting_id = m.id + ) AS agenda_item_count + FROM meeting m + LEFT JOIN committee c ON c.id = m.committee_id + LEFT JOIN app_user cu ON cu.id = m.chair_user_id + LEFT JOIN app_user su ON su.id = m.secretary_user_id + LEFT JOIN app_user qru ON qru.id = m.quorum_recorded_by + ${whereSql} + ORDER BY m.meeting_date DESC, m.start_time NULLS LAST, m.id DESC + LIMIT $${limitIdx} + OFFSET $${offsetIdx} + `, + listValues + ); + + return { + items: listResult.rows.map(mapMeetingRow), + total, + page, + page_size: pageSize, + total_pages: totalPages, + show_ended: showEnded, + q: options.q ?? "", + }; +} + +async function getMeetingById(db, id) { + const result = await db.query( + ` + SELECT m.id, + m.meeting_number, + m.title, + m.meeting_type, + m.meeting_date, + m.start_time, + m.end_time, + m.location, + m.status, + m.is_quorate, + m.quorum_recorded_at, + m.quorum_recorded_by, + qru.full_name AS quorum_recorded_by_name, + m.protocol_text, + m.committee_id, + c.name AS committee_name, + m.chair_user_id, + cu.full_name AS chair_name, + m.secretary_user_id, + su.full_name AS secretary_name, + m.created_at, + m.updated_at, + ( + SELECT COUNT(*)::int + FROM meeting_agenda_item mai + WHERE mai.meeting_id = m.id + ) AS agenda_item_count + FROM meeting m + LEFT JOIN committee c ON c.id = m.committee_id + LEFT JOIN app_user cu ON cu.id = m.chair_user_id + LEFT JOIN app_user su ON su.id = m.secretary_user_id + LEFT JOIN app_user qru ON qru.id = m.quorum_recorded_by + WHERE m.id = $1 + `, + [id] + ); + + return mapMeetingRow(result.rows[0]); +} + +async function assertReferences(db, payload) { + if (payload.committee_id !== undefined && payload.committee_id !== null) { + const ref = await db.query(`SELECT id FROM committee WHERE id = $1`, [payload.committee_id]); + if (!ref.rows[0]) { + throw Object.assign(new Error("Ausschuss nicht gefunden"), { + status: 400, + publicMessage: "Ausschuss nicht gefunden", + }); + } + } + + for (const [field, label] of [ + ["chair_user_id", "Leitung"], + ["secretary_user_id", "Schriftführung"], + ]) { + if (payload[field] !== undefined && payload[field] !== null) { + const ref = await db.query( + ` + SELECT id + FROM app_user + WHERE id = $1 + AND is_active = TRUE + `, + [payload[field]] + ); + + if (!ref.rows[0]) { + throw Object.assign(new Error(`${label} nicht gefunden`), { + status: 400, + publicMessage: `${label} nicht gefunden`, + }); + } + } + } +} + +async function completeMeetingDecisions(db, meetingId, actorId) { + const result = await db.query( + `UPDATE decision + SET status = 'abgeschlossen', + updated_by = $2, + updated_at = NOW() + WHERE meeting_id = $1 + AND status <> 'abgeschlossen' + RETURNING id`, + [meetingId, actorId] + ); + + return result.rows.map((row) => row.id); +} + +async function cleanupMeetingAttachments(db, meetingId) { + const result = await db.query( + `WITH candidate_documents AS ( + SELECT DISTINCT d.id + FROM document_item d + LEFT JOIN br_case c ON c.id = d.case_id + LEFT JOIN meeting_agenda_item ai + ON ai.case_id = d.case_id + AND ai.meeting_id = $1 + WHERE d.deleted_at IS NULL + AND ( + (d.meeting_id = $1 AND COALESCE(c.legal_hold, FALSE) = FALSE) + OR (ai.meeting_id = $1 AND COALESCE(c.legal_hold, FALSE) = FALSE) + ) + ) + UPDATE document_item d + SET deleted_at = NOW(), + updated_at = NOW() + FROM candidate_documents cd + WHERE d.id = cd.id + RETURNING d.id, d.file_name, d.case_id, d.meeting_id`, + [meetingId] + ); + + return result.rows; +} + +async function finalizeMeetingClosure(db, meetingId, actorId) { + const completedDecisionIds = await completeMeetingDecisions(db, meetingId, actorId); + const cleanedAttachments = await cleanupMeetingAttachments(db, meetingId); + const protocolDocument = await ensureMeetingProtocolDocument({ + db, + meetingId: Number(meetingId), + actorId, + }); + const decisionReportDocument = await ensureMeetingDecisionReportDocument({ + db, + meetingId: Number(meetingId), + actorId, + }); + + return { + completedDecisionIds, + cleanedAttachments, + protocolDocument, + decisionReportDocument, + }; +} + +async function updateMeetingStatus(db, meetingId, status, actorId, options = {}) { + const current = await getMeetingById(db, meetingId); + if (!current) return null; + + const transitionedToEnded = status === "beendet" && current.status !== "beendet"; + + let result; + if (status === "gestartet") { + assertQuorumAnswer(options.isQuorate); + result = await db.query( + ` + UPDATE meeting + SET status = $2, + is_quorate = $3, + quorum_recorded_at = NOW(), + quorum_recorded_by = $4, + updated_by = $4 + WHERE id = $1 + RETURNING id + `, + [meetingId, status, options.isQuorate, actorId] + ); + } else { + result = await db.query( + ` + UPDATE meeting + SET status = $2, + updated_by = $3 + WHERE id = $1 + RETURNING id + `, + [meetingId, status, actorId] + ); + } + + if (!result.rows[0]) return null; + + let completedDecisionIds = []; + let cleanedAttachments = []; + let protocolDocument = null; + let decisionReportDocument = null; + + if (transitionedToEnded) { + const finalized = await finalizeMeetingClosure(db, meetingId, actorId); + completedDecisionIds = finalized.completedDecisionIds; + cleanedAttachments = finalized.cleanedAttachments; + protocolDocument = finalized.protocolDocument; + decisionReportDocument = finalized.decisionReportDocument; + } + + return { + meeting: await getMeetingById(db, meetingId), + transitionedToEnded, + completedDecisionIds, + cleanedAttachments, + protocolDocument, + decisionReportDocument, + }; +} + +async function getAgendaModalData(db, meetingId) { + const meeting = await getMeetingById(db, meetingId); + if (!meeting) return null; + + const [assigned, unassigned] = await Promise.all([ + db.query( + ` + SELECT ai.id, + ai.meeting_id, + ai.case_id, + c.case_number, + ai.agenda_number, + ai.title, + ai.description, + ai.duration_minutes, + ai.status, + ai.parent_agenda_item_id, + parent_ai.agenda_number AS parent_agenda_number, + parent_ai.title AS parent_title, + ai.root_agenda_item_id, + ai.follow_up_type, + ai.follow_up_note, + ai.follow_up_created_from_meeting_id, + source_meeting.meeting_number AS follow_up_source_meeting_number, + ai.follow_up_created_from_agenda_item_id, + ai.follow_up_created_at, + ai.follow_up_created_by, + fubu.full_name AS follow_up_created_by_name, + ai.returned_from_meeting_id, + ai.returned_at, + ai.returned_by, + ru.full_name AS returned_by_name, + COALESCE(ai.follow_up_note, last_return.remark) AS last_return_remark, + COALESCE(ai.follow_up_type, last_return.return_type) AS last_return_type, + last_return.follow_up_agenda_item_id, + ai.created_at, + ( + SELECT COUNT(*)::int + FROM document_item d + WHERE d.deleted_at IS NULL + AND d.scan_status = 'clean' + AND d.document_type <> 'protokoll' + AND ( + (ai.case_id IS NOT NULL AND d.case_id = ai.case_id) + OR (ai.meeting_id IS NOT NULL AND d.meeting_id = ai.meeting_id) + ) + ) AS report_count, + ( + SELECT COUNT(*)::int + FROM comment_item ci + WHERE ci.deleted_at IS NULL + AND ci.entity_type = 'agenda_item' + AND ci.entity_id = ai.id + ) AS comment_count, + ( + SELECT COUNT(*)::int + FROM comment_item ci + WHERE ci.deleted_at IS NULL + AND ci.entity_type = 'agenda_item' + AND ci.entity_id = ai.id + AND COALESCE(ci.include_in_protocol, FALSE) = TRUE + ) AS protocol_comment_count + FROM meeting_agenda_item ai + LEFT JOIN br_case c ON c.id = ai.case_id + LEFT JOIN meeting_agenda_item parent_ai ON parent_ai.id = ai.parent_agenda_item_id + LEFT JOIN meeting source_meeting ON source_meeting.id = ai.follow_up_created_from_meeting_id + LEFT JOIN app_user fubu ON fubu.id = ai.follow_up_created_by + LEFT JOIN app_user ru ON ru.id = ai.returned_by + LEFT JOIN LATERAL ( + SELECT l.remark, l.return_type, l.follow_up_agenda_item_id + FROM meeting_agenda_item_return_log l + WHERE l.agenda_item_id = ai.id + ORDER BY l.returned_at DESC, l.id DESC + LIMIT 1 + ) last_return ON TRUE + WHERE ai.meeting_id = $1 + ORDER BY + CASE + WHEN ai.agenda_number ~ '^[0-9]+$' THEN ai.agenda_number::int + ELSE NULL + END NULLS LAST, + ai.agenda_number NULLS LAST, + ai.id + `, + [meetingId] + ), + db.query( + ` + SELECT ai.id, + ai.meeting_id, + ai.case_id, + c.case_number, + ai.agenda_number, + ai.title, + ai.description, + ai.duration_minutes, + ai.status, + ai.parent_agenda_item_id, + parent_ai.agenda_number AS parent_agenda_number, + parent_ai.title AS parent_title, + ai.root_agenda_item_id, + ai.follow_up_type, + ai.follow_up_note, + ai.follow_up_created_from_meeting_id, + source_meeting.meeting_number AS follow_up_source_meeting_number, + ai.follow_up_created_from_agenda_item_id, + ai.follow_up_created_at, + ai.follow_up_created_by, + fubu.full_name AS follow_up_created_by_name, + ai.returned_from_meeting_id, + ai.returned_at, + ai.returned_by, + ru.full_name AS returned_by_name, + COALESCE(ai.follow_up_note, last_return.remark) AS last_return_remark, + COALESCE(ai.follow_up_type, last_return.return_type) AS last_return_type, + last_return.follow_up_agenda_item_id, + ai.created_at, + ( + SELECT COUNT(*)::int + FROM document_item d + WHERE d.deleted_at IS NULL + AND d.scan_status = 'clean' + AND d.document_type <> 'protokoll' + AND ai.case_id IS NOT NULL + AND d.case_id = ai.case_id + ) AS report_count + FROM meeting_agenda_item ai + LEFT JOIN br_case c ON c.id = ai.case_id + LEFT JOIN meeting_agenda_item parent_ai ON parent_ai.id = ai.parent_agenda_item_id + LEFT JOIN meeting source_meeting ON source_meeting.id = ai.follow_up_created_from_meeting_id + LEFT JOIN app_user fubu ON fubu.id = ai.follow_up_created_by + LEFT JOIN app_user ru ON ru.id = ai.returned_by + LEFT JOIN LATERAL ( + SELECT l.remark, l.return_type, l.follow_up_agenda_item_id + FROM meeting_agenda_item_return_log l + WHERE l.agenda_item_id = ai.id + ORDER BY l.returned_at DESC, l.id DESC + LIMIT 1 + ) last_return ON TRUE + WHERE ai.meeting_id IS NULL + ORDER BY ai.created_at DESC, ai.id DESC + ` + ), + ]); + + return { + meeting, + assigned: assigned.rows, + unassigned: unassigned.rows, + }; +} + +async function nextAgendaNumber(db, meetingId) { + const result = await db.query( + ` + SELECT COALESCE( + MAX( + CASE + WHEN agenda_number ~ '^[0-9]+$' THEN agenda_number::int + ELSE NULL + END + ), + 0 + ) + 1 AS next_number + FROM meeting_agenda_item + WHERE meeting_id = $1 + `, + [meetingId] + ); + + return String(result.rows[0].next_number); +} + +async function getAgendaItemReportData(db, meetingId, agendaItemId) { + const agendaResult = await db.query( + ` + SELECT ai.id, + ai.meeting_id, + ai.case_id, + ai.agenda_number, + ai.title, + ai.description, + ai.duration_minutes, + ai.created_at, + c.case_number, + c.title AS case_title, + c.summary AS case_summary + FROM meeting_agenda_item ai + LEFT JOIN br_case c ON c.id = ai.case_id + WHERE ai.id = $2::bigint + AND ai.meeting_id = $1::bigint + `, + [meetingId, agendaItemId] + ); + + const agendaItem = agendaResult.rows[0]; + if (!agendaItem) return null; + + const orderBy = ` + ORDER BY CASE WHEN d.document_type = 'bericht' THEN 0 ELSE 1 END, + d.uploaded_at DESC, + d.id DESC + `; + + async function runReportQuery(includeDeletedAtFilter) { + const deletedAtFilter = includeDeletedAtFilter ? "d.deleted_at IS NULL AND" : ""; + + if (agendaItem.case_id) { + return db.query( + ` + SELECT d.id, + d.document_number, + d.title, + d.document_type, + d.description, + d.file_name, + d.mime_type, + d.size_bytes, + d.uploaded_at, + d.scan_status, + d.case_id, + d.meeting_id, + uploader.full_name AS uploaded_by_name + FROM document_item d + LEFT JOIN app_user uploader ON uploader.id = d.uploaded_by + WHERE ${deletedAtFilter} + d.scan_status = 'clean' + AND d.document_type <> 'protokoll' + AND (d.case_id = $2::bigint OR d.meeting_id = $1::bigint) + ${orderBy} + `, + [meetingId, agendaItem.case_id] + ); + } + + return db.query( + ` + SELECT d.id, + d.document_number, + d.title, + d.document_type, + d.description, + d.file_name, + d.mime_type, + d.size_bytes, + d.uploaded_at, + d.scan_status, + d.case_id, + d.meeting_id, + uploader.full_name AS uploaded_by_name + FROM document_item d + LEFT JOIN app_user uploader ON uploader.id = d.uploaded_by + WHERE ${deletedAtFilter} + d.scan_status = 'clean' + AND d.document_type <> 'protokoll' + AND d.meeting_id = $1::bigint + ${orderBy} + `, + [meetingId] + ); + } + + let reportRows = []; + try { + reportRows = (await runReportQuery(true)).rows; + } catch (err) { + if (["42703", "42P18"].includes(err?.code)) { + reportRows = (await runReportQuery(false)).rows; + } else { + throw err; + } + } + + return { + meeting_id: Number(meetingId), + agenda_item: agendaItem, + reports: reportRows, + }; +} + +function mapAgendaComment(row) { + if (!row) return null; + return { + id: row.id, + entity_type: row.entity_type, + entity_id: row.entity_id, + comment_text: row.comment_text, + visibility: row.visibility, + include_in_protocol: Boolean(row.include_in_protocol), + author_user_id: row.author_user_id, + author_name: row.author_name, + created_at: row.created_at, + }; +} + +async function assertMeetingAgendaItem(db, meetingId, agendaItemId) { + const result = await db.query( + `SELECT id, + meeting_id, + agenda_number, + title, + description, + duration_minutes, + case_id, + status, + parent_agenda_item_id, + root_agenda_item_id + FROM meeting_agenda_item + WHERE id = $2::bigint + AND meeting_id = $1::bigint`, + [meetingId, agendaItemId] + ); + + const row = result.rows[0]; + if (!row) { + throw Object.assign(new Error("TOP oder Sitzung nicht gefunden"), { + status: 404, + publicMessage: "TOP oder Sitzung nicht gefunden", + }); + } + return row; +} + +async function getAgendaComments(db, meetingId, agendaItemId) { + const agendaItem = await assertMeetingAgendaItem(db, meetingId, agendaItemId); + const result = await db.query( + `SELECT c.id, + c.entity_type, + c.entity_id, + c.comment_text, + c.visibility, + COALESCE(c.include_in_protocol, FALSE) AS include_in_protocol, + c.author_user_id, + au.full_name AS author_name, + c.created_at + FROM comment_item c + LEFT JOIN app_user au ON au.id = c.author_user_id + WHERE c.deleted_at IS NULL + AND c.entity_type = 'agenda_item' + AND c.entity_id = $1::bigint + ORDER BY c.created_at DESC, c.id DESC`, + [agendaItemId] + ); + + return { + meeting_id: Number(meetingId), + agenda_item: agendaItem, + comments: result.rows.map(mapAgendaComment), + }; +} + +async function getAgendaComment(db, meetingId, agendaItemId, commentId) { + await assertMeetingAgendaItem(db, meetingId, agendaItemId); + const result = await db.query( + `SELECT c.id, + c.entity_type, + c.entity_id, + c.comment_text, + c.visibility, + COALESCE(c.include_in_protocol, FALSE) AS include_in_protocol, + c.author_user_id, + au.full_name AS author_name, + c.created_at + FROM comment_item c + LEFT JOIN app_user au ON au.id = c.author_user_id + WHERE c.deleted_at IS NULL + AND c.id = $1::bigint + AND c.entity_type = 'agenda_item' + AND c.entity_id = $2::bigint`, + [commentId, agendaItemId] + ); + return mapAgendaComment(result.rows[0]); +} + +router.get("/", async (req, res, next) => { + try { + const hasQueryMode = + req.query.show_ended !== undefined || + req.query.page !== undefined || + req.query.page_size !== undefined || + req.query.q !== undefined; + + if (hasQueryMode) { + const data = await withDbSession(req.user, (db) => + getMeetingList(db, { + showEnded: req.query.show_ended, + page: req.query.page, + pageSize: req.query.page_size, + q: req.query.q, + }) + ); + return res.json(data); + } + + const data = await withDbSession(req.user, (db) => + getMeetingList(db, { + showEnded: false, + page: 1, + pageSize: 100, + q: "", + }) + ); + + res.json(data.items); + } catch (err) { + next(err); + } +}); + +router.get("/:id", async (req, res, next) => { + try { + const row = await withDbSession(req.user, (db) => getMeetingById(db, req.params.id)); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.get("/:id/agenda-items", async (req, res, next) => { + try { + const data = await withDbSession(req.user, (db) => getAgendaModalData(db, req.params.id)); + if (!data) return res.status(404).json({ error: "Sitzung nicht gefunden" }); + res.json(data); + } catch (err) { + next(err); + } +}); + +router.get("/:id/agenda-items/:agendaItemId/reports", async (req, res, next) => { + try { + const data = await withDbSession(req.user, (db) => + getAgendaItemReportData(db, req.params.id, req.params.agendaItemId) + ); + if (!data) return res.status(404).json({ error: "TOP oder Sitzung nicht gefunden" }); + res.json(data); + } catch (err) { + next(err); + } +}); + +router.get("/:id/agenda-items/:agendaItemId/comments", async (req, res, next) => { + try { + const data = await withDbSession(req.user, (db) => + getAgendaComments(db, req.params.id, req.params.agendaItemId) + ); + res.json(data); + } catch (err) { + next(err); + } +}); + +router.post( + "/:id/agenda-items/:agendaItemId/comments", + requireMfa, + requirePermission("comments.write", ["admin", "chair", "secretary", "member"]), + validate(agendaCommentSchema), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + await assertMeetingAgendaItem(db, req.params.id, req.params.agendaItemId); + const result = await db.query( + `INSERT INTO comment_item(entity_type, entity_id, comment_text, visibility, author_user_id, include_in_protocol) + VALUES ('agenda_item', $1, $2, $3, $4, $5) + RETURNING id`, + [ + req.params.agendaItemId, + req.body.comment_text.trim(), + req.body.visibility || "intern", + Number(req.user.sub), + Boolean(req.body.include_in_protocol), + ] + ); + return getAgendaComment(db, req.params.id, req.params.agendaItemId, result.rows[0].id); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_AGENDA_COMMENT_CREATED", + entityType: "comment", + entityId: row.id, + ipAddress: req.ip, + metadata: { + meeting_id: Number(req.params.id), + agenda_item_id: Number(req.params.agendaItemId), + include_in_protocol: row.include_in_protocol, + }, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +router.put( + "/:id/agenda-items/:agendaItemId/comments/:commentId", + requireMfa, + requirePermission("comments.write", ["admin", "chair", "secretary", "member"]), + validate(agendaCommentUpdateSchema), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getAgendaComment(db, req.params.id, req.params.agendaItemId, req.params.commentId); + if (!existing) return null; + + const fields = []; + const values = []; + let idx = 1; + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + if (req.body.comment_text !== undefined) push("comment_text", req.body.comment_text.trim()); + if (req.body.visibility !== undefined) push("visibility", req.body.visibility); + if (req.body.include_in_protocol !== undefined) push("include_in_protocol", Boolean(req.body.include_in_protocol)); + + if (!fields.length) { + throw Object.assign(new Error("Keine Felder zum Aktualisieren"), { + status: 400, + publicMessage: "Keine Felder zum Aktualisieren.", + }); + } + + values.push(req.params.commentId); + await db.query(`UPDATE comment_item SET ${fields.join(", ")} WHERE id = $${idx}`, values); + return getAgendaComment(db, req.params.id, req.params.agendaItemId, req.params.commentId); + }); + + if (!row) return res.status(404).json({ error: "Kommentar nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_AGENDA_COMMENT_UPDATED", + entityType: "comment", + entityId: row.id, + ipAddress: req.ip, + metadata: { + meeting_id: Number(req.params.id), + agenda_item_id: Number(req.params.agendaItemId), + changes: Object.keys(req.body || {}), + }, + }); + + res.json(row); + } catch (err) { + next(err); + } + } +); + +router.delete( + "/:id/agenda-items/:agendaItemId/comments/:commentId", + requireMfa, + requirePermission("comments.write", ["admin", "chair", "secretary", "member"]), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getAgendaComment(db, req.params.id, req.params.agendaItemId, req.params.commentId); + if (!existing) return null; + await db.query(`UPDATE comment_item SET deleted_at = NOW() WHERE id = $1`, [req.params.commentId]); + return existing; + }); + + if (!row) return res.status(404).json({ error: "Kommentar nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_AGENDA_COMMENT_DELETED", + entityType: "comment", + entityId: row.id, + ipAddress: req.ip, + metadata: { + meeting_id: Number(req.params.id), + agenda_item_id: Number(req.params.agendaItemId), + }, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } + } +); + +router.post( + "/:id/agenda-items/assign", + requireMfa, + requirePermission("meetings.write", ["admin", "chair", "secretary"]), + validate(assignAgendaSchema), + async (req, res, next) => { + try { + const data = await withDbSession(req.user, async (db) => { + const meeting = await getMeetingById(db, req.params.id); + if (!meeting) { + throw Object.assign(new Error("Sitzung nicht gefunden"), { + status: 404, + publicMessage: "Sitzung nicht gefunden", + }); + } + + const agendaItemResult = await db.query( + ` + SELECT id, meeting_id + FROM meeting_agenda_item + WHERE id = $1 + `, + [req.body.agenda_item_id] + ); + + const item = agendaItemResult.rows[0]; + if (!item) { + throw Object.assign(new Error("TOP nicht gefunden"), { + status: 404, + publicMessage: "TOP nicht gefunden", + }); + } + + if (item.meeting_id) { + throw Object.assign(new Error("TOP ist bereits einer Sitzung zugeordnet"), { + status: 400, + publicMessage: "TOP ist bereits einer Sitzung zugeordnet", + }); + } + + const agendaNumber = await nextAgendaNumber(db, req.params.id); + + await db.query( + ` + UPDATE meeting_agenda_item + SET meeting_id = $2, + agenda_number = $3, + status = 'in_sitzung' + WHERE id = $1 + `, + [req.body.agenda_item_id, req.params.id, agendaNumber] + ); + + return getAgendaModalData(db, req.params.id); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_AGENDA_ITEM_ASSIGNED", + entityType: "meeting", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { agenda_item_id: req.body.agenda_item_id }, + }); + + res.json(data); + } catch (err) { + next(err); + } + } +); + +router.post( + "/:id/agenda-items/create-and-assign", + requireMfa, + requirePermission("meetings.write", ["admin", "chair", "secretary"]), + validate(createAndAssignAgendaSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + description: normalizeNullableText(req.body.description), + case_id: normalizeNullableNumber(req.body.case_id), + }; + + const row = await withDbSession(req.user, async (db) => { + const meeting = await getMeetingById(db, req.params.id); + if (!meeting) { + throw Object.assign(new Error("Sitzung nicht gefunden"), { + status: 404, + publicMessage: "Sitzung nicht gefunden", + }); + } + + if (payload.case_id) { + const caseRef = await db.query(`SELECT id FROM br_case WHERE id = $1`, [payload.case_id]); + if (!caseRef.rows[0]) { + throw Object.assign(new Error("Vorgang nicht gefunden"), { + status: 400, + publicMessage: "Vorgang nicht gefunden", + }); + } + } + + const agendaNumber = await nextAgendaNumber(db, req.params.id); + + const result = await db.query( + ` + INSERT INTO meeting_agenda_item( + meeting_id, + case_id, + agenda_number, + title, + description, + duration_minutes, + status + ) + VALUES ($1, $2, $3, $4, $5, $6, 'in_sitzung') + RETURNING * + `, + [ + req.params.id, + payload.case_id ?? null, + agendaNumber, + payload.title.trim(), + payload.description, + payload.duration_minutes ?? null, + ] + ); + + return result.rows[0]; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_AGENDA_ITEM_CREATED_AND_ASSIGNED", + entityType: "meeting", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { agenda_item_id: row.id }, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +router.post( + "/:id/agenda-items/:agendaItemId/return-to-pool", + requireMfa, + requirePermission("meetings.write", ["admin", "chair", "secretary"]), + validate(agendaReturnSchema), + async (req, res, next) => { + try { + const payload = { + remark: req.body.remark.trim(), + return_type: req.body.return_type || "rueckfrage", + include_in_protocol: Boolean(req.body.include_in_protocol ?? true), + }; + + const result = await withDbSession(req.user, async (db) => { + const meeting = await getMeetingById(db, req.params.id); + if (!meeting) { + throw Object.assign(new Error("Sitzung nicht gefunden"), { + status: 404, + publicMessage: "Sitzung nicht gefunden", + }); + } + + if (meeting.status === "beendet") { + throw Object.assign(new Error("Sitzung ist beendet"), { + status: 400, + publicMessage: "Aus einer beendeten Sitzung kann kein Folge-TOP mehr erzeugt werden.", + }); + } + + const agendaItem = await assertMeetingAgendaItem(db, req.params.id, req.params.agendaItemId); + const rootAgendaItemId = agendaItem.root_agenda_item_id || agendaItem.parent_agenda_item_id || agendaItem.id; + const followUpStatus = payload.return_type === "klaerung" ? "klaerung" : "rueckfrage"; + const followUpLabel = payload.return_type === "klaerung" ? "Klärung" : payload.return_type === "bemerkung" ? "Bemerkung / Wiedervorlage" : "Rückfrage"; + + const followUpInsert = await db.query( + `INSERT INTO meeting_agenda_item( + meeting_id, + agenda_number, + title, + description, + duration_minutes, + case_id, + status, + parent_agenda_item_id, + root_agenda_item_id, + follow_up_type, + follow_up_note, + follow_up_created_from_meeting_id, + follow_up_created_from_agenda_item_id, + follow_up_created_at, + follow_up_created_by + ) VALUES ( + NULL, NULL, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), $12 + ) + RETURNING id`, + [ + agendaItem.title, + agendaItem.description, + agendaItem.duration_minutes, + agendaItem.case_id, + followUpStatus, + agendaItem.id, + rootAgendaItemId, + payload.return_type, + payload.remark, + req.params.id, + agendaItem.id, + req.user.sub, + ] + ); + + const followUpAgendaItemId = followUpInsert.rows[0].id; + + await db.query( + `INSERT INTO meeting_agenda_item_return_log( + agenda_item_id, + meeting_id, + agenda_number_at_return, + title_at_return, + return_type, + remark, + include_in_protocol, + returned_by, + follow_up_agenda_item_id + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [ + agendaItem.id, + req.params.id, + agendaItem.agenda_number, + agendaItem.title, + payload.return_type, + payload.remark, + payload.include_in_protocol, + req.user.sub, + followUpAgendaItemId, + ] + ); + + await db.query( + `INSERT INTO comment_item(entity_type, entity_id, comment_text, visibility, author_user_id, include_in_protocol) + VALUES ('agenda_item', $1, $2, 'intern', $3, FALSE)`, + [agendaItem.id, `${followUpLabel}: ${payload.remark}`, req.user.sub] + ); + + await db.query( + `INSERT INTO comment_item(entity_type, entity_id, comment_text, visibility, author_user_id, include_in_protocol) + VALUES ('agenda_item', $1, $2, 'intern', $3, FALSE)`, + [followUpAgendaItemId, `Folge-TOP aus Sitzung ${meeting.meeting_number || meeting.id}: ${followUpLabel}. ${payload.remark}`, req.user.sub] + ); + + await db.query( + `UPDATE meeting_agenda_item + SET status = $3 + WHERE id = $1 + AND meeting_id = $2`, + [agendaItem.id, req.params.id, followUpStatus] + ); + + return { + data: await getAgendaModalData(db, req.params.id), + follow_up_agenda_item_id: Number(followUpAgendaItemId), + }; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_AGENDA_ITEM_FOLLOW_UP_CREATED", + entityType: "meeting", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { + agenda_item_id: Number(req.params.agendaItemId), + follow_up_agenda_item_id: result.follow_up_agenda_item_id, + return_type: payload.return_type, + include_in_protocol: payload.include_in_protocol, + }, + }); + + res.json(result.data); + } catch (err) { + next(err); + } + } +); + +router.post( + "/:id/agenda-items/:agendaItemId/unassign", + requireMfa, + requirePermission("meetings.write", ["admin", "chair", "secretary"]), + async (req, res, next) => { + try { + const data = await withDbSession(req.user, async (db) => { + const result = await db.query( + ` + UPDATE meeting_agenda_item + SET meeting_id = NULL, + agenda_number = NULL, + status = 'offen' + WHERE id = $1 + AND meeting_id = $2 + RETURNING id + `, + [req.params.agendaItemId, req.params.id] + ); + + if (!result.rows[0]) { + throw Object.assign(new Error("TOP nicht gefunden"), { + status: 404, + publicMessage: "TOP nicht gefunden", + }); + } + + return getAgendaModalData(db, req.params.id); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_AGENDA_ITEM_UNASSIGNED", + entityType: "meeting", + entityId: Number(req.params.id), + ipAddress: req.ip, + metadata: { agenda_item_id: Number(req.params.agendaItemId) }, + }); + + res.json(data); + } catch (err) { + next(err); + } + } +); + +router.post( + "/", + requireMfa, + requirePermission("meetings.write", ["admin", "chair", "secretary"]), + validate(createSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + meeting_number: normalizeOptionalText(req.body.meeting_number), + title: req.body.title.trim(), + location: normalizeNullableText(req.body.location), + is_quorate: req.body.is_quorate, + protocol_text: normalizeNullableText(req.body.protocol_text), + committee_id: normalizeNullableNumber(req.body.committee_id), + chair_user_id: normalizeNullableNumber(req.body.chair_user_id), + secretary_user_id: normalizeNullableNumber(req.body.secretary_user_id), + }; + + const row = await withDbSession(req.user, async (db) => { + await assertReferences(db, payload); + + if (payload.status === "gestartet") { + assertQuorumAnswer(payload.is_quorate); + } + + const result = await db.query( + ` + INSERT INTO meeting( + meeting_number, + title, + meeting_type, + meeting_date, + start_time, + end_time, + location, + status, + is_quorate, + quorum_recorded_at, + quorum_recorded_by, + protocol_text, + committee_id, + chair_user_id, + secretary_user_id, + created_by, + updated_by + ) VALUES ( + COALESCE(NULLIF($1::text, ''), next_meeting_number($4::date)), + $2, $3, $4, $5, $6, $7, $8, $9, + CASE WHEN $9::boolean IS NULL THEN NULL ELSE NOW() END, + CASE WHEN $9::boolean IS NULL THEN NULL ELSE $14 END, + $10, $11, $12, $13, $14, $14 + ) + RETURNING id + `, + [ + payload.meeting_number ?? null, + payload.title, + payload.meeting_type, + payload.meeting_date, + payload.start_time || null, + payload.end_time || null, + payload.location, + payload.status, + payload.status === "gestartet" ? payload.is_quorate : null, + payload.protocol_text, + payload.committee_id, + payload.chair_user_id, + payload.secretary_user_id, + req.user.sub, + ] + ); + + return getMeetingById(db, result.rows[0].id); + }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_CREATED", + entityType: "meeting", + entityId: row.id, + ipAddress: req.ip, + metadata: { payload }, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +router.put( + "/:id", + requireMfa, + requirePermission("meetings.write", ["admin", "chair", "secretary"]), + validate(updateSchema), + async (req, res, next) => { + try { + const payload = { + ...req.body, + title: req.body.title !== undefined ? req.body.title.trim() : undefined, + location: normalizeNullableText(req.body.location), + is_quorate: req.body.is_quorate, + protocol_text: normalizeNullableText(req.body.protocol_text), + committee_id: normalizeNullableNumber(req.body.committee_id), + chair_user_id: normalizeNullableNumber(req.body.chair_user_id), + secretary_user_id: normalizeNullableNumber(req.body.secretary_user_id), + }; + + const row = await withDbSession(req.user, async (db) => { + const existing = await getMeetingById(db, req.params.id); + if (!existing) return null; + + await assertReferences(db, payload); + + const fields = []; + const values = []; + let idx = 1; + + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + const allowed = [ + ["title", payload.title], + ["meeting_type", payload.meeting_type], + ["meeting_date", payload.meeting_date], + ["start_time", payload.start_time], + ["end_time", payload.end_time], + ["location", payload.location], + ["status", payload.status], + ["protocol_text", payload.protocol_text], + ["committee_id", payload.committee_id], + ["chair_user_id", payload.chair_user_id], + ["secretary_user_id", payload.secretary_user_id], + ]; + + for (const [column, value] of allowed) { + if (value !== undefined) push(column, value); + } + + const current = await getMeetingById(db, req.params.id); + const nextStatus = payload.status ?? current?.status; + const transitionedToStarted = current && current.status !== "gestartet" && nextStatus === "gestartet"; + const transitionedToEnded = current && current.status !== "beendet" && nextStatus === "beendet"; + + if (transitionedToStarted || payload.is_quorate !== undefined) { + assertQuorumAnswer(payload.is_quorate); + push("is_quorate", payload.is_quorate); + fields.push("quorum_recorded_at = NOW()"); + push("quorum_recorded_by", req.user.sub); + } + + if (!fields.length) { + throw Object.assign(new Error("Keine Felder zum Aktualisieren"), { + status: 400, + publicMessage: "Keine Felder zum Aktualisieren", + }); + } + + push("updated_by", req.user.sub); + values.push(req.params.id); + + await db.query( + ` + UPDATE meeting + SET ${fields.join(", ")} + WHERE id = $${idx} + RETURNING id + `, + values + ); + + let completedDecisionIds = []; + let cleanedAttachments = []; + let protocolDocument = null; + let decisionReportDocument = null; + + if (transitionedToEnded) { + const finalized = await finalizeMeetingClosure(db, req.params.id, req.user.sub); + completedDecisionIds = finalized.completedDecisionIds; + cleanedAttachments = finalized.cleanedAttachments; + protocolDocument = finalized.protocolDocument; + decisionReportDocument = finalized.decisionReportDocument; + } + + return { + meeting: await getMeetingById(db, req.params.id), + completedDecisionIds, + cleanedAttachments, + protocolDocument, + decisionReportDocument, + }; + }); + + if (!row?.meeting) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_UPDATED", + entityType: "meeting", + entityId: row.meeting.id, + ipAddress: req.ip, + metadata: { + changes: payload, + completed_decision_ids: row.completedDecisionIds || [], + cleaned_attachment_ids: (row.cleanedAttachments || []).map((item) => item.id), + protocol_document_id: row.protocolDocument?.id || null, + decision_report_document_id: row.decisionReportDocument?.id || null, + }, + }); + + res.json({ + ...row.meeting, + protocol_document: row.protocolDocument || null, + decision_report_document: row.decisionReportDocument || null, + }); + } catch (err) { + next(err); + } + } +); + +router.post( + "/:id/start", + requireMfa, + requirePermission("meetings.write", ["admin", "chair", "secretary"]), + validate(startMeetingSchema), + async (req, res, next) => { + try { + const result = await withDbSession(req.user, async (db) => + updateMeetingStatus(db, req.params.id, "gestartet", req.user.sub, { + isQuorate: req.body.is_quorate, + }) + ); + + if (!result?.meeting) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_STARTED", + entityType: "meeting", + entityId: result.meeting.id, + ipAddress: req.ip, + metadata: { is_quorate: req.body.is_quorate }, + }); + + res.json(result.meeting); + } catch (err) { + next(err); + } +}); + +router.post("/:id/stop", requireMfa, requirePermission("meetings.write", ["admin", "chair", "secretary"]), async (req, res, next) => { + try { + const result = await withDbSession(req.user, async (db) => + updateMeetingStatus(db, req.params.id, "beendet", req.user.sub) + ); + + if (!result?.meeting) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_STOPPED", + entityType: "meeting", + entityId: result.meeting.id, + ipAddress: req.ip, + metadata: { + protocol_document_id: result.protocolDocument?.id || null, + decision_report_document_id: result.decisionReportDocument?.id || null, + completed_decision_ids: result.completedDecisionIds || [], + }, + }); + + res.json({ + ...result.meeting, + protocol_document: result.protocolDocument || null, + decision_report_document: result.decisionReportDocument || null, + }); + } catch (err) { + next(err); + } +}); + +router.delete("/:id", requireMfa, requirePermission("meetings.write", ["admin", "chair", "secretary"]), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const existing = await getMeetingById(db, req.params.id); + if (!existing) return null; + + await db.query(`DELETE FROM meeting WHERE id = $1`, [req.params.id]); + return existing; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "MEETING_DELETED", + entityType: "meeting", + entityId: row.id, + ipAddress: req.ip, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/modules/notifications/routes.js b/backend/src/modules/notifications/routes.js new file mode 100644 index 0000000..a1db130 --- /dev/null +++ b/backend/src/modules/notifications/routes.js @@ -0,0 +1,348 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission, requireRoles } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { writeAudit } from "../../utils/audit.js"; + +const router = express.Router(); +router.use(authenticate); + +const createSchema = z.object({ + user_id: z.coerce.number().int().positive(), + notification_type: z.string().min(2).max(50), + title: z.string().min(2).max(255), + entity_type: z.string().max(50).optional().nullable(), + entity_id: z.coerce.number().int().positive().optional().nullable(), + is_read: z.boolean().default(false), +}); + +const updateSchema = createSchema.partial(); + +function parseBoolean(value, fallback = false) { + if (value === undefined || value === null || value === "") return fallback; + if (typeof value === "boolean") return value; + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "ja", "on"].includes(normalized)) return true; + if (["0", "false", "no", "nein", "off"].includes(normalized)) return false; + return fallback; +} + +function buildSearchPattern(raw) { + const value = String(raw ?? "").trim(); + if (!value) return null; + const escaped = value.replace(/\\/g, "\\\\"); + const hasWildcard = /[%_]/.test(escaped); + return hasWildcard ? escaped : `%${escaped}%`; +} + +function mapRow(row) { + return { + id: row.id, + user_id: row.user_id, + user_name: row.user_name, + notification_type: row.notification_type, + title: row.title, + entity_type: row.entity_type, + entity_id: row.entity_id, + is_read: row.is_read, + created_at: row.created_at, + }; +} + +router.get("/", async (req, res, next) => { + try { + const onlyMine = parseBoolean(req.query.only_mine, false); + const unreadFirst = parseBoolean(req.query.unread_first, true); + const searchPattern = buildSearchPattern(req.query.q); + + const rows = await withDbSession(req.user, async (db) => { + const values = [searchPattern, onlyMine, req.user.sub, unreadFirst]; + + const result = await db.query( + ` + SELECT n.id, + n.user_id, + u.full_name AS user_name, + n.notification_type, + n.title, + n.entity_type, + n.entity_id, + n.is_read, + n.created_at + FROM notification_item n + LEFT JOIN app_user u ON u.id = n.user_id + WHERE ( + $1::text IS NULL + OR n.title ILIKE $1 ESCAPE '\\' + OR n.notification_type ILIKE $1 ESCAPE '\\' + OR COALESCE(n.entity_type, '') ILIKE $1 ESCAPE '\\' + OR COALESCE(n.entity_id::text, '') ILIKE $1 ESCAPE '\\' + OR COALESCE(u.full_name, '') ILIKE $1 ESCAPE '\\' + ) + AND ( + $2::boolean = FALSE + OR n.user_id = $3::bigint + ) + ORDER BY + CASE WHEN $4::boolean THEN CASE WHEN n.is_read THEN 1 ELSE 0 END ELSE 0 END, + n.created_at DESC, + n.id DESC + `, + values + ); + + return result.rows.map(mapRow); + }); + + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/:id", async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + ` + SELECT n.id, + n.user_id, + u.full_name AS user_name, + n.notification_type, + n.title, + n.entity_type, + n.entity_id, + n.is_read, + n.created_at + FROM notification_item n + LEFT JOIN app_user u ON u.id = n.user_id + WHERE n.id = $1 + `, + [req.params.id] + ); + + return result.rows[0] ? mapRow(result.rows[0]) : null; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.post( + "/:id/read", + requireMfa, + requirePermission("notifications.write", ["admin", "chair", "secretary", "member"]), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + ` + UPDATE notification_item + SET is_read = TRUE + WHERE id = $1 + RETURNING id, user_id, notification_type, title, entity_type, entity_id, is_read, created_at + `, + [req.params.id] + ); + + return result.rows[0] || null; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "NOTIFICATION_MARKED_READ", + entityType: "notification", + entityId: Number(req.params.id), + ipAddress: req.ip, + }); + + res.json(row); + } catch (err) { + next(err); + } + } +); + +router.post( + "/mark-all-read", + requireMfa, + requirePermission("notifications.write", ["admin", "chair", "secretary", "member"]), + async (req, res, next) => { + try { + const result = await withDbSession(req.user, async (db) => + db.query( + ` + UPDATE notification_item + SET is_read = TRUE + WHERE is_read = FALSE + AND user_id = $1 + RETURNING id + `, + [req.user.sub] + ) + ); + + await writeAudit({ + actorId: req.user.sub, + action: "NOTIFICATION_MARKED_ALL_READ", + entityType: "notification", + entityId: null, + ipAddress: req.ip, + metadata: { count: result.rowCount || 0 }, + }); + + res.json({ ok: true, count: result.rowCount || 0 }); + } catch (err) { + next(err); + } + } +); + +router.post( + "/", + requireMfa, + requirePermission("notifications.write", ["admin", "chair", "secretary", "member"]), + validate(createSchema), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + ` + INSERT INTO notification_item( + user_id, + notification_type, + title, + entity_type, + entity_id, + is_read + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, user_id, notification_type, title, entity_type, entity_id, is_read, created_at + `, + [ + req.body.user_id, + req.body.notification_type, + req.body.title, + req.body.entity_type ?? null, + req.body.entity_id ?? null, + req.body.is_read ?? false, + ] + ); + return result.rows[0]; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "NOTIFICATION_CREATED", + entityType: "notification", + entityId: row.id, + ipAddress: req.ip, + }); + + res.status(201).json(row); + } catch (err) { + next(err); + } + } +); + +router.put( + "/:id", + requireMfa, + requirePermission("notifications.write", ["admin", "chair", "secretary", "member"]), + validate(updateSchema), + async (req, res, next) => { + try { + const fields = []; + const values = []; + let idx = 1; + + const push = (column, value) => { + fields.push(`${column} = $${idx}`); + values.push(value); + idx += 1; + }; + + if (req.body.user_id !== undefined) push("user_id", req.body.user_id); + if (req.body.notification_type !== undefined) push("notification_type", req.body.notification_type); + if (req.body.title !== undefined) push("title", req.body.title); + if (req.body.entity_type !== undefined) push("entity_type", req.body.entity_type ?? null); + if (req.body.entity_id !== undefined) push("entity_id", req.body.entity_id ?? null); + if (req.body.is_read !== undefined) push("is_read", req.body.is_read); + + if (!fields.length) { + return res.status(400).json({ error: "Keine Felder zum Aktualisieren" }); + } + + values.push(req.params.id); + + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + ` + UPDATE notification_item + SET ${fields.join(", ")} + WHERE id = $${idx} + RETURNING id, user_id, notification_type, title, entity_type, entity_id, is_read, created_at + `, + values + ); + + return result.rows[0] || null; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "NOTIFICATION_UPDATED", + entityType: "notification", + entityId: Number(req.params.id), + ipAddress: req.ip, + }); + + res.json(row); + } catch (err) { + next(err); + } + } +); + +router.delete( + "/:id", + requireMfa, + requirePermission("notifications.write", ["admin", "chair", "secretary", "member"]), + async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `DELETE FROM notification_item WHERE id = $1 RETURNING *`, + [req.params.id] + ); + return result.rows[0] || null; + }); + + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + + await writeAudit({ + actorId: req.user.sub, + action: "NOTIFICATION_DELETED", + entityType: "notification", + entityId: Number(req.params.id), + ipAddress: req.ip, + }); + + res.json({ ok: true, row }); + } catch (err) { + next(err); + } + } +); + +export default router; diff --git a/backend/src/modules/permissions/routes.js b/backend/src/modules/permissions/routes.js new file mode 100644 index 0000000..a4e1dad --- /dev/null +++ b/backend/src/modules/permissions/routes.js @@ -0,0 +1,269 @@ +import express from "express"; +import { z } from "zod"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { getUserAccess } from "../../services/accessControl.js"; +import { writeAudit } from "../../utils/audit.js"; + +const router = express.Router(); +router.use(authenticate); + +const roleSchema = z.object({ + code: z.string().min(2).max(80).regex(/^[a-z0-9_.-]+$/i), + name: z.string().min(2).max(120), + description: z.string().max(2000).optional().nullable(), + is_active: z.boolean().optional().default(true), +}); + +const teamRoleSchema = z.object({ + code: z.string().min(2).max(80).regex(/^[a-z0-9_.-]+$/i), + name: z.string().min(2).max(120), + description: z.string().max(2000).optional().nullable(), + can_view_cases: z.boolean().optional().default(false), + can_write_cases: z.boolean().optional().default(false), + can_view_documents: z.boolean().optional().default(false), + can_write_documents: z.boolean().optional().default(false), + is_active: z.boolean().optional().default(true), +}); + +const matrixSchema = z.object({ + page_keys: z.array(z.string().min(1)).optional().default([]), + permission_keys: z.array(z.string().min(1)).optional().default([]), +}); + +const assignmentSchema = z.object({ + role_profile_ids: z.array(z.coerce.number().int().positive()).optional().default([]), + team_role_ids: z.array(z.coerce.number().int().positive()).optional().default([]), +}); + +async function getMatrix(db) { + const [pages, permissions, roles, rolePages, rolePermissions, teamRoles, users, userRoles, userTeamRoles] = await Promise.all([ + db.query(`SELECT page_key, path, label, nav_group, sort_order, description FROM app_page ORDER BY sort_order, label`), + db.query(`SELECT permission_key, label, category, description FROM app_permission ORDER BY category, label`), + db.query(`SELECT id, code, name, description, system_role, is_system, is_active FROM app_role_profile ORDER BY is_system DESC, name`), + db.query(`SELECT role_profile_id, page_key FROM app_role_page WHERE can_view = TRUE`), + db.query(`SELECT role_profile_id, permission_key FROM app_role_permission WHERE granted = TRUE`), + db.query(`SELECT id, code, name, description, can_view_cases, can_write_cases, can_view_documents, can_write_documents, is_active FROM app_team_role ORDER BY name`), + db.query(`SELECT id, full_name, email, role, committee_id, is_active FROM app_user ORDER BY full_name`), + db.query(`SELECT user_id, role_profile_id FROM app_user_role_profile`), + db.query(`SELECT user_id, team_role_id, committee_id FROM app_user_team_role`), + ]); + + const pagesByRole = new Map(); + for (const row of rolePages.rows) { + if (!pagesByRole.has(String(row.role_profile_id))) pagesByRole.set(String(row.role_profile_id), []); + pagesByRole.get(String(row.role_profile_id)).push(row.page_key); + } + + const permissionsByRole = new Map(); + for (const row of rolePermissions.rows) { + if (!permissionsByRole.has(String(row.role_profile_id))) permissionsByRole.set(String(row.role_profile_id), []); + permissionsByRole.get(String(row.role_profile_id)).push(row.permission_key); + } + + return { + pages: pages.rows, + permissions: permissions.rows, + role_profiles: roles.rows.map((role) => ({ + ...role, + page_keys: pagesByRole.get(String(role.id)) || [], + permission_keys: permissionsByRole.get(String(role.id)) || [], + })), + team_roles: teamRoles.rows, + users: users.rows.map((user) => ({ + ...user, + role_profile_ids: userRoles.rows.filter((row) => Number(row.user_id) === Number(user.id)).map((row) => row.role_profile_id), + team_role_ids: userTeamRoles.rows.filter((row) => Number(row.user_id) === Number(user.id)).map((row) => row.team_role_id), + team_role_assignments: userTeamRoles.rows.filter((row) => Number(row.user_id) === Number(user.id)), + })), + }; +} + +router.get("/access", async (req, res, next) => { + try { + const access = await getUserAccess(req.user.sub, req.user.role); + res.json(access); + } catch (err) { + next(err); + } +}); + +router.get("/matrix", requirePermission("system.permissions", ["admin"]), async (req, res, next) => { + try { + const data = await withDbSession(req.user, getMatrix); + res.json(data); + } catch (err) { + next(err); + } +}); + +router.post("/roles", requireMfa, requirePermission("system.permissions", ["admin"]), validate(roleSchema), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `INSERT INTO app_role_profile(code, name, description, is_system, is_active, created_by, updated_by) + VALUES ($1,$2,$3,FALSE,$4,$5,$5) + RETURNING id, code, name, description, system_role, is_system, is_active`, + [req.body.code, req.body.name, req.body.description || null, req.body.is_active, req.user.sub] + ); + return result.rows[0]; + }); + await writeAudit({ actorId: req.user.sub, action: "ROLE_PROFILE_CREATED", entityType: "role_profile", entityId: row.id, metadata: req.body, ipAddress: req.ip }); + res.status(201).json(row); + } catch (err) { + next(err); + } +}); + +router.put("/roles/:id", requireMfa, requirePermission("system.permissions", ["admin"]), validate(roleSchema.partial()), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const current = await db.query(`SELECT id, is_system FROM app_role_profile WHERE id = $1`, [req.params.id]); + if (!current.rows[0]) return null; + const result = await db.query( + `UPDATE app_role_profile + SET code = COALESCE($2, code), + name = COALESCE($3, name), + description = COALESCE($4, description), + is_active = COALESCE($5, is_active), + updated_by = $6, + updated_at = NOW() + WHERE id = $1 + RETURNING id, code, name, description, system_role, is_system, is_active`, + [req.params.id, req.body.code, req.body.name, req.body.description, req.body.is_active, req.user.sub] + ); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Rolle nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "ROLE_PROFILE_UPDATED", entityType: "role_profile", entityId: row.id, metadata: req.body, ipAddress: req.ip }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.put("/roles/:id/matrix", requireMfa, requirePermission("system.permissions", ["admin"]), validate(matrixSchema), async (req, res, next) => { + try { + const role = await withDbSession(req.user, async (db) => { + const existing = await db.query(`SELECT id, is_system FROM app_role_profile WHERE id = $1`, [req.params.id]); + if (!existing.rows[0]) return null; + + await db.query(`DELETE FROM app_role_page WHERE role_profile_id = $1`, [req.params.id]); + if (req.body.page_keys.length) { + await db.query( + `INSERT INTO app_role_page(role_profile_id, page_key, can_view) + SELECT $1::bigint, unnest($2::text[]), TRUE + ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE`, + [req.params.id, req.body.page_keys] + ); + } + + await db.query(`DELETE FROM app_role_permission WHERE role_profile_id = $1`, [req.params.id]); + if (req.body.permission_keys.length) { + await db.query( + `INSERT INTO app_role_permission(role_profile_id, permission_key, granted) + SELECT $1::bigint, unnest($2::text[]), TRUE + ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE`, + [req.params.id, req.body.permission_keys] + ); + } + + return existing.rows[0]; + }); + + if (!role) return res.status(404).json({ error: "Rolle nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "ROLE_MATRIX_UPDATED", entityType: "role_profile", entityId: Number(req.params.id), metadata: req.body, ipAddress: req.ip }); + const data = await withDbSession(req.user, getMatrix); + res.json(data); + } catch (err) { + next(err); + } +}); + +router.post("/team-roles", requireMfa, requirePermission("system.permissions", ["admin"]), validate(teamRoleSchema), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `INSERT INTO app_team_role(code, name, description, can_view_cases, can_write_cases, can_view_documents, can_write_documents, is_active, created_by, updated_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9) + RETURNING *`, + [req.body.code, req.body.name, req.body.description || null, req.body.can_view_cases, req.body.can_write_cases, req.body.can_view_documents, req.body.can_write_documents, req.body.is_active, req.user.sub] + ); + return result.rows[0]; + }); + await writeAudit({ actorId: req.user.sub, action: "TEAM_ROLE_CREATED", entityType: "team_role", entityId: row.id, metadata: req.body, ipAddress: req.ip }); + res.status(201).json(row); + } catch (err) { + next(err); + } +}); + +router.put("/team-roles/:id", requireMfa, requirePermission("system.permissions", ["admin"]), validate(teamRoleSchema.partial()), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `UPDATE app_team_role + SET code = COALESCE($2, code), + name = COALESCE($3, name), + description = COALESCE($4, description), + can_view_cases = COALESCE($5, can_view_cases), + can_write_cases = COALESCE($6, can_write_cases), + can_view_documents = COALESCE($7, can_view_documents), + can_write_documents = COALESCE($8, can_write_documents), + is_active = COALESCE($9, is_active), + updated_by = $10, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [req.params.id, req.body.code, req.body.name, req.body.description, req.body.can_view_cases, req.body.can_write_cases, req.body.can_view_documents, req.body.can_write_documents, req.body.is_active, req.user.sub] + ); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Teamrolle nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "TEAM_ROLE_UPDATED", entityType: "team_role", entityId: row.id, metadata: req.body, ipAddress: req.ip }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.put("/users/:id/assignments", requireMfa, requirePermission("system.permissions", ["admin"]), validate(assignmentSchema), async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const user = await db.query(`SELECT id FROM app_user WHERE id = $1`, [req.params.id]); + if (!user.rows[0]) return null; + + await db.query(`DELETE FROM app_user_role_profile WHERE user_id = $1`, [req.params.id]); + if (req.body.role_profile_ids.length) { + await db.query( + `INSERT INTO app_user_role_profile(user_id, role_profile_id, assigned_by) + SELECT $1::bigint, unnest($2::bigint[]), $3::bigint + ON CONFLICT (user_id, role_profile_id) DO NOTHING`, + [req.params.id, req.body.role_profile_ids, req.user.sub] + ); + } + + await db.query(`DELETE FROM app_user_team_role WHERE user_id = $1`, [req.params.id]); + if (req.body.team_role_ids.length) { + await db.query( + `INSERT INTO app_user_team_role(user_id, team_role_id, committee_id, assigned_by) + SELECT $1::bigint, unnest($2::bigint[]), NULL, $3::bigint + ON CONFLICT DO NOTHING`, + [req.params.id, req.body.team_role_ids, req.user.sub] + ); + } + + return user.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Benutzer nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "USER_ACCESS_ASSIGNED", entityType: "user", entityId: Number(req.params.id), metadata: req.body, ipAddress: req.ip }); + const data = await withDbSession(req.user, getMatrix); + res.json(data); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/modules/reports/routes.js b/backend/src/modules/reports/routes.js new file mode 100644 index 0000000..77b9556 --- /dev/null +++ b/backend/src/modules/reports/routes.js @@ -0,0 +1,146 @@ +import express from "express"; +import { authenticate } from "../../middleware/auth.js"; +import { withDbSession } from "../../config/db.js"; + +const router = express.Router(); +router.use(authenticate); + +function buildSearchPattern(raw) { + const value = String(raw ?? "").trim(); + if (!value) return null; + + const escaped = value.replace(/\\/g, "\\\\"); + const hasWildcard = /[%_]/.test(escaped); + + return hasWildcard ? escaped : `%${escaped}%`; +} + +router.get("/", async (req, res, next) => { + try { + const searchPattern = buildSearchPattern(req.query.q); + + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query( + ` + WITH latest_reports AS ( + SELECT d.id, + d.document_number, + d.title, + d.description, + d.document_type, + d.version_label, + COALESCE(d.version_number, 1) AS version_number, + COALESCE(d.version_group_id, d.id) AS version_root_id, + COUNT(*) OVER (PARTITION BY COALESCE(d.version_group_id, d.id))::int AS version_count, + d.file_name, + d.mime_type, + d.size_bytes, + d.scan_status, + d.uploaded_at, + d.meeting_id, + d.case_id, + d.decision_id, + d.uploaded_by, + ROW_NUMBER() OVER ( + PARTITION BY COALESCE(d.version_group_id, d.id) + ORDER BY COALESCE(d.version_number, 1) DESC, d.uploaded_at DESC, d.id DESC + ) AS version_rank + FROM document_item d + WHERE d.deleted_at IS NULL + AND d.document_type = 'bericht' + ), + report_rows AS ( + SELECT lr.id, + lr.document_number, + lr.title, + lr.description, + lr.document_type, + lr.version_label, + lr.version_number, + lr.version_root_id, + lr.version_count, + lr.file_name, + lr.mime_type, + lr.size_bytes, + lr.scan_status, + lr.uploaded_at, + lr.meeting_id, + m.meeting_number, + lr.case_id, + c.case_number, + lr.decision_id, + direct_decision.decision_number AS direct_decision_number, + direct_decision.title AS direct_decision_title, + uploader.full_name AS uploaded_by_name, + uploader.email AS uploaded_by_email, + COALESCE(agenda_info.agenda_refs, '') AS agenda_refs, + COALESCE(decision_info.decision_refs, '') AS decision_refs + FROM latest_reports lr + LEFT JOIN meeting m ON m.id = lr.meeting_id + LEFT JOIN br_case c ON c.id = lr.case_id + LEFT JOIN decision direct_decision ON direct_decision.id = lr.decision_id + LEFT JOIN app_user uploader ON uploader.id = lr.uploaded_by + LEFT JOIN LATERAL ( + SELECT STRING_AGG( + DISTINCT TRIM(BOTH ' ' FROM CONCAT( + CASE + WHEN ai.agenda_number IS NOT NULL THEN 'TOP ' || ai.agenda_number || ' – ' + ELSE '' + END, + COALESCE(ai.title, '') + )), + ' | ' + ) AS agenda_refs + FROM meeting_agenda_item ai + WHERE (lr.case_id IS NOT NULL AND ai.case_id = lr.case_id) + OR (direct_decision.agenda_item_id IS NOT NULL AND ai.id = direct_decision.agenda_item_id) + OR (lr.meeting_id IS NOT NULL AND ai.meeting_id = lr.meeting_id) + ) agenda_info ON TRUE + LEFT JOIN LATERAL ( + SELECT STRING_AGG( + DISTINCT TRIM(BOTH ' ' FROM CONCAT( + COALESCE(d2.decision_number, 'Beschluss ' || d2.id::text), + CASE + WHEN COALESCE(d2.title, '') <> '' THEN ' – ' || d2.title + ELSE '' + END + )), + ' | ' + ) AS decision_refs + FROM decision d2 + WHERE (lr.decision_id IS NOT NULL AND d2.id = lr.decision_id) + OR (lr.case_id IS NOT NULL AND d2.case_id = lr.case_id) + OR (lr.meeting_id IS NOT NULL AND d2.meeting_id = lr.meeting_id) + ) decision_info ON TRUE + WHERE lr.version_rank = 1 + ) + SELECT * + FROM report_rows + WHERE ( + $1::text IS NULL + OR document_number ILIKE $1 ESCAPE '\\' + OR title ILIKE $1 ESCAPE '\\' + OR COALESCE(description, '') ILIKE $1 ESCAPE '\\' + OR COALESCE(file_name, '') ILIKE $1 ESCAPE '\\' + OR COALESCE(meeting_number, '') ILIKE $1 ESCAPE '\\' + OR COALESCE(case_number, '') ILIKE $1 ESCAPE '\\' + OR COALESCE(agenda_refs, '') ILIKE $1 ESCAPE '\\' + OR COALESCE(decision_refs, '') ILIKE $1 ESCAPE '\\' + OR COALESCE(direct_decision_number, '') ILIKE $1 ESCAPE '\\' + OR COALESCE(direct_decision_title, '') ILIKE $1 ESCAPE '\\' + ) + ORDER BY uploaded_at DESC, id DESC + `, + [searchPattern] + ); + + return result.rows; + }); + + res.json(rows); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/modules/tasks/routes.js b/backend/src/modules/tasks/routes.js new file mode 100644 index 0000000..6a0295e --- /dev/null +++ b/backend/src/modules/tasks/routes.js @@ -0,0 +1,435 @@ +import express from "express"; +import { withDbSession } from "../../config/db.js"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission, requireRoles } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { writeAudit } from "../../utils/audit.js"; +import { taskSchema, taskUpdateSchema } from "./schemas.js"; + +const router = express.Router(); +router.use(authenticate); + +function asId(value) { + if (value === undefined || value === null || value === "") return null; + const n = Number(value); + return Number.isInteger(n) && n > 0 ? n : null; +} + +const referenceTables = { + app_user: "app_user", + committee: "committee", + br_case: "br_case", + meeting: "meeting", +}; + +async function ensureExists(db, tableKey, id, label, where = "") { + if (!id) return null; + + const table = referenceTables[tableKey]; + if (!table) throw new Error(`Ungültige Referenztabelle: ${tableKey}`); + + const result = await db.query(`SELECT id FROM ${table} WHERE id = $1 ${where}`, [id]); + if (!result.rows[0]) { + throw Object.assign(new Error(`${label} nicht gefunden`), { + status: 400, + publicMessage: `${label} nicht gefunden`, + }); + } + return result.rows[0]; +} + +async function getAgendaItemById(db, id) { + if (!id) return null; + const result = await db.query( + `SELECT ai.id, + ai.title, + ai.description, + ai.duration_minutes, + ai.case_id, + ai.meeting_id, + latest_decision.id AS decision_id + FROM meeting_agenda_item ai + LEFT JOIN LATERAL ( + SELECT d.id + FROM decision d + WHERE d.agenda_item_id = ai.id + ORDER BY d.created_at DESC, d.id DESC + LIMIT 1 + ) latest_decision ON TRUE + WHERE ai.id = $1`, + [id] + ); + return result.rows[0] || null; +} + +async function getDecisionById(db, id) { + if (!id) return null; + const result = await db.query( + `SELECT id, title, case_id, meeting_id, agenda_item_id + FROM decision + WHERE id = $1`, + [id] + ); + return result.rows[0] || null; +} + +async function getTaskById(db, id) { + const result = await db.query(`SELECT * FROM task WHERE id = $1 AND deleted_at IS NULL`, [id]); + return result.rows[0] || null; +} + +async function validateReferences(db, payload) { + const assignedUserId = asId(payload.assigned_user_id); + const backupUserId = asId(payload.backup_user_id); + const committeeId = asId(payload.committee_id); + const caseId = asId(payload.case_id); + const meetingId = asId(payload.meeting_id); + const agendaItemId = asId(payload.agenda_item_id); + const decisionId = asId(payload.decision_id); + + await ensureExists(db, "app_user", assignedUserId, "Zugewiesener Benutzer", "AND is_active = TRUE"); + await ensureExists(db, "app_user", backupUserId, "Stellvertretung", "AND is_active = TRUE"); + await ensureExists(db, "committee", committeeId, "Gremium"); + await ensureExists(db, "br_case", caseId, "Vorgang", "AND deleted_at IS NULL"); + await ensureExists(db, "meeting", meetingId, "Sitzung"); + + const agendaItem = await getAgendaItemById(db, agendaItemId); + if (agendaItemId && !agendaItem) { + throw Object.assign(new Error("TOP nicht gefunden"), { + status: 400, + publicMessage: "TOP nicht gefunden", + }); + } + + const decision = await getDecisionById(db, decisionId); + if (decisionId && !decision) { + throw Object.assign(new Error("Beschluss nicht gefunden"), { + status: 400, + publicMessage: "Beschluss nicht gefunden", + }); + } + + if (agendaItem && meetingId && agendaItem.meeting_id && Number(agendaItem.meeting_id) !== meetingId) { + throw Object.assign(new Error("TOP gehört nicht zur gewählten Sitzung"), { + status: 400, + publicMessage: "TOP gehört nicht zur gewählten Sitzung", + }); + } + + if (agendaItem && caseId && agendaItem.case_id && Number(agendaItem.case_id) !== caseId) { + throw Object.assign(new Error("TOP gehört nicht zum gewählten Vorgang"), { + status: 400, + publicMessage: "TOP gehört nicht zum gewählten Vorgang", + }); + } + + if (decision && meetingId && decision.meeting_id && Number(decision.meeting_id) !== meetingId) { + throw Object.assign(new Error("Beschluss gehört nicht zur gewählten Sitzung"), { + status: 400, + publicMessage: "Beschluss gehört nicht zur gewählten Sitzung", + }); + } + + if (decision && caseId && decision.case_id && Number(decision.case_id) !== caseId) { + throw Object.assign(new Error("Beschluss gehört nicht zum gewählten Vorgang"), { + status: 400, + publicMessage: "Beschluss gehört nicht zum gewählten Vorgang", + }); + } + + if ( + decision && + agendaItem && + decision.agenda_item_id && + Number(decision.agenda_item_id) !== Number(agendaItem.id) + ) { + throw Object.assign(new Error("Beschluss gehört nicht zum gewählten TOP"), { + status: 400, + publicMessage: "Beschluss gehört nicht zum gewählten TOP", + }); + } + + return { agendaItem, decision }; +} + +function buildResolvedPayload(payload, context = {}) { + const agendaItem = context.agendaItem || null; + const decision = context.decision || null; + + const resolvedCaseId = + asId(payload.case_id) ?? + asId(agendaItem?.case_id) ?? + asId(decision?.case_id) ?? + null; + + const resolvedMeetingId = + asId(payload.meeting_id) ?? + asId(agendaItem?.meeting_id) ?? + asId(decision?.meeting_id) ?? + null; + + const resolvedAgendaItemId = + asId(payload.agenda_item_id) ?? + asId(decision?.agenda_item_id) ?? + null; + + const resolvedDecisionId = + asId(payload.decision_id) ?? + asId(agendaItem?.decision_id) ?? + null; + + return { + title: payload.title?.trim() || "", + description: payload.description?.trim() || null, + status: payload.status || "offen", + priority: payload.priority || "mittel", + assigned_user_id: asId(payload.assigned_user_id), + backup_user_id: asId(payload.backup_user_id), + committee_id: asId(payload.committee_id), + case_id: resolvedCaseId, + meeting_id: resolvedMeetingId, + agenda_item_id: resolvedAgendaItemId, + decision_id: resolvedDecisionId, + due_date: payload.due_date || null, + confidentiality_level: payload.confidentiality_level || "intern", + }; +} + +router.get("/", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query( + `SELECT t.*, + u.full_name AS assigned_user_name, + bu.full_name AS backup_user_name, + c.name AS committee_name, + bc.case_number, + bc.title AS case_title, + m.meeting_number, + ai.title AS agenda_item_title, + ai.agenda_number, + d.title AS decision_title, + d.decision_number + FROM task t + LEFT JOIN app_user u ON u.id = t.assigned_user_id + LEFT JOIN app_user bu ON bu.id = t.backup_user_id + LEFT JOIN committee c ON c.id = t.committee_id + LEFT JOIN br_case bc ON bc.id = t.case_id + LEFT JOIN meeting m ON m.id = t.meeting_id + LEFT JOIN meeting_agenda_item ai ON ai.id = t.agenda_item_id + LEFT JOIN decision d ON d.id = t.decision_id + WHERE t.deleted_at IS NULL + ORDER BY t.id DESC` + ); + return result.rows; + }); + + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/:id", async (req, res, next) => { + try { + const row = await withDbSession(req.user, (db) => getTaskById(db, req.params.id)); + if (!row) { + return res.status(404).json({ error: "Aufgabe nicht gefunden" }); + } + res.json(row); + } catch (err) { + next(err); + } +}); + +router.post( + "/", + requireMfa, + requirePermission("tasks.write", ["admin", "chair", "secretary", "member"]), + validate(taskSchema), + async (req, res, next) => { + try { + const created = await withDbSession(req.user, async (db) => { + const refs = await validateReferences(db, req.body); + const payload = buildResolvedPayload(req.body, refs); + + const result = await db.query( + `INSERT INTO task ( + title, + description, + status, + priority, + assigned_user_id, + backup_user_id, + committee_id, + case_id, + meeting_id, + agenda_item_id, + decision_id, + due_date, + confidentiality_level, + created_by, + updated_by + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$14) + RETURNING *`, + [ + payload.title, + payload.description, + payload.status, + payload.priority, + payload.assigned_user_id, + payload.backup_user_id, + payload.committee_id, + payload.case_id, + payload.meeting_id, + payload.agenda_item_id, + payload.decision_id, + payload.due_date, + payload.confidentiality_level, + req.user.sub, + ] + ); + + return result.rows[0]; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "TASK_CREATED", + entityType: "task", + entityId: created.id, + ipAddress: req.ip, + metadata: { payload: req.body }, + }); + + res.status(201).json(created); + } catch (err) { + next(err); + } + } +); + +router.put( + "/:id", + requireMfa, + requirePermission("tasks.write", ["admin", "chair", "secretary", "member"]), + validate(taskUpdateSchema), + async (req, res, next) => { + try { + const updated = await withDbSession(req.user, async (db) => { + const id = Number(req.params.id); + const current = await getTaskById(db, id); + + if (!current) return null; + + const merged = { + ...current, + ...req.body, + }; + + const refs = await validateReferences(db, merged); + const payload = buildResolvedPayload(merged, refs); + + const result = await db.query( + `UPDATE task + SET title = $2, + description = $3, + status = $4, + priority = $5, + assigned_user_id = $6, + backup_user_id = $7, + committee_id = $8, + case_id = $9, + meeting_id = $10, + agenda_item_id = $11, + decision_id = $12, + due_date = $13, + confidentiality_level = $14, + updated_by = $15, + updated_at = NOW() + WHERE id = $1 + AND deleted_at IS NULL + RETURNING *`, + [ + id, + payload.title, + payload.description, + payload.status, + payload.priority, + payload.assigned_user_id, + payload.backup_user_id, + payload.committee_id, + payload.case_id, + payload.meeting_id, + payload.agenda_item_id, + payload.decision_id, + payload.due_date, + payload.confidentiality_level, + req.user.sub, + ] + ); + + return result.rows[0] || null; + }); + + if (!updated) { + return res.status(404).json({ error: "Aufgabe nicht gefunden" }); + } + + await writeAudit({ + actorId: req.user.sub, + action: "TASK_UPDATED", + entityType: "task", + entityId: updated.id, + ipAddress: req.ip, + metadata: { changes: req.body }, + }); + + res.json(updated); + } catch (err) { + next(err); + } + } +); + +router.delete( + "/:id", + requireMfa, + requirePermission("tasks.write", ["admin", "chair", "secretary", "member"]), + async (req, res, next) => { + try { + const deleted = await withDbSession(req.user, async (db) => { + const result = await db.query( + `UPDATE task + SET deleted_at = NOW(), + updated_by = $2, + updated_at = NOW() + WHERE id = $1 + AND deleted_at IS NULL + RETURNING *`, + [req.params.id, req.user.sub] + ); + return result.rows[0] || null; + }); + + if (!deleted) { + return res.status(404).json({ error: "Aufgabe nicht gefunden" }); + } + + await writeAudit({ + actorId: req.user.sub, + action: "TASK_DELETED", + entityType: "task", + entityId: deleted.id, + ipAddress: req.ip, + }); + + res.json({ ok: true, row: deleted }); + } catch (err) { + next(err); + } + } +); + +export default router; diff --git a/backend/src/modules/tasks/schemas.js b/backend/src/modules/tasks/schemas.js new file mode 100644 index 0000000..49b2532 --- /dev/null +++ b/backend/src/modules/tasks/schemas.js @@ -0,0 +1,46 @@ +import { z } from "zod"; + +function nullableIntField() { + return z.preprocess((value) => { + if (value === "" || value === undefined || value === null) return null; + const n = Number(value); + return Number.isInteger(n) ? n : value; + }, z.number().int().positive().nullable()); +} + +function nullableStringField() { + return z.preprocess((value) => { + if (value === undefined || value === null) return null; + const str = String(value).trim(); + return str === "" ? null : str; + }, z.string().nullable()); +} + +export const taskSchema = z.object({ + title: z.string().trim().min(1, "Titel ist erforderlich").max(255), + description: nullableStringField().optional(), + status: z + .enum(["offen", "in_bearbeitung", "wartet", "erledigt", "abgebrochen"]) + .default("offen"), + priority: z + .enum(["sehr_hoch", "hoch", "mittel", "niedrig"]) + .default("mittel"), + assigned_user_id: nullableIntField().optional(), + backup_user_id: nullableIntField().optional(), + committee_id: nullableIntField().optional(), + case_id: nullableIntField().optional(), + meeting_id: nullableIntField().optional(), + agenda_item_id: nullableIntField().optional(), + decision_id: nullableIntField().optional(), + due_date: nullableStringField().optional(), + confidentiality_level: z + .enum(["intern", "vertraulich", "streng_vertraulich"]) + .default("intern"), +}); + +export const taskUpdateSchema = taskSchema.partial().refine( + (data) => Object.keys(data).length > 0, + { + message: "Mindestens ein Feld muss gesetzt sein.", + } +); diff --git a/backend/src/modules/templates/routes.js b/backend/src/modules/templates/routes.js new file mode 100644 index 0000000..723d8c1 --- /dev/null +++ b/backend/src/modules/templates/routes.js @@ -0,0 +1,402 @@ +import express from "express"; +import crypto from "crypto"; +import { z } from "zod"; +import { fileTypeFromBuffer } from "file-type"; +import { v4 as uuidv4 } from "uuid"; +import { authenticate, requireMfa } from "../../middleware/auth.js"; +import { requirePermission } from "../../middleware/rbac.js"; +import { validate } from "../../middleware/validate.js"; +import { withDbSession } from "../../config/db.js"; +import { upload } from "../../middleware/upload.js"; +import { scanBuffer } from "../../services/clamav.js"; +import { saveObject, getObjectStream } from "../../services/storage.js"; +import { writeAudit } from "../../utils/audit.js"; + +const router = express.Router(); +router.use(authenticate); + +const writeGuard = [requireMfa, requirePermission("templates.write", ["admin", "chair", "secretary"])]; + +const nullablePositiveInt = z.preprocess( + (value) => { + if (value === "" || value === null || value === undefined) return null; + return value; + }, + z.coerce.number().int().positive().nullable().optional() +); + +const templateSchema = z.object({ + title: z.string().min(3).max(255), + category: z.string().min(2).max(50), + description: z.string().max(4000).optional().nullable(), + content: z.string().min(3).max(200000), + content_format: z.enum(["markdown", "html"]).default("markdown"), + logo_asset_id: nullablePositiveInt, + version_label: z.string().min(1).max(30).default("v1"), + is_active: z.boolean().default(true), +}); + +const updateTemplateSchema = templateSchema.partial(); + +const assetUpdateSchema = z.object({ + title: z.string().min(1).max(255).optional(), + description: z.string().max(2000).optional().nullable(), + is_active: z.boolean().optional(), +}); + +function normalizeNullableText(value) { + if (value === undefined) return undefined; + if (value === null) return null; + const trimmed = String(value).trim(); + return trimmed || null; +} + +function normalizeTemplatePayload(payload, userId, isCreate = false) { + const normalized = { ...payload }; + if (normalized.description !== undefined) normalized.description = normalizeNullableText(normalized.description); + if (normalized.logo_asset_id === undefined) normalized.logo_asset_id = null; + if (!normalized.content_format) normalized.content_format = "markdown"; + if (isCreate) { + normalized.created_by = userId; + } + normalized.updated_by = userId; + return normalized; +} + +function mapAsset(row) { + if (!row) return null; + return { + id: row.id, + title: row.title, + description: row.description, + file_name: row.file_name, + mime_type: row.mime_type, + size_bytes: row.size_bytes, + is_active: row.is_active, + created_at: row.created_at, + uploaded_by_name: row.uploaded_by_name, + }; +} + +async function assertAssetExists(db, assetId) { + if (!assetId) return null; + const result = await db.query( + `SELECT id FROM template_asset WHERE id = $1::bigint AND is_active = TRUE`, + [assetId] + ); + if (!result.rows[0]) { + throw Object.assign(new Error("Logo/Bild wurde nicht gefunden"), { + status: 400, + publicMessage: "Das ausgewählte Logo/Bild wurde nicht gefunden oder ist deaktiviert.", + }); + } + return result.rows[0]; +} + +async function streamTemplateAsset(row, res, disposition = "inline") { + const { stream, contentType } = await getObjectStream(row.storage_path); + const safeFilename = encodeURIComponent(row.file_name || row.title || `template-asset-${row.id}`); + res.setHeader("Content-Type", contentType || row.mime_type || "application/octet-stream"); + res.setHeader("Content-Disposition", `${disposition}; filename="${safeFilename}"`); + res.setHeader("Cache-Control", "private, max-age=300"); + stream.pipe(res); +} + +router.get("/placeholders", async (req, res) => { + res.json([ + { token: "{{template_logo}}", label: "Template-Logo/Bild" }, + { token: "{{committee_name}}", label: "Gremium / Betriebsrat" }, + { token: "{{meeting_number}}", label: "Sitzungsnummer" }, + { token: "{{meeting_date}}", label: "Sitzungsdatum" }, + { token: "{{meeting_title_block}}", label: "Sitzungstitel" }, + { token: "{{meeting_type}}", label: "Sitzungsart" }, + { token: "{{start_time}}", label: "Beginn" }, + { token: "{{end_time}}", label: "Ende" }, + { token: "{{location}}", label: "Ort" }, + { token: "{{chair_name}}", label: "Sitzungsleitung" }, + { token: "{{secretary_name}}", label: "Schriftführung" }, + { token: "{{quorum_statement}}", label: "Beschlussfähigkeit" }, + { token: "{{attendees_block}}", label: "Anwesende Mitglieder" }, + { token: "{{protocol_notes}}", label: "Protokollnotizen" }, + { token: "{{agenda_sections}}", label: "TOPs / Beratung / Beschlüsse" }, + { token: "{{signature_place}}", label: "Ort/Datum Unterschrift" }, + ]); +}); + +router.get("/assets", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query( + `SELECT a.id, + a.title, + a.description, + a.file_name, + a.mime_type, + a.size_bytes, + a.is_active, + a.created_at, + u.full_name AS uploaded_by_name + FROM template_asset a + LEFT JOIN app_user u ON u.id = a.uploaded_by + WHERE a.is_active = TRUE + ORDER BY a.created_at DESC, a.id DESC` + ); + return result.rows.map(mapAsset); + }); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.post( + "/assets/upload", + ...writeGuard, + upload.single("file"), + async (req, res, next) => { + try { + if (!req.file) return res.status(400).json({ error: "Datei fehlt" }); + + const detected = await fileTypeFromBuffer(req.file.buffer); + const detectedMime = detected?.mime || req.file.mimetype || "application/octet-stream"; + if (!["image/png", "image/jpeg"].includes(detectedMime)) { + return res.status(415).json({ error: "Nur PNG- und JPEG-Bilder sind als Template-Logo/Bild erlaubt." }); + } + + const scan = await scanBuffer(req.file.buffer); + if (!scan.clean) { + return res.status(422).json({ error: "Datei durch Virenscan abgelehnt", scan_result: scan.raw }); + } + + const sha256 = crypto.createHash("sha256").update(req.file.buffer).digest("hex"); + const safeName = req.file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_"); + const storageKey = `${new Date().toISOString().slice(0, 10)}/template-assets/${uuidv4()}-${safeName}`; + const stored = await saveObject(storageKey, req.file.buffer, detectedMime); + + const asset = await withDbSession(req.user, async (db) => { + const result = await db.query( + `INSERT INTO template_asset( + title, description, file_name, storage_path, storage_provider, mime_type, size_bytes, sha256_hash, uploaded_by, is_active + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE) + RETURNING *`, + [ + normalizeNullableText(req.body.title) || req.file.originalname, + normalizeNullableText(req.body.description), + req.file.originalname, + stored.storage_path, + stored.storage_provider, + detectedMime, + req.file.size, + sha256, + req.user.sub, + ] + ); + return result.rows[0]; + }); + + await writeAudit({ + actorId: req.user.sub, + action: "TEMPLATE_ASSET_UPLOADED", + entityType: "template_asset", + entityId: asset.id, + ipAddress: req.ip, + metadata: { file_name: req.file.originalname, mime_type: detectedMime, sha256 }, + }); + + res.status(201).json(mapAsset(asset)); + } catch (err) { + next(err); + } + } +); + +router.get("/assets/:id/view", async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `SELECT id, title, file_name, storage_path, mime_type + FROM template_asset + WHERE id = $1::bigint + AND is_active = TRUE`, + [req.params.id] + ); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Bild nicht gefunden" }); + await streamTemplateAsset(row, res, "inline"); + } catch (err) { + next(err); + } +}); + +router.put("/assets/:id", ...writeGuard, validate(assetUpdateSchema), async (req, res, next) => { + try { + const payload = { + title: normalizeNullableText(req.body.title), + description: normalizeNullableText(req.body.description), + is_active: req.body.is_active, + }; + const fields = Object.entries(payload).filter(([, value]) => value !== undefined); + if (!fields.length) return res.status(400).json({ error: "Keine Felder zum Aktualisieren" }); + + const sets = fields.map(([key], index) => `${key} = $${index + 1}`); + const values = fields.map(([, value]) => value); + values.push(req.params.id); + + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `UPDATE template_asset SET ${sets.join(", ")} WHERE id = $${values.length}::bigint RETURNING *`, + values + ); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Bild nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "TEMPLATE_ASSET_UPDATED", entityType: "template_asset", entityId: row.id, ipAddress: req.ip, metadata: req.body }); + res.json(mapAsset(row)); + } catch (err) { + next(err); + } +}); + +router.delete("/assets/:id", ...writeGuard, async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + await db.query(`UPDATE template_item SET logo_asset_id = NULL WHERE logo_asset_id = $1::bigint`, [req.params.id]); + const result = await db.query( + `UPDATE template_asset SET is_active = FALSE WHERE id = $1::bigint RETURNING *`, + [req.params.id] + ); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Bild nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "TEMPLATE_ASSET_DEACTIVATED", entityType: "template_asset", entityId: row.id, ipAddress: req.ip }); + res.json({ ok: true, row: mapAsset(row) }); + } catch (err) { + next(err); + } +}); + +router.get("/", async (req, res, next) => { + try { + const rows = await withDbSession(req.user, async (db) => { + const result = await db.query( + `SELECT t.id, + t.title, + t.category, + t.description, + t.content_format, + t.version_label, + t.is_active, + t.logo_asset_id, + a.title AS logo_title, + t.updated_at, + t.created_at + FROM template_item t + LEFT JOIN template_asset a ON a.id = t.logo_asset_id + ORDER BY t.category, t.title, t.id` + ); + return result.rows; + }); + res.json(rows); + } catch (err) { + next(err); + } +}); + +router.get("/:id", async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query( + `SELECT t.*, + a.title AS logo_title, + a.file_name AS logo_file_name, + a.mime_type AS logo_mime_type, + a.size_bytes AS logo_size_bytes + FROM template_item t + LEFT JOIN template_asset a ON a.id = t.logo_asset_id + WHERE t.id = $1::bigint`, + [req.params.id] + ); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.post("/", ...writeGuard, validate(templateSchema), async (req, res, next) => { + try { + const payload = normalizeTemplatePayload(req.body, req.user.sub, true); + const row = await withDbSession(req.user, async (db) => { + await assertAssetExists(db, payload.logo_asset_id); + const result = await db.query( + `INSERT INTO template_item( + title, category, description, content, content_format, logo_asset_id, version_label, is_active, created_by, updated_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING *`, + [ + payload.title, + payload.category, + payload.description, + payload.content, + payload.content_format, + payload.logo_asset_id, + payload.version_label, + payload.is_active, + payload.created_by, + payload.updated_by, + ] + ); + return result.rows[0]; + }); + await writeAudit({ actorId: req.user.sub, action: "TEMPLATE_CREATED", entityType: "template", entityId: row.id, ipAddress: req.ip, metadata: { title: row.title, category: row.category } }); + res.status(201).json(row); + } catch (err) { + next(err); + } +}); + +router.put("/:id", ...writeGuard, validate(updateTemplateSchema), async (req, res, next) => { + try { + const payload = normalizeTemplatePayload(req.body, req.user.sub, false); + const fields = ["title", "category", "description", "content", "content_format", "logo_asset_id", "version_label", "is_active", "updated_by"].filter( + (field) => payload[field] !== undefined + ); + if (!fields.length) return res.status(400).json({ error: "Keine Felder zum Aktualisieren" }); + + const row = await withDbSession(req.user, async (db) => { + if (payload.logo_asset_id !== undefined) await assertAssetExists(db, payload.logo_asset_id); + const sets = fields.map((field, index) => `${field} = $${index + 1}`); + const values = fields.map((field) => payload[field]); + values.push(req.params.id); + const result = await db.query( + `UPDATE template_item SET ${sets.join(", ")}, updated_at = NOW() WHERE id = $${values.length}::bigint RETURNING *`, + values + ); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "TEMPLATE_UPDATED", entityType: "template", entityId: row.id, ipAddress: req.ip, metadata: req.body }); + res.json(row); + } catch (err) { + next(err); + } +}); + +router.delete("/:id", ...writeGuard, async (req, res, next) => { + try { + const row = await withDbSession(req.user, async (db) => { + const result = await db.query(`DELETE FROM template_item WHERE id = $1::bigint RETURNING *`, [req.params.id]); + return result.rows[0]; + }); + if (!row) return res.status(404).json({ error: "Nicht gefunden" }); + await writeAudit({ actorId: req.user.sub, action: "TEMPLATE_DELETED", entityType: "template", entityId: row.id, ipAddress: req.ip }); + res.json({ ok: true, row }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/backend/src/modules/users/routes.js b/backend/src/modules/users/routes.js new file mode 100644 index 0000000..e999d62 --- /dev/null +++ b/backend/src/modules/users/routes.js @@ -0,0 +1,22 @@ + import { z } from "zod"; + import { createCrudRouter } from "../crudFactory.js"; + + export default createCrudRouter({ + table: "app_user", + entityType: "user", + auditPrefix: "USER", + listSql: `SELECT id, full_name, email, role, committee_id, is_active, two_factor_enabled, security_classification, force_password_reset, created_at FROM app_user ORDER BY full_name`, + detailSql: `SELECT id, full_name, email, role, committee_id, is_active, two_factor_enabled, security_classification, force_password_reset, last_login_at, created_at FROM app_user WHERE id = $1`, + deleteSql: `UPDATE app_user SET is_active = FALSE WHERE id = $1 RETURNING id, full_name, email, role, committee_id, is_active`, + writeRoles: ['admin'], + writePermission: 'system.users', + fields: [ + { name: 'full_name', schema: z.string().min(2).max(120) }, +{ name: 'email', schema: z.string().email() }, +{ name: 'role', schema: z.enum(['admin','chair','member','secretary','advisor']) }, +{ name: 'committee_id', schema: z.number().int().nullable().optional() }, +{ name: 'is_active', schema: z.boolean().optional().default(true) }, +{ name: 'security_classification', schema: z.string().max(30).optional().default('standard') }, +{ name: 'force_password_reset', schema: z.boolean().optional().default(false) } + ] + }); diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..6c1a854 --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,79 @@ +import express from "express"; +import cookieParser from "cookie-parser"; +import { commonSecurity } from "./middleware/security.js"; +import { errorHandler } from "./middleware/errorHandler.js"; +import { env } from "./config/env.js"; +import authRoutes from "./modules/auth/routes.js"; +import dashboardRoutes from "./modules/dashboard/routes.js"; +import auditRoutes from "./modules/audit/routes.js"; +import committeeRoutes from "./modules/committees/routes.js"; +import userRoutes from "./modules/users/routes.js"; +import caseRoutes from "./modules/cases/routes.js"; +import deadlineRoutes from "./modules/deadlines/routes.js"; +import meetingRoutes from "./modules/meetings/routes.js"; +import agendaRoutes from "./modules/agenda-items/routes.js"; +import decisionRoutes from "./modules/decisions/routes.js"; +import taskRoutes from "./modules/tasks/routes.js"; +import documentRoutes, { publicDocumentsRouter } from "./modules/documents/routes.js"; +import commentRoutes from "./modules/comments/routes.js"; +import templateRoutes from "./modules/templates/routes.js"; +import notificationRoutes from "./modules/notifications/routes.js"; +import calendarRoutes from "./modules/calendar/routes.js"; +import { runBootstrapMigrations } from "./db/bootstrap.js"; +import reportRoutes from "./modules/reports/routes.js"; +import permissionRoutes from "./modules/permissions/routes.js"; +import financeRoutes from "./modules/finance/routes.js"; +import kummerkastenRoutes, { publicKummerkastenRouter } from "./modules/kummerkasten/routes.js"; +import departmentRoutes from "./modules/departments/routes.js"; +import documentCategoryRoutes from "./modules/document-categories/routes.js"; + +const app = express(); + +app.use(commonSecurity); +app.use(express.json({ limit: "10mb" })); +app.use(cookieParser()); + +app.get("/health", (req, res) => res.json({ ok: true, env: env.nodeEnv })); +app.use("/api/auth", authRoutes); +app.use("/api/dashboard", dashboardRoutes); +app.use("/api/committees", committeeRoutes); +app.use("/api/users", userRoutes); +app.use("/api/cases", caseRoutes); +app.use("/api/deadlines", deadlineRoutes); +app.use("/api/meetings", meetingRoutes); +app.use("/api/agenda-items", agendaRoutes); +app.use("/api/decisions", decisionRoutes); +app.use("/api/tasks", taskRoutes); +app.use("/api/documents", documentRoutes); +app.use("/api/comments", commentRoutes); +app.use("/api/templates", templateRoutes); +app.use("/api/notifications", notificationRoutes); +app.use("/api/calendar", calendarRoutes); +app.use("/api/audit", auditRoutes); +app.use("/api/reports", reportRoutes); +app.use("/api/permissions", permissionRoutes); +app.use("/api/finance", financeRoutes); +app.use("/api/kummerkasten", kummerkastenRoutes); +app.use("/api/departments", departmentRoutes); +app.use("/api/document-categories", documentCategoryRoutes); +app.use("/api/public/kummerkasten", publicKummerkastenRouter); +app.use("/api/public/documents", publicDocumentsRouter); +app.use(errorHandler); + +async function start() { + try { + console.log("[startup] starting backend"); + await runBootstrapMigrations(); + console.log("[startup] migrations done"); + + app.listen(env.port, "0.0.0.0", () => { + console.log(`BR-Office backend listening on :${env.port}`); + }); + } catch (error) { + console.error("[startup] fatal error"); + console.error(error); + process.exit(1); + } +} + +start(); diff --git a/backend/src/services/accessControl.js b/backend/src/services/accessControl.js new file mode 100644 index 0000000..d25ceb5 --- /dev/null +++ b/backend/src/services/accessControl.js @@ -0,0 +1,120 @@ +import { query, withDbSession } from "../config/db.js"; + +const FALLBACK_PAGES_BY_ROLE = { + admin: ["/", "/cases", "/deadlines", "/meetings", "/agenda-items", "/decisions", "/reports", "/finance", "/tasks", "/calendar", "/documents", "/comments", "/notifications", "/users", "/committees", "/templates", "/permissions", "/security", "/audit"], + chair: ["/", "/cases", "/deadlines", "/meetings", "/agenda-items", "/decisions", "/reports", "/finance", "/tasks", "/calendar", "/documents", "/comments", "/notifications", "/committees", "/templates"], + secretary: ["/", "/meetings", "/agenda-items", "/decisions", "/reports", "/finance", "/tasks", "/calendar", "/documents", "/comments", "/notifications"], + member: ["/", "/cases", "/deadlines", "/tasks", "/calendar", "/documents", "/comments", "/notifications"], + advisor: ["/", "/calendar", "/documents", "/comments", "/notifications"], +}; + +export function fallbackAllowedPagePaths(role) { + return FALLBACK_PAGES_BY_ROLE[role] || ["/"]; +} + +export async function getUserAccess(userId, role) { + try { + return await withDbSession({ sub: userId, role }, async (db) => { + const pages = await db.query( + `SELECT DISTINCT p.page_key, p.path, p.label, p.nav_group, p.sort_order + FROM app_page p + JOIN app_role_page rp ON rp.page_key = p.page_key AND rp.can_view = TRUE + JOIN app_role_profile r ON r.id = rp.role_profile_id AND r.is_active = TRUE + WHERE r.system_role::TEXT = $2 + OR EXISTS ( + SELECT 1 FROM app_user_role_profile urp + WHERE urp.role_profile_id = r.id + AND urp.user_id = $1 + ) + ORDER BY p.sort_order, p.label`, + [userId, role] + ); + + const permissions = await db.query( + `SELECT DISTINCT perm.permission_key + FROM app_permission perm + JOIN app_role_permission rp ON rp.permission_key = perm.permission_key AND rp.granted = TRUE + JOIN app_role_profile r ON r.id = rp.role_profile_id AND r.is_active = TRUE + WHERE r.system_role::TEXT = $2 + OR EXISTS ( + SELECT 1 FROM app_user_role_profile urp + WHERE urp.role_profile_id = r.id + AND urp.user_id = $1 + ) + ORDER BY perm.permission_key`, + [userId, role] + ); + + const roleProfiles = await db.query( + `SELECT DISTINCT r.id, r.code, r.name, r.description, r.system_role, r.is_system + FROM app_role_profile r + WHERE r.is_active = TRUE + AND ( + r.system_role::TEXT = $2 + OR EXISTS ( + SELECT 1 FROM app_user_role_profile urp + WHERE urp.role_profile_id = r.id + AND urp.user_id = $1 + ) + ) + ORDER BY r.is_system DESC, r.name`, + [userId, role] + ); + + const teamRoles = await db.query( + `SELECT tr.id, tr.code, tr.name, tr.description, + tr.can_view_cases, tr.can_write_cases, + tr.can_view_documents, tr.can_write_documents, + utr.committee_id + FROM app_user_team_role utr + JOIN app_team_role tr ON tr.id = utr.team_role_id + WHERE utr.user_id = $1 + AND tr.is_active = TRUE + ORDER BY tr.name`, + [userId] + ); + + const pageRows = pages.rows || []; + const permissionKeys = permissions.rows.map((row) => row.permission_key); + const pagePaths = pageRows.map((row) => row.path); + + if (role === "admin") { + return { + allowed_pages: pageRows, + allowed_page_paths: pagePaths.length ? pagePaths : fallbackAllowedPagePaths(role), + permissions: permissionKeys.includes("system.admin") ? permissionKeys : ["system.admin", ...permissionKeys], + role_profiles: roleProfiles.rows, + team_roles: teamRoles.rows, + }; + } + + return { + allowed_pages: pageRows, + allowed_page_paths: pagePaths.length ? pagePaths : fallbackAllowedPagePaths(role), + permissions: permissionKeys, + role_profiles: roleProfiles.rows, + team_roles: teamRoles.rows, + }; + }); + } catch (err) { + return { + allowed_pages: [], + allowed_page_paths: fallbackAllowedPagePaths(role), + permissions: role === "admin" ? ["system.admin"] : [], + role_profiles: [], + team_roles: [], + }; + } +} + +export async function userHasPermission(userId, role, permissionKey) { + if (role === "admin") return true; + const result = await query(`SELECT app_has_permission($1, $2::bigint, $3::text) AS allowed`, [permissionKey, userId, role || "anonymous"]); + return Boolean(result.rows[0]?.allowed); +} + +export async function userCanViewPage(userId, role, pageKey) { + if (role === "admin") return true; + const result = await query(`SELECT app_can_view_page($1, $2::bigint, $3::text) AS allowed`, [pageKey, userId, role || "anonymous"]); + return Boolean(result.rows[0]?.allowed); +} diff --git a/backend/src/services/clamav.js b/backend/src/services/clamav.js new file mode 100644 index 0000000..1376303 --- /dev/null +++ b/backend/src/services/clamav.js @@ -0,0 +1,8 @@ +export async function scanBuffer(buffer) { + // Platzhalter für echten ClamAV-Scan. Für die Referenzstruktur wird der API-Vertrag bereits abgebildet. + // In Produktion hier TCP-Scan gegen clamd oder ICAP ergänzen. + return { + clean: true, + raw: "stream: OK", + }; +} diff --git a/backend/src/services/mail.js b/backend/src/services/mail.js new file mode 100644 index 0000000..03d0744 --- /dev/null +++ b/backend/src/services/mail.js @@ -0,0 +1,57 @@ +import nodemailer from "nodemailer"; +import { env } from "../config/env.js"; + +let transporterPromise = null; + +export function isMailConfigured() { + return Boolean(env.smtpHost && env.smtpPort); +} + +async function createTransporter() { + if (!isMailConfigured()) return null; + + const transport = nodemailer.createTransport({ + host: env.smtpHost, + port: env.smtpPort, + secure: env.smtpSecure, + requireTLS: env.smtpRequireTls, + auth: env.smtpUser ? { user: env.smtpUser, pass: env.smtpPass } : undefined, + }); + + return transport; +} + +async function getTransporter() { + if (!transporterPromise) { + transporterPromise = createTransporter(); + } + + return transporterPromise; +} + +export async function sendMail({ to, subject, text, html, from }) { + if (!isMailConfigured()) { + return { ok: false, skipped: true, reason: "smtp_not_configured" }; + } + + const transporter = await getTransporter(); + if (!transporter) { + return { ok: false, skipped: true, reason: "smtp_not_configured" }; + } + + const info = await transporter.sendMail({ + from: from || env.smtpFrom, + to, + subject, + text, + html, + }); + + return { + ok: true, + skipped: false, + messageId: info.messageId, + accepted: info.accepted, + rejected: info.rejected, + }; +} diff --git a/backend/src/services/meetingDecisionReport.js b/backend/src/services/meetingDecisionReport.js new file mode 100644 index 0000000..d04700a --- /dev/null +++ b/backend/src/services/meetingDecisionReport.js @@ -0,0 +1,379 @@ +import crypto from "crypto"; +import { v4 as uuidv4 } from "uuid"; +import { saveObject } from "./storage.js"; + +const AUTO_DESCRIPTION = "Automatisch beim Beenden der Sitzung erzeugter Beschlussbericht."; + +export const DEFAULT_MEETING_DECISION_REPORT_TEMPLATE = `# Beschlussbericht zur BR-Sitzung am {{meeting_date}} +VERTRAULICH + +{{meeting_title_block}} + +## Sitzungsdaten +Sitzungsnummer: {{meeting_number}} +Sitzungsart: {{meeting_type}} +Sitzungsdatum: {{meeting_date}} +Sitzungsort: {{location}} +Sitzungsleitung: {{chair_name}} +Schriftführung: {{secretary_name}} + +## Behandelte Beschlüsse ab TOP 1 +{{decision_sections}} + +{{signature_place}} +______________________________ +{{chair_name}} + +______________________________ +{{secretary_name}}`; + +function escapeRtf(value) { + return String(value ?? "") + .replace(/\\/g, "\\\\") + .replace(/\{/g, "\\{") + .replace(/\}/g, "\\}") + .replace(/\t/g, "\\tab "); +} + +function formatDate(value) { + if (!value) return ""; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleDateString("de-DE"); +} + +function formatDateTime(value) { + if (!value) return ""; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString("de-DE"); +} + +function meetingTypeLabel(value) { + if (value === "ausserordentlich") return "Außerordentlich"; + return "Ordentlich"; +} + +function decisionStatusLabel(value) { + switch (String(value ?? "").trim().toLowerCase()) { + case "in_bearbeitung": + return "In Bearbeitung"; + case "rueckfrage": + return "Rückfrage"; + case "abgeschlossen": + return "Abgeschlossen"; + default: + return value || "offen"; + } +} + +function sanitizeLine(value, fallback = "-") { + const text = String(value ?? "").trim(); + return text || fallback; +} + +function computeDecisionOutcome(decision) { + const yes = Number(decision?.votes_yes ?? 0); + const no = Number(decision?.votes_no ?? 0); + if (yes === 0 && no === 0) return "Ergebnis: nicht hinterlegt"; + if (yes > no) return "Ergebnis: angenommen"; + if (no > yes) return "Ergebnis: abgelehnt"; + return "Ergebnis: Stimmengleichheit"; +} + +function replaceTokens(template, values) { + return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { + const value = values[key]; + if (value === undefined || value === null) return ""; + return String(value); + }); +} + +function linesToRtf(content) { + const lines = String(content ?? "").replace(/\r\n/g, "\n").split("\n"); + const rtfLines = [ + "{\\rtf1\\ansi\\deff0", + "{\\fonttbl{\\f0 Arial;}}", + "\\paperw11907\\paperh16840\\margl1134\\margr1134\\margt1134\\margb1134", + "\\viewkind4\\uc1", + ]; + + for (const rawLine of lines) { + const line = rawLine ?? ""; + const trimmed = line.trim(); + + if (!trimmed) { + rtfLines.push("{\\pard\\sa140\\par}"); + continue; + } + + if (trimmed === "VERTRAULICH") { + rtfLines.push(`{\\pard\\qc\\sa220\\b\\fs26 ${escapeRtf(trimmed)}\\par}`); + continue; + } + + if (trimmed.startsWith("# ")) { + rtfLines.push(`{\\pard\\qc\\sa220\\b\\fs32 ${escapeRtf(trimmed.slice(2))}\\par}`); + continue; + } + + if (trimmed.startsWith("## ")) { + rtfLines.push(`{\\pard\\sa200\\b\\fs26 ${escapeRtf(trimmed.slice(3))}\\par}`); + continue; + } + + if (trimmed.startsWith("### ")) { + rtfLines.push(`{\\pard\\sa160\\b\\fs23 ${escapeRtf(trimmed.slice(4))}\\par}`); + continue; + } + + if (trimmed.startsWith("- ")) { + rtfLines.push(`{\\pard\\li540\\fi-240\\sa120\\fs22 ${escapeRtf(trimmed)}\\par}`); + continue; + } + + rtfLines.push(`{\\pard\\sa120\\fs22 ${escapeRtf(line)}\\par}`); + } + + rtfLines.push("}"); + return rtfLines.join("\n"); +} + +function renderDecisionSections(agendaItems = []) { + const itemsWithDecisions = agendaItems.filter((item) => Array.isArray(item.decisions) && item.decisions.length > 0); + if (!itemsWithDecisions.length) { + return "Für diese Sitzung wurden keine Beschlüsse dokumentiert."; + } + + return itemsWithDecisions + .map((item) => { + const lines = [`## TOP ${sanitizeLine(item.agenda_number, String(item.id))}: ${sanitizeLine(item.title, "Ohne Titel")}`]; + + if (item.case_number) { + lines.push(`Vorgang: ${item.case_number}`); + } + + if (item.description?.trim()) { + lines.push(item.description.trim()); + } + + item.decisions.forEach((decision) => { + lines.push(`### Beschluss ${sanitizeLine(decision.decision_number, String(decision.id))}: ${sanitizeLine(decision.title, "Ohne Titel")}`); + lines.push(decision.decision_text?.trim() || "Kein Beschlusstext hinterlegt."); + lines.push([ + `Ja-Stimmen: ${Number(decision.votes_yes ?? 0)}`, + `Nein-Stimmen: ${Number(decision.votes_no ?? 0)}`, + `Enthaltungen: ${Number(decision.votes_abstain ?? 0)}`, + ].join(" | ")); + lines.push(computeDecisionOutcome(decision)); + lines.push(`Status: ${decisionStatusLabel(decision.status)}`); + + if (decision.responsible_name) { + lines.push(`Verantwortlich: ${decision.responsible_name}`); + } + + if (decision.due_date) { + lines.push(`Frist: ${formatDateTime(decision.due_date)}`); + } + }); + + return lines.join("\n\n"); + }) + .join("\n\n"); +} + +function buildDecisionReportValues(meeting, agendaItems) { + const title = meeting.title?.trim(); + return { + meeting_number: sanitizeLine(meeting.meeting_number, "offen"), + meeting_title_block: title ? `Titel: ${title}` : "", + meeting_date: formatDate(meeting.meeting_date), + meeting_type: meetingTypeLabel(meeting.meeting_type), + location: sanitizeLine(meeting.location, "nicht hinterlegt"), + chair_name: sanitizeLine(meeting.chair_name, "nicht hinterlegt"), + secretary_name: sanitizeLine(meeting.secretary_name, "nicht hinterlegt"), + decision_sections: renderDecisionSections(agendaItems), + signature_place: `${sanitizeLine(meeting.location, "Ort")}, den ${formatDate(meeting.meeting_date)}`, + }; +} + +function generateDocumentNumber() { + return `D-${new Date().getFullYear()}-${Math.floor(Math.random() * 100000).toString().padStart(5, "0")}`; +} + +async function fetchActiveTemplate(db) { + const result = await db.query( + `SELECT id, title, content, version_label + FROM template_item + WHERE category = 'meeting_decision_report' + AND is_active = TRUE + ORDER BY updated_at DESC, id DESC + LIMIT 1` + ); + return result.rows[0] || null; +} + +async function fetchMeetingDecisionReportData(db, meetingId) { + const meetingResult = await db.query( + `SELECT m.id, + m.meeting_number, + m.title, + m.meeting_type, + m.meeting_date, + m.location, + chair.full_name AS chair_name, + secretary.full_name AS secretary_name + FROM meeting m + LEFT JOIN app_user chair ON chair.id = m.chair_user_id + LEFT JOIN app_user secretary ON secretary.id = m.secretary_user_id + WHERE m.id = $1`, + [meetingId] + ); + + const meeting = meetingResult.rows[0]; + if (!meeting) return null; + + const agendaResult = await db.query( + `SELECT ai.id, + ai.agenda_number, + ai.title, + ai.description, + ai.case_id, + c.case_number + FROM meeting_agenda_item ai + LEFT JOIN br_case c ON c.id = ai.case_id + WHERE ai.meeting_id = $1 + ORDER BY + CASE WHEN ai.agenda_number ~ '^[0-9]+$' THEN ai.agenda_number::int ELSE NULL END NULLS LAST, + ai.agenda_number NULLS LAST, + ai.id`, + [meetingId] + ); + + const agendaItems = agendaResult.rows; + const agendaIds = agendaItems.map((item) => item.id); + const decisionMap = new Map(agendaIds.map((id) => [id, []])); + + if (agendaIds.length) { + const decisionResult = await db.query( + `SELECT d.id, + d.agenda_item_id, + d.decision_number, + d.title, + d.decision_text, + d.status, + d.votes_yes, + d.votes_no, + d.votes_abstain, + d.due_date, + u.full_name AS responsible_name + FROM decision d + LEFT JOIN app_user u ON u.id = d.responsible_user_id + WHERE d.meeting_id = $1 + AND d.agenda_item_id = ANY($2::bigint[]) + ORDER BY d.created_at, d.id`, + [meetingId, agendaIds] + ); + + decisionResult.rows.forEach((row) => { + const bucket = decisionMap.get(row.agenda_item_id) || []; + bucket.push(row); + decisionMap.set(row.agenda_item_id, bucket); + }); + } + + return { + meeting, + agendaItems: agendaItems.map((item) => ({ ...item, decisions: decisionMap.get(item.id) || [] })), + }; +} + +export async function ensureMeetingDecisionReportDocument({ db, meetingId, actorId }) { + const existing = await db.query( + `SELECT id, document_number, title, file_name, uploaded_at + FROM document_item + WHERE meeting_id = $1 + AND document_type = 'bericht' + AND description = $2 + AND deleted_at IS NULL + ORDER BY uploaded_at DESC, id DESC + LIMIT 1`, + [meetingId, AUTO_DESCRIPTION] + ); + + if (existing.rows[0]) { + return existing.rows[0]; + } + + const data = await fetchMeetingDecisionReportData(db, meetingId); + if (!data) return null; + + const template = await fetchActiveTemplate(db); + const templateContent = template?.content?.trim() || DEFAULT_MEETING_DECISION_REPORT_TEMPLATE; + const values = buildDecisionReportValues(data.meeting, data.agendaItems); + const renderedText = replaceTokens(templateContent, values); + const rtf = linesToRtf(renderedText); + const buffer = Buffer.from(rtf, "utf8"); + + const filenameBase = `${data.meeting.meeting_number || `sitzung-${meetingId}`}-beschlussbericht`; + const safeFilename = filenameBase.replace(/[^a-zA-Z0-9._-]/g, "_"); + const storageKey = `${new Date().toISOString().slice(0, 10)}/${uuidv4()}-${safeFilename}.rtf`; + const sha256 = crypto.createHash("sha256").update(buffer).digest("hex"); + const docMeta = await saveObject(storageKey, buffer, "application/rtf"); + + const insert = await db.query( + `INSERT INTO document_item( + document_number, + title, + document_type, + description, + version_label, + file_name, + storage_path, + storage_provider, + mime_type, + size_bytes, + sha256_hash, + confidentiality_level, + case_id, + meeting_id, + decision_id, + uploaded_by, + scan_status, + scan_engine, + scan_result, + uploaded_at, + updated_at + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NULL,$13,NULL,$14,$15,$16,$17,NOW(),NOW() + ) + RETURNING id, document_number, title, file_name, uploaded_at`, + [ + generateDocumentNumber(), + `Beschlussbericht ${data.meeting.meeting_number}`, + "bericht", + AUTO_DESCRIPTION, + template?.version_label || "v1", + `${safeFilename}.rtf`, + docMeta.storage_path, + docMeta.storage_provider, + "application/rtf", + buffer.length, + sha256, + "vertraulich", + meetingId, + actorId, + "clean", + "system", + `Automatisch generiert aus Vorlage ${template?.title || "meeting_decision_report"}`, + ] + ); + + await db.query( + `INSERT INTO document_scan_event(document_id, scanned_by_user_id, status, engine, result_text, scanned_at) + VALUES ($1,$2,$3,$4,$5,NOW())`, + [insert.rows[0].id, actorId, "clean", "system", "Automatisch generierter Beschlussbericht"] + ); + + return insert.rows[0]; +} diff --git a/backend/src/services/meetingProtocol.js b/backend/src/services/meetingProtocol.js new file mode 100644 index 0000000..3531a3a --- /dev/null +++ b/backend/src/services/meetingProtocol.js @@ -0,0 +1,776 @@ +import crypto from "crypto"; +import { v4 as uuidv4 } from "uuid"; +import { getObjectStream, saveObject } from "./storage.js"; + +export const DEFAULT_MEETING_PROTOCOL_TEMPLATE = `{{template_logo}} + +# Betriebsrat {{committee_name}} +# Protokoll der BR-Sitzung am {{meeting_date}} +VERTRAULICH + +{{meeting_title_block}} + +## Organisatorisches und Feststellungen + +Sitzungsnummer: {{meeting_number}} +Sitzungsart: {{meeting_type}} +Sitzungsbeginn: {{start_time}} +Sitzungsort: {{location}} +Sitzungsleitung: {{chair_name}} +Schriftführung: {{secretary_name}} +{{quorum_statement}} +{{attendees_block}} + +## Protokollnotizen +{{protocol_notes}} + +## Tagesordnung und Beratung +{{agenda_sections}} + +## Sitzungsende +Schließung der Sitzung um {{end_time}} Uhr. + +{{signature_place}} +______________________________ +{{chair_name}} + +______________________________ +{{secretary_name}}`; + +function escapeRtf(value) { + return String(value ?? "") + .replace(/\\/g, "\\\\") + .replace(/\{/g, "\\{") + .replace(/\}/g, "\\}") + .replace(/\t/g, "\\tab "); +} + + +function decodeBasicHtmlEntities(value) { + return String(value ?? "") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/'/gi, "'"); +} + +function htmlToProtocolText(html) { + let text = String(html ?? ""); + text = text.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ""); + text = text.replace(/<\s*img[^>]*(?:data-template-logo=["']true["']|alt=["']template_logo["'])[^>]*>/gi, "\n{{template_logo}}\n"); + text = text.replace(/<\s*h1[^>]*>/gi, "\n# ").replace(/<\s*\/\s*h1\s*>/gi, "\n"); + text = text.replace(/<\s*h2[^>]*>/gi, "\n## ").replace(/<\s*\/\s*h2\s*>/gi, "\n"); + text = text.replace(/<\s*h3[^>]*>/gi, "\n### ").replace(/<\s*\/\s*h3\s*>/gi, "\n"); + text = text.replace(/<\s*li[^>]*>/gi, "\n- ").replace(/<\s*\/\s*li\s*>/gi, ""); + text = text.replace(/<\s*br\s*\/?>/gi, "\n"); + text = text.replace(/<\s*\/\s*(p|div|section|article|tr|table|ul|ol)\s*>/gi, "\n"); + text = text.replace(/<\s*(p|div|section|article|tr|table|ul|ol)[^>]*>/gi, "\n"); + text = text.replace(/<\s*\/\s*(td|th)\s*>/gi, " | "); + text = text.replace(/<[^>]+>/g, ""); + text = decodeBasicHtmlEntities(text); + return text + .split("\n") + .map((line) => line.replace(/[ \t]+$/g, "")) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function normalizeTemplateContentForProtocol(content, format = "markdown") { + if (String(format || "markdown").toLowerCase() === "html") { + return htmlToProtocolText(content); + } + return String(content ?? ""); +} + +function rtfImageBlock(asset) { + if (!asset?.buffer?.length) return ""; + const mime = String(asset.mime_type || "").toLowerCase(); + const blip = mime === "image/png" ? "pngblip" : mime === "image/jpeg" || mime === "image/jpg" ? "jpegblip" : ""; + if (!blip) return `{\\pard\\qc\\sa180\\fs20 ${escapeRtf(`[Bild kann im RTF nicht eingebettet werden: ${asset.file_name || "Logo"}`)}\\par}`; + const hex = asset.buffer.toString("hex"); + return `{\\pard\\qc\\sa220{\\pict\\${blip}\\picwgoal3200\\pichgoal1100 ${hex}}\\par}`; +} + +async function streamToBuffer(stream) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +async function loadTemplateLogo(db, template) { + if (!template?.logo_asset_id) return null; + const result = await db.query( + `SELECT id, title, file_name, storage_path, mime_type + FROM template_asset + WHERE id = $1::bigint + AND is_active = TRUE`, + [template.logo_asset_id] + ); + const asset = result.rows[0]; + if (!asset) return null; + try { + const { stream } = await getObjectStream(asset.storage_path); + return { ...asset, buffer: await streamToBuffer(stream) }; + } catch { + return null; + } +} + +function formatDate(value) { + if (!value) return ""; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleDateString("de-DE"); +} + +function formatDateTime(value) { + if (!value) return ""; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString("de-DE"); +} + +function formatTime(value) { + if (!value) return "offen"; + const raw = String(value); + if (/^\d{2}:\d{2}/.test(raw)) return raw.slice(0, 5); + return raw; +} + +function meetingTypeLabel(value) { + if (value === "ausserordentlich") return "Außerordentlich"; + return "Ordentlich"; +} + +function decisionStatusLabel(value) { + switch (String(value ?? "").trim().toLowerCase()) { + case "in_bearbeitung": + return "In Bearbeitung"; + case "rueckfrage": + return "Rückfrage"; + case "klaerung": + return "Klärung"; + case "abgeschlossen": + return "Abgeschlossen"; + default: + return value || "offen"; + } +} + +function sanitizeLine(value, fallback = "-") { + const text = String(value ?? "").trim(); + return text || fallback; +} + +function quorumStatement(value) { + if (value === true) return "Die Sitzung ist Beschlussfähig."; + if (value === false) return "Die Sitzung ist nicht Beschlussfähig."; + return "Die Beschlussfähigkeit wurde nicht dokumentiert."; +} + +function roleLabel(value) { + switch (String(value ?? "").trim().toLowerCase()) { + case "admin": + return "Administration"; + case "chair": + return "Vorsitz"; + case "secretary": + return "Schriftführung"; + case "member": + return "Mitglied"; + case "advisor": + return "Beratung"; + default: + return value || ""; + } +} + +function renderAttendeesBlock(attendees = []) { + if (!Array.isArray(attendees) || !attendees.length) { + return "\n## Anwesende Betriebsratsmitglieder\nKeine anwesenden Betriebsratsmitglieder dokumentiert."; + } + + const lines = ["", "## Anwesende Betriebsratsmitglieder"]; + attendees.forEach((attendee) => { + const name = sanitizeLine(attendee.full_name, "Unbekannt"); + const parts = []; + const role = roleLabel(attendee.role); + if (role) parts.push(role); + if (attendee.email) parts.push(attendee.email); + lines.push(parts.length ? `- ${name} (${parts.join(", ")})` : `- ${name}`); + }); + + return lines.join("\n"); +} + +function computeDecisionOutcome(decision) { + const yes = Number(decision?.votes_yes ?? 0); + const no = Number(decision?.votes_no ?? 0); + if (yes === 0 && no === 0) return "Ergebnis: nicht hinterlegt"; + if (yes > no) return "Ergebnis: angenommen"; + if (no > yes) return "Ergebnis: abgelehnt"; + return "Ergebnis: Stimmengleichheit"; +} + +function returnTypeLabel(value) { + const normalized = String(value || "rueckfrage"); + if (normalized === "klaerung") return "Klärung"; + if (normalized === "bemerkung") return "Bemerkung / Wiedervorlage"; + return "Rückfrage"; +} + +function renderReturnedAgendaSections(returnedItems = []) { + if (!Array.isArray(returnedItems) || !returnedItems.length) return ""; + + const lines = ["## Folge-TOPs / Rückfragen"]; + returnedItems.forEach((item) => { + const topNumber = item.agenda_number_at_return || item.agenda_number || String(item.agenda_item_id || item.id); + lines.push(`### TOP ${sanitizeLine(topNumber, "-")}: ${sanitizeLine(item.title_at_return || item.title, "Ohne Titel")}`); + lines.push(`Status: ${returnTypeLabel(item.return_type)} – Folge-TOP im TOP-Bestand angelegt.`); + if (item.follow_up_agenda_item_id) { + lines.push(`Folge-TOP: #${item.follow_up_agenda_item_id}${item.follow_up_title ? ` – ${sanitizeLine(item.follow_up_title, "")}` : ""}`); + } + if (item.returned_by_name || item.returned_at) { + const parts = []; + if (item.returned_by_name) parts.push(`durch ${sanitizeLine(item.returned_by_name, "")}`); + if (item.returned_at) parts.push(`am ${formatDateTime(item.returned_at)}`); + lines.push(`Dokumentiert ${parts.join(" ")}.`); + } + lines.push(`Bemerkung: ${sanitizeLine(item.remark, "-")}`); + }); + + return lines.join("\n\n"); +} + + +function renderFollowUpHistory(item) { + if (!item?.parent_agenda_item_id && !item?.follow_up_created_from_agenda_item_id) return []; + + const lines = ["### Historie / Rückverfolgung"]; + const parentNumber = item.parent_agenda_number || item.follow_up_source_agenda_number || item.parent_agenda_item_id || item.follow_up_created_from_agenda_item_id; + const parentTitle = item.parent_title || item.follow_up_source_agenda_title; + const meetingParts = []; + + if (item.follow_up_source_meeting_number || item.parent_meeting_number) { + meetingParts.push(`Sitzung ${sanitizeLine(item.follow_up_source_meeting_number || item.parent_meeting_number, "")}`); + } + if (item.follow_up_source_meeting_date || item.parent_meeting_date) { + meetingParts.push(`vom ${formatDate(item.follow_up_source_meeting_date || item.parent_meeting_date)}`); + } + + const parentLabel = `TOP ${sanitizeLine(parentNumber, "-")}${parentTitle ? `: ${sanitizeLine(parentTitle, "")}` : ""}`; + lines.push( + meetingParts.length + ? `Dieser TOP ist eine Fortsetzung von ${parentLabel} aus ${meetingParts.join(" ")}.` + : `Dieser TOP ist eine Fortsetzung von ${parentLabel}.` + ); + + if (item.follow_up_type) { + lines.push(`Art: ${returnTypeLabel(item.follow_up_type)}`); + } + + if (item.follow_up_note) { + lines.push(`Bemerkung: ${sanitizeLine(item.follow_up_note, "-")}`); + } + + const createdParts = []; + if (item.follow_up_created_by_name) createdParts.push(`durch ${sanitizeLine(item.follow_up_created_by_name, "")}`); + if (item.follow_up_created_at) createdParts.push(`am ${formatDateTime(item.follow_up_created_at)}`); + if (createdParts.length) { + lines.push(`Folge-TOP angelegt ${createdParts.join(" ")}.`); + } + + if (item.root_agenda_item_id && String(item.root_agenda_item_id) !== String(item.parent_agenda_item_id || "")) { + const rootNumber = item.root_agenda_number || item.root_agenda_item_id; + const rootTitle = item.root_title ? `: ${sanitizeLine(item.root_title, "")}` : ""; + const rootMeeting = item.root_meeting_number ? ` aus Sitzung ${sanitizeLine(item.root_meeting_number, "")}` : ""; + const rootDate = item.root_meeting_date ? ` vom ${formatDate(item.root_meeting_date)}` : ""; + lines.push(`Ursprung der Kette: TOP ${sanitizeLine(rootNumber, "-")}${rootTitle}${rootMeeting}${rootDate}.`); + } + + return lines; +} + +function renderAgendaSections(agendaItems = [], returnedItems = []) { + if (!agendaItems.length && !returnedItems.length) { + return "### TOP-Bestand\nFür diese Sitzung wurden keine TOPs zugeordnet."; + } + + const assignedBlock = agendaItems.length ? agendaItems + .map((item) => { + const lines = [ + `## TOP ${sanitizeLine(item.agenda_number, String(item.id))}: ${sanitizeLine(item.title, "Ohne Titel")}`, + ]; + + const historyLines = renderFollowUpHistory(item); + if (historyLines.length) { + lines.push(...historyLines); + } + + if (item.case_number) { + lines.push(`Vorgang: ${item.case_number}`); + } + + lines.push(item.description?.trim() ? item.description.trim() : "Keine zusätzliche Beschreibung hinterlegt."); + + const comments = Array.isArray(item.protocol_comments) ? item.protocol_comments : []; + if (comments.length) { + lines.push("### Kommentare zum TOP"); + comments.forEach((comment) => { + const author = comment.author_name ? sanitizeLine(comment.author_name, "") : "unbekannt"; + const created = comment.created_at ? ` vom ${formatDateTime(comment.created_at)}` : ""; + lines.push(`- Kommentar von: ${author}${created}: ${sanitizeLine(comment.comment_text, "-")}`); + }); + } + + const decisions = Array.isArray(item.decisions) ? item.decisions : []; + if (!decisions.length) { + lines.push("Zu diesem TOP wurde kein Beschluss dokumentiert."); + return lines.join("\n\n"); + } + + decisions.forEach((decision) => { + lines.push(`### Beschluss ${sanitizeLine(decision.decision_number, String(decision.id))}: ${sanitizeLine(decision.title, "Ohne Titel")}`); + lines.push(decision.decision_text?.trim() || "Kein Beschlusstext hinterlegt."); + lines.push( + [ + `Ja-Stimmen: ${Number(decision.votes_yes ?? 0)}`, + `Nein-Stimmen: ${Number(decision.votes_no ?? 0)}`, + `Enthaltungen: ${Number(decision.votes_abstain ?? 0)}`, + ].join(" | ") + ); + lines.push(computeDecisionOutcome(decision)); + lines.push(`Status: ${decisionStatusLabel(decision.status)}`); + + if (decision.responsible_name) { + lines.push(`Verantwortlich: ${decision.responsible_name}`); + } + + if (decision.due_date) { + lines.push(`Frist: ${formatDateTime(decision.due_date)}`); + } + }); + + return lines.join("\n\n"); + }) + .join("\n\n") : ""; + + const returnedBlock = renderReturnedAgendaSections(returnedItems); + return [assignedBlock, returnedBlock].filter(Boolean).join("\n\n"); +} + +function ensureProtocolPlaceholders(template) { + let content = String(template ?? ""); + + if (!/\{\{\s*quorum_statement\s*\}\}/.test(content)) { + if (content.includes("## Protokollnotizen")) { + content = content.replace("## Protokollnotizen", "{{quorum_statement}}\n\n## Protokollnotizen"); + } else { + content = `${content.trim()}\n\n{{quorum_statement}}`; + } + } + + if (!/\{\{\s*attendees_block\s*\}\}/.test(content)) { + if (/\{\{\s*quorum_statement\s*\}\}/.test(content)) { + content = content.replace(/\{\{\s*quorum_statement\s*\}\}/, "{{quorum_statement}}\n{{attendees_block}}"); + } else if (content.includes("## Protokollnotizen")) { + content = content.replace("## Protokollnotizen", "{{attendees_block}}\n\n## Protokollnotizen"); + } else { + content = `${content.trim()}\n\n{{attendees_block}}`; + } + } + + return content; +} + +function replaceTokens(template, values) { + return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { + const value = values[key]; + if (value === undefined || value === null) return ""; + return String(value); + }); +} + +function linesToRtf(content, logoAsset = null) { + const lines = String(content ?? "").replace(/\r\n/g, "\n").split("\n"); + const rtfLines = [ + "{\\rtf1\\ansi\\deff0", + "{\\fonttbl{\\f0 Arial;}}", + "\\paperw11907\\paperh16840\\margl1134\\margr1134\\margt1134\\margb1134", + "\\viewkind4\\uc1", + ]; + + for (const rawLine of lines) { + const line = rawLine ?? ""; + const trimmed = line.trim(); + + if (!trimmed) { + rtfLines.push("{\\pard\\sa140\\par}"); + continue; + } + + if (trimmed === "VERTRAULICH") { + rtfLines.push(`{\\pard\\qc\\sa220\\b\\fs26 ${escapeRtf(trimmed)}\\par}`); + continue; + } + + if (trimmed.startsWith("# ")) { + rtfLines.push(`{\\pard\\qc\\sa220\\b\\fs32 ${escapeRtf(trimmed.slice(2))}\\par}`); + continue; + } + + if (trimmed.startsWith("## ")) { + rtfLines.push(`{\\pard\\sa200\\b\\fs26 ${escapeRtf(trimmed.slice(3))}\\par}`); + continue; + } + + if (trimmed.startsWith("### ")) { + rtfLines.push(`{\\pard\\sa160\\b\\fs23 ${escapeRtf(trimmed.slice(4))}\\par}`); + continue; + } + + if (trimmed.startsWith("- ")) { + rtfLines.push(`{\\pard\\li540\\fi-240\\sa120\\fs22 ${escapeRtf(trimmed)}\\par}`); + continue; + } + + rtfLines.push(`{\\pard\\sa120\\fs22 ${escapeRtf(line)}\\par}`); + } + + rtfLines.push("}"); + return rtfLines.join("\n"); +} + +function buildProtocolValues(meeting, agendaItems, attendees = [], returnedItems = []) { + const committeeName = meeting.committee_name?.trim() || "der Firma"; + const title = meeting.title?.trim(); + const notes = meeting.protocol_text?.trim() || "Keine zusätzlichen Protokollnotizen hinterlegt."; + + return { + committee_name: committeeName, + meeting_number: sanitizeLine(meeting.meeting_number, "offen"), + meeting_title_block: title ? `Titel: ${title}` : "", + meeting_date: formatDate(meeting.meeting_date), + meeting_type: meetingTypeLabel(meeting.meeting_type), + start_time: formatTime(meeting.start_time), + end_time: formatTime(meeting.end_time), + location: sanitizeLine(meeting.location, "nicht hinterlegt"), + chair_name: sanitizeLine(meeting.chair_name, "nicht hinterlegt"), + secretary_name: sanitizeLine(meeting.secretary_name, "nicht hinterlegt"), + quorum_statement: quorumStatement(meeting.is_quorate), + attendees_block: renderAttendeesBlock(attendees), + protocol_notes: notes, + agenda_sections: renderAgendaSections(agendaItems, returnedItems), + signature_place: `${sanitizeLine(meeting.location, "Ort")}, den ${formatDate(meeting.meeting_date)}`, + }; +} + +function generateDocumentNumber() { + return `D-${new Date().getFullYear()}-${Math.floor(Math.random() * 100000) + .toString() + .padStart(5, "0")}`; +} + +async function fetchActiveTemplate(db) { + const result = await db.query( + `SELECT id, title, content, content_format, logo_asset_id, version_label + FROM template_item + WHERE category = 'meeting_protocol' + AND is_active = TRUE + ORDER BY updated_at DESC, id DESC + LIMIT 1` + ); + return result.rows[0] || null; +} + +async function fetchMeetingProtocolData(db, meetingId) { + const meetingResult = await db.query( + `SELECT m.id, + m.meeting_number, + m.title, + m.meeting_type, + m.meeting_date, + m.start_time, + m.end_time, + m.location, + m.is_quorate, + m.protocol_text, + m.committee_id, + m.chair_user_id, + m.secretary_user_id, + c.name AS committee_name, + chair.full_name AS chair_name, + secretary.full_name AS secretary_name + FROM meeting m + LEFT JOIN committee c ON c.id = m.committee_id + LEFT JOIN app_user chair ON chair.id = m.chair_user_id + LEFT JOIN app_user secretary ON secretary.id = m.secretary_user_id + WHERE m.id = $1`, + [meetingId] + ); + + const meeting = meetingResult.rows[0]; + if (!meeting) return null; + + const attendeeResult = meeting.committee_id + ? await db.query( + `SELECT DISTINCT u.id, + u.full_name, + u.email, + u.role + FROM app_user u + LEFT JOIN committee_member cm + ON cm.user_id = u.id + AND cm.committee_id = $1 + WHERE u.is_active = TRUE + AND u.role <> 'advisor' + AND (u.committee_id = $1 OR cm.user_id IS NOT NULL) + ORDER BY u.full_name ASC, u.id ASC`, + [meeting.committee_id] + ) + : await db.query( + `SELECT DISTINCT u.id, + u.full_name, + u.email, + u.role + FROM app_user u + WHERE u.is_active = TRUE + AND u.role <> 'advisor' + AND u.id = ANY($1::bigint[]) + ORDER BY u.full_name ASC, u.id ASC`, + [[meeting.chair_user_id, meeting.secretary_user_id].filter(Boolean)] + ); + + const agendaResult = await db.query( + `SELECT ai.id, + ai.agenda_number, + ai.title, + ai.description, + ai.duration_minutes, + ai.case_id, + ai.parent_agenda_item_id, + ai.root_agenda_item_id, + ai.follow_up_type, + ai.follow_up_note, + ai.follow_up_created_from_meeting_id, + ai.follow_up_created_from_agenda_item_id, + ai.follow_up_created_at, + ai.follow_up_created_by, + c.case_number, + parent_ai.agenda_number AS parent_agenda_number, + parent_ai.title AS parent_title, + parent_meeting.meeting_number AS parent_meeting_number, + parent_meeting.meeting_date AS parent_meeting_date, + source_ai.agenda_number AS follow_up_source_agenda_number, + source_ai.title AS follow_up_source_agenda_title, + source_meeting.meeting_number AS follow_up_source_meeting_number, + source_meeting.meeting_date AS follow_up_source_meeting_date, + root_ai.agenda_number AS root_agenda_number, + root_ai.title AS root_title, + root_meeting.meeting_number AS root_meeting_number, + root_meeting.meeting_date AS root_meeting_date, + fubu.full_name AS follow_up_created_by_name + FROM meeting_agenda_item ai + LEFT JOIN br_case c ON c.id = ai.case_id + LEFT JOIN meeting_agenda_item parent_ai ON parent_ai.id = ai.parent_agenda_item_id + LEFT JOIN meeting parent_meeting ON parent_meeting.id = parent_ai.meeting_id + LEFT JOIN meeting_agenda_item source_ai ON source_ai.id = ai.follow_up_created_from_agenda_item_id + LEFT JOIN meeting source_meeting ON source_meeting.id = ai.follow_up_created_from_meeting_id + LEFT JOIN meeting_agenda_item root_ai ON root_ai.id = ai.root_agenda_item_id + LEFT JOIN meeting root_meeting ON root_meeting.id = root_ai.meeting_id + LEFT JOIN app_user fubu ON fubu.id = ai.follow_up_created_by + WHERE ai.meeting_id = $1 + ORDER BY + CASE WHEN ai.agenda_number ~ '^[0-9]+$' THEN ai.agenda_number::int ELSE NULL END NULLS LAST, + ai.agenda_number NULLS LAST, + ai.id`, + [meetingId] + ); + + const agendaItems = agendaResult.rows; + const agendaIds = agendaItems.map((item) => item.id); + + const decisionMap = new Map(agendaIds.map((id) => [id, []])); + if (agendaIds.length) { + const decisionResult = await db.query( + `SELECT d.id, + d.agenda_item_id, + d.decision_number, + d.title, + d.decision_text, + d.status, + d.votes_yes, + d.votes_no, + d.votes_abstain, + d.due_date, + u.full_name AS responsible_name + FROM decision d + LEFT JOIN app_user u ON u.id = d.responsible_user_id + WHERE d.meeting_id = $1 + AND d.agenda_item_id = ANY($2::bigint[]) + ORDER BY d.created_at, d.id`, + [meetingId, agendaIds] + ); + + decisionResult.rows.forEach((row) => { + const bucket = decisionMap.get(row.agenda_item_id) || []; + bucket.push(row); + decisionMap.set(row.agenda_item_id, bucket); + }); + } + + const commentMap = new Map(agendaIds.map((id) => [id, []])); + if (agendaIds.length) { + const commentResult = await db.query( + `SELECT c.id, + c.entity_id AS agenda_item_id, + c.comment_text, + c.created_at, + u.full_name AS author_name + FROM comment_item c + LEFT JOIN app_user u ON u.id = c.author_user_id + WHERE c.deleted_at IS NULL + AND c.entity_type = 'agenda_item' + AND COALESCE(c.include_in_protocol, FALSE) = TRUE + AND c.entity_id = ANY($1::bigint[]) + ORDER BY c.created_at, c.id`, + [agendaIds] + ); + + commentResult.rows.forEach((row) => { + const bucket = commentMap.get(row.agenda_item_id) || []; + bucket.push(row); + commentMap.set(row.agenda_item_id, bucket); + }); + } + + const returnedResult = await db.query( + `SELECT l.id, + l.agenda_item_id, + l.meeting_id, + l.agenda_number_at_return, + l.title_at_return, + l.return_type, + l.remark, + l.returned_at, + l.follow_up_agenda_item_id, + follow_up.title AS follow_up_title, + u.full_name AS returned_by_name + FROM meeting_agenda_item_return_log l + LEFT JOIN meeting_agenda_item follow_up ON follow_up.id = l.follow_up_agenda_item_id + LEFT JOIN app_user u ON u.id = l.returned_by + WHERE l.meeting_id = $1 + AND COALESCE(l.include_in_protocol, FALSE) = TRUE + ORDER BY l.returned_at, l.id`, + [meetingId] + ); + + const enrichedAgenda = agendaItems.map((item) => ({ + ...item, + decisions: decisionMap.get(item.id) || [], + protocol_comments: commentMap.get(item.id) || [], + })); + + return { meeting, agendaItems: enrichedAgenda, attendees: attendeeResult.rows, returnedItems: returnedResult.rows }; +} + +export async function ensureMeetingProtocolDocument({ db, meetingId, actorId }) { + const existing = await db.query( + `SELECT id, document_number, title, file_name, uploaded_at + FROM document_item + WHERE meeting_id = $1 + AND document_type = 'protokoll' + AND deleted_at IS NULL + ORDER BY uploaded_at DESC, id DESC + LIMIT 1`, + [meetingId] + ); + + if (existing.rows[0]) { + return existing.rows[0]; + } + + const data = await fetchMeetingProtocolData(db, meetingId); + if (!data) return null; + + const template = await fetchActiveTemplate(db); + const rawTemplateContent = template?.content?.trim() || DEFAULT_MEETING_PROTOCOL_TEMPLATE; + const normalizedTemplateContent = normalizeTemplateContentForProtocol(rawTemplateContent, template?.content_format || "markdown"); + const templateContent = ensureProtocolPlaceholders(normalizedTemplateContent); + const templateLogo = await loadTemplateLogo(db, template); + const values = { + ...buildProtocolValues(data.meeting, data.agendaItems, data.attendees, data.returnedItems), + template_logo: templateLogo ? "__BR_TEMPLATE_LOGO__" : "", + }; + const renderedText = replaceTokens(templateContent, values); + const rtf = linesToRtf(renderedText, templateLogo); + const buffer = Buffer.from(rtf, "utf8"); + + const filenameBase = `${data.meeting.meeting_number || `sitzung-${meetingId}`}-protokoll`; + const safeFilename = filenameBase.replace(/[^a-zA-Z0-9._-]/g, "_"); + const storageKey = `${new Date().toISOString().slice(0, 10)}/${uuidv4()}-${safeFilename}.rtf`; + const sha256 = crypto.createHash("sha256").update(buffer).digest("hex"); + const docMeta = await saveObject(storageKey, buffer, "application/rtf"); + + const insert = await db.query( + `INSERT INTO document_item( + document_number, + title, + document_type, + description, + version_label, + file_name, + storage_path, + storage_provider, + mime_type, + size_bytes, + sha256_hash, + confidentiality_level, + case_id, + meeting_id, + decision_id, + uploaded_by, + scan_status, + scan_engine, + scan_result, + uploaded_at, + updated_at + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NULL,$13,NULL,$14,$15,$16,$17,NOW(),NOW() + ) + RETURNING id, document_number, title, file_name, uploaded_at`, + [ + generateDocumentNumber(), + `Protokoll ${data.meeting.meeting_number}`, + "protokoll", + "Automatisch beim Beenden der Sitzung erzeugtes Sitzungsprotokoll.", + template?.version_label || "v1", + `${safeFilename}.rtf`, + docMeta.storage_path, + docMeta.storage_provider, + "application/rtf", + buffer.length, + sha256, + "vertraulich", + meetingId, + actorId, + "clean", + "system", + `Automatisch generiert aus Vorlage ${template?.title || "meeting_protocol"}`, + ] + ); + + await db.query( + `INSERT INTO document_scan_event(document_id, scanned_by_user_id, status, engine, result_text, scanned_at) + VALUES ($1,$2,$3,$4,$5,NOW())`, + [insert.rows[0].id, actorId, "clean", "system", "Automatisch generiertes Protokoll"] + ); + + return insert.rows[0]; +} diff --git a/backend/src/services/storage.js b/backend/src/services/storage.js new file mode 100644 index 0000000..d6460b8 --- /dev/null +++ b/backend/src/services/storage.js @@ -0,0 +1,119 @@ +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; +import { Readable } from "stream"; +import { env } from "../config/env.js"; + +const ENCRYPTION_MAGIC = Buffer.from("BROFFENC1"); +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; + +function resolveEncryptionKey(rawValue) { + const raw = String(rawValue || "").trim(); + if (!raw) return null; + + if (/^[0-9a-fA-F]{64}$/.test(raw)) { + return Buffer.from(raw, "hex"); + } + + try { + const base64 = Buffer.from(raw, "base64"); + if (base64.length === 32) return base64; + } catch { + // noop + } + + return crypto.createHash("sha256").update(raw, "utf8").digest(); +} + +function getConfiguredEncryptionKey() { + return resolveEncryptionKey(env.fileEncryptionKey); +} + +function encryptBuffer(buffer) { + const key = getConfiguredEncryptionKey(); + + if (!key) { + throw new Error("Dateiverschlüsselung ist aktiviert, aber FILE_ENCRYPTION_KEY fehlt oder ist ungültig."); + } + + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return Buffer.concat([ENCRYPTION_MAGIC, iv, authTag, encrypted]); +} + +function decryptBuffer(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < ENCRYPTION_MAGIC.length) { + return buffer; + } + + if (!buffer.subarray(0, ENCRYPTION_MAGIC.length).equals(ENCRYPTION_MAGIC)) { + return buffer; + } + + const key = getConfiguredEncryptionKey(); + if (!key) { + throw new Error("Die Datei ist verschlüsselt, aber FILE_ENCRYPTION_KEY ist nicht gesetzt."); + } + + const ivStart = ENCRYPTION_MAGIC.length; + const ivEnd = ivStart + IV_LENGTH; + const authTagEnd = ivEnd + AUTH_TAG_LENGTH; + + const iv = buffer.subarray(ivStart, ivEnd); + const authTag = buffer.subarray(ivEnd, authTagEnd); + const payload = buffer.subarray(authTagEnd); + + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(authTag); + + return Buffer.concat([decipher.update(payload), decipher.final()]); +} + +export async function saveObject(storageKey, buffer, contentType) { + if (env.storageProvider === "local") { + const fullPath = path.resolve(env.uploadDir, storageKey); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + + const outputBuffer = env.fileEncryptionEnabled ? encryptBuffer(buffer) : buffer; + fs.writeFileSync(fullPath, outputBuffer); + + return { + storage_provider: "local", + storage_path: fullPath, + content_type: contentType, + encrypted: env.fileEncryptionEnabled, + }; + } + + throw new Error("S3-Provider in dieser Referenz nicht implementiert"); +} + +export async function getObjectStream(storagePath) { + const raw = fs.readFileSync(storagePath); + const output = decryptBuffer(raw); + + return { + stream: Readable.from(output), + contentType: undefined, + }; +} + + +export async function deleteObject(storagePath) { + if (!storagePath) return; + + if (env.storageProvider === "local") { + try { + fs.unlinkSync(storagePath); + } catch (err) { + if (err?.code !== "ENOENT") throw err; + } + return; + } + + throw new Error("S3-Provider in dieser Referenz nicht implementiert"); +} diff --git a/backend/src/services/totp.js b/backend/src/services/totp.js new file mode 100644 index 0000000..f3d147c --- /dev/null +++ b/backend/src/services/totp.js @@ -0,0 +1,65 @@ +import * as OTPAuth from "otpauth"; +import QRCode from "qrcode"; +import { query } from "../config/db.js"; + +export async function startTotpEnrollment(user) { + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + issuer: "BR-Office", + label: user.email, + algorithm: "SHA1", + digits: 6, + period: 30, + secret, + }); + + await query( + `INSERT INTO user_totp_secret(user_id, secret_base32, is_verified) + VALUES ($1,$2,FALSE) + ON CONFLICT (user_id) DO UPDATE SET secret_base32 = EXCLUDED.secret_base32, is_verified = FALSE, verified_at = NULL`, + [user.id, secret.base32] + ); + + return { + secretBase32: secret.base32, + otpAuthUrl: totp.toString(), + qrCodeDataUrl: await QRCode.toDataURL(totp.toString()), + }; +} + +export async function verifyTotpEnrollment(user, token) { + const result = await query(`SELECT secret_base32 FROM user_totp_secret WHERE user_id = $1`, [user.id]); + const row = result.rows[0]; + if (!row) throw Object.assign(new Error("TOTP-Setup fehlt"), { status: 400, publicMessage: "TOTP-Setup fehlt" }); + + const totp = new OTPAuth.TOTP({ + issuer: "BR-Office", + label: user.email, + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(row.secret_base32), + }); + + const delta = totp.validate({ token, window: 1 }); + if (delta === null) throw Object.assign(new Error("Ungültiger TOTP-Code"), { status: 401, publicMessage: "Ungültiger TOTP-Code" }); + + await query(`UPDATE user_totp_secret SET is_verified = TRUE, verified_at = NOW() WHERE user_id = $1`, [user.id]); + await query(`UPDATE app_user SET two_factor_enabled = TRUE, last_mfa_at = NOW() WHERE id = $1`, [user.id]); + return { ok: true }; +} + +export async function verifyTotpCode(user, token) { + const result = await query(`SELECT secret_base32, is_verified FROM user_totp_secret WHERE user_id = $1`, [user.id]); + const row = result.rows[0]; + if (!row || !row.is_verified) return false; + const totp = new OTPAuth.TOTP({ + issuer: "BR-Office", + label: user.email, + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(row.secret_base32), + }); + return totp.validate({ token, window: 1 }) !== null; +} diff --git a/backend/src/services/webauthn.js b/backend/src/services/webauthn.js new file mode 100644 index 0000000..fdad283 --- /dev/null +++ b/backend/src/services/webauthn.js @@ -0,0 +1,148 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server"; +import { query } from "../config/db.js"; +import { env } from "../config/env.js"; + +function parseTransports(val) { + try { return val ? JSON.parse(val) : undefined; } catch { return undefined; } +} + +export async function beginRegistration(user) { + const result = await query( + `SELECT credential_id FROM webauthn_credential WHERE user_id = $1 AND revoked_at IS NULL`, + [user.id] + ); + + const options = await generateRegistrationOptions({ + rpName: "BR-Office", + rpID: env.rpId, + userName: user.email, + userDisplayName: user.full_name || user.email, + userID: String(user.id), + attestationType: "none", + excludeCredentials: result.rows.map((row) => ({ + id: row.credential_id, + type: "public-key", + })), + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }); + + await query( + `INSERT INTO webauthn_challenge(user_id, flow_type, challenge, expires_at) + VALUES ($1,'register',$2,NOW() + INTERVAL '10 minutes')`, + [user.id, options.challenge] + ); + + return options; +} + +export async function finishRegistration(user, credential) { + const result = await query( + `SELECT challenge FROM webauthn_challenge + WHERE user_id = $1 AND flow_type = 'register' AND used_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC LIMIT 1`, + [user.id] + ); + const row = result.rows[0]; + if (!row) throw Object.assign(new Error("WebAuthn-Challenge fehlt"), { status: 400, publicMessage: "WebAuthn-Challenge fehlt" }); + + const verification = await verifyRegistrationResponse({ + response: credential, + expectedChallenge: row.challenge, + expectedOrigin: env.rpOrigin, + expectedRPID: env.rpId, + }); + + if (!verification.verified || !verification.registrationInfo) { + throw Object.assign(new Error("Passkey-Registrierung fehlgeschlagen"), { status: 400, publicMessage: "Passkey-Registrierung fehlgeschlagen" }); + } + + const { credentialPublicKey, credentialID, counter, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; + + await query( + `INSERT INTO webauthn_credential(user_id, credential_id, public_key, counter, transports, credential_device_type, credential_backed_up, nickname) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, + [ + user.id, + Buffer.from(credentialID).toString("base64url"), + Buffer.from(credentialPublicKey).toString("base64url"), + counter, + JSON.stringify(credential.response?.transports || []), + credentialDeviceType, + credentialBackedUp, + credential.id, + ] + ); + await query(`UPDATE webauthn_challenge SET used_at = NOW() WHERE user_id = $1 AND flow_type = 'register' AND used_at IS NULL`, [user.id]); + await query(`UPDATE app_user SET last_mfa_at = NOW() WHERE id = $1`, [user.id]); + return { ok: true }; +} + +export async function beginAuthentication(user) { + const result = await query( + `SELECT credential_id, transports FROM webauthn_credential WHERE user_id = $1 AND revoked_at IS NULL`, + [user.id] + ); + const options = await generateAuthenticationOptions({ + rpID: env.rpId, + allowCredentials: result.rows.map((row) => ({ + id: row.credential_id, + type: "public-key", + transports: parseTransports(row.transports), + })), + userVerification: "preferred", + }); + await query( + `INSERT INTO webauthn_challenge(user_id, flow_type, challenge, expires_at) + VALUES ($1,'auth',$2,NOW() + INTERVAL '10 minutes')`, + [user.id, options.challenge] + ); + return options; +} + +export async function finishAuthentication(user, credential) { + const challengeResult = await query( + `SELECT challenge FROM webauthn_challenge + WHERE user_id = $1 AND flow_type = 'auth' AND used_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC LIMIT 1`, + [user.id] + ); + const challenge = challengeResult.rows[0]?.challenge; + if (!challenge) throw Object.assign(new Error("WebAuthn-Challenge fehlt"), { status: 400, publicMessage: "WebAuthn-Challenge fehlt" }); + + const credResult = await query( + `SELECT id, credential_id, public_key, counter FROM webauthn_credential + WHERE user_id = $1 AND credential_id = $2 AND revoked_at IS NULL`, + [user.id, credential.id] + ); + const dbCred = credResult.rows[0]; + if (!dbCred) throw Object.assign(new Error("Passkey nicht gefunden"), { status: 404, publicMessage: "Passkey nicht gefunden" }); + + const verification = await verifyAuthenticationResponse({ + response: credential, + expectedChallenge: challenge, + expectedOrigin: env.rpOrigin, + expectedRPID: env.rpId, + authenticator: { + credentialID: dbCred.credential_id, + credentialPublicKey: Buffer.from(dbCred.public_key, "base64url"), + counter: dbCred.counter, + }, + }); + + if (!verification.verified) { + throw Object.assign(new Error("Passkey-Prüfung fehlgeschlagen"), { status: 401, publicMessage: "Passkey-Prüfung fehlgeschlagen" }); + } + + await query(`UPDATE webauthn_credential SET counter = $2, last_used_at = NOW() WHERE id = $1`, [dbCred.id, verification.authenticationInfo.newCounter]); + await query(`UPDATE webauthn_challenge SET used_at = NOW() WHERE user_id = $1 AND flow_type = 'auth' AND used_at IS NULL`, [user.id]); + await query(`UPDATE app_user SET last_mfa_at = NOW() WHERE id = $1`, [user.id]); + return { ok: true }; +} diff --git a/backend/src/utils/audit.js b/backend/src/utils/audit.js new file mode 100644 index 0000000..a0133d3 --- /dev/null +++ b/backend/src/utils/audit.js @@ -0,0 +1,9 @@ +import { query } from "../config/db.js"; + +export async function writeAudit({ actorId = null, action, entityType = null, entityId = null, metadata = null, ipAddress = null }) { + await query( + `INSERT INTO audit_log(actor_user_id, action, entity_type, entity_id, metadata, ip_address) + VALUES ($1,$2,$3,$4,$5,$6)`, + [actorId, action, entityType, entityId, metadata, ipAddress] + ); +} diff --git a/backend/src/utils/jwt.js b/backend/src/utils/jwt.js new file mode 100644 index 0000000..dabd6df --- /dev/null +++ b/backend/src/utils/jwt.js @@ -0,0 +1,33 @@ +import jwt from "jsonwebtoken"; +import { env } from "../config/env.js"; + +export function signAccessToken(user) { + return jwt.sign( + { + sub: user.id, + role: user.role, + committee_id: user.committee_id || null, + email: user.email || null, + mfa_verified: Boolean(user.mfa_verified), + amr: user.amr || [], + }, + env.jwtAccessSecret, + { expiresIn: "15m" } + ); +} + +export function signRefreshToken(user) { + return jwt.sign( + { sub: user.id, role: user.role, committee_id: user.committee_id || null, email: user.email || null, mfa_verified: Boolean(user.mfa_verified) }, + env.jwtRefreshSecret, + { expiresIn: "7d" } + ); +} + +export function verifyAccessToken(token) { + return jwt.verify(token, env.jwtAccessSecret); +} + +export function verifyRefreshToken(token) { + return jwt.verify(token, env.jwtRefreshSecret); +} diff --git a/database/README.md b/database/README.md new file mode 100644 index 0000000..9a940e1 --- /dev/null +++ b/database/README.md @@ -0,0 +1,12 @@ +Die Migrationen sind in der vorgesehenen Reihenfolge auszuführen: + +1. `001_extensions_and_types.sql` +2. `002_core_schema.sql` +3. `003_triggers_views_and_helpers.sql` +4. `004_rls_policies.sql` +5. `005_roles_and_seed.sql` + +Empfehlung: +- Migrationen mit eigener Rolle `br_migration` +- Anwendung mit Rolle `br_app` +- Reporting nur über `br_reporting` diff --git a/database/migrations/001_extensions_and_types.sql b/database/migrations/001_extensions_and_types.sql new file mode 100644 index 0000000..740d5c4 --- /dev/null +++ b/database/migrations/001_extensions_and_types.sql @@ -0,0 +1,14 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN + CREATE TYPE role_type AS ENUM ('admin','chair','member','secretary','advisor'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'conf_level') THEN + CREATE TYPE conf_level AS ENUM ('intern','vertraulich','streng_vertraulich'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scan_status_type') THEN + CREATE TYPE scan_status_type AS ENUM ('pending','clean','infected','failed'); + END IF; +END$$; diff --git a/database/migrations/002_core_schema.sql b/database/migrations/002_core_schema.sql new file mode 100644 index 0000000..75236c9 --- /dev/null +++ b/database/migrations/002_core_schema.sql @@ -0,0 +1,268 @@ +CREATE TABLE IF NOT EXISTS committee ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + committee_type VARCHAR(50) NOT NULL DEFAULT 'general', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_user ( + id BIGSERIAL PRIMARY KEY, + full_name VARCHAR(120) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash TEXT NOT NULL DEFAULT '', + role role_type NOT NULL DEFAULT 'member', + committee_id BIGINT REFERENCES committee(id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + security_classification VARCHAR(30) NOT NULL DEFAULT 'standard', + force_password_reset BOOLEAN NOT NULL DEFAULT FALSE, + two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE, + last_mfa_at TIMESTAMPTZ, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS refresh_token ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS br_case ( + id BIGSERIAL PRIMARY KEY, + case_number VARCHAR(50) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + case_type VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'neu', + priority VARCHAR(20) NOT NULL DEFAULT 'mittel', + confidentiality_level conf_level NOT NULL DEFAULT 'intern', + summary TEXT, + affected_employee_name VARCHAR(120), + department VARCHAR(120), + committee_id BIGINT REFERENCES committee(id), + owner_user_id BIGINT NOT NULL REFERENCES app_user(id), + legal_hold BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS deadline ( + id BIGSERIAL PRIMARY KEY, + case_id BIGINT REFERENCES br_case(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + deadline_type VARCHAR(50) NOT NULL, + due_date TIMESTAMPTZ NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'offen', + remind_days_before INTEGER NOT NULL DEFAULT 2, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS meeting ( + id BIGSERIAL PRIMARY KEY, + meeting_number VARCHAR(50) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + meeting_type VARCHAR(50) NOT NULL DEFAULT 'ordentlich', + meeting_date DATE NOT NULL, + start_time TIME, + end_time TIME, + location VARCHAR(255), + status VARCHAR(30) NOT NULL DEFAULT 'geplant', + protocol_text TEXT, + is_quorate BOOLEAN, + quorum_recorded_at TIMESTAMPTZ, + quorum_recorded_by BIGINT REFERENCES app_user(id), + committee_id BIGINT REFERENCES committee(id), + chair_user_id BIGINT REFERENCES app_user(id), + secretary_user_id BIGINT REFERENCES app_user(id), + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS meeting_agenda_item ( + id BIGSERIAL PRIMARY KEY, + meeting_id BIGINT NOT NULL REFERENCES meeting(id) ON DELETE CASCADE, + agenda_number VARCHAR(20) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + duration_minutes INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS decision ( + id BIGSERIAL PRIMARY KEY, + decision_number VARCHAR(50) UNIQUE NOT NULL, + meeting_id BIGINT REFERENCES meeting(id) ON DELETE SET NULL, + agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL, + case_id BIGINT REFERENCES br_case(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + decision_text TEXT NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'offen', + votes_yes INTEGER NOT NULL DEFAULT 0, + votes_no INTEGER NOT NULL DEFAULT 0, + votes_abstain INTEGER NOT NULL DEFAULT 0, + responsible_user_id BIGINT REFERENCES app_user(id), + due_date TIMESTAMPTZ, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS task ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(30) NOT NULL DEFAULT 'offen', + priority VARCHAR(20) NOT NULL DEFAULT 'mittel', + assigned_user_id BIGINT REFERENCES app_user(id), + backup_user_id BIGINT REFERENCES app_user(id), + case_id BIGINT REFERENCES br_case(id) ON DELETE SET NULL, + meeting_id BIGINT REFERENCES meeting(id) ON DELETE SET NULL, + decision_id BIGINT REFERENCES decision(id) ON DELETE SET NULL, + due_date TIMESTAMPTZ, + confidentiality_level conf_level NOT NULL DEFAULT 'intern', + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS document_item ( + id BIGSERIAL PRIMARY KEY, + document_number VARCHAR(50) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + document_type VARCHAR(50) NOT NULL, + description TEXT, + version_label VARCHAR(30) NOT NULL DEFAULT 'v1', + file_name VARCHAR(255), + storage_path TEXT, + storage_provider VARCHAR(20) NOT NULL DEFAULT 'local', + mime_type VARCHAR(100), + size_bytes BIGINT, + sha256_hash VARCHAR(64), + confidentiality_level conf_level NOT NULL DEFAULT 'intern', + case_id BIGINT REFERENCES br_case(id) ON DELETE SET NULL, + meeting_id BIGINT REFERENCES meeting(id) ON DELETE SET NULL, + decision_id BIGINT REFERENCES decision(id) ON DELETE SET NULL, + uploaded_by BIGINT REFERENCES app_user(id), + is_signed BOOLEAN NOT NULL DEFAULT FALSE, + scan_status scan_status_type NOT NULL DEFAULT 'pending', + scan_engine VARCHAR(50), + scan_result TEXT, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS document_scan_event ( + id BIGSERIAL PRIMARY KEY, + document_id BIGINT NOT NULL REFERENCES document_item(id) ON DELETE CASCADE, + scanned_by_user_id BIGINT REFERENCES app_user(id), + status scan_status_type NOT NULL, + engine VARCHAR(50) NOT NULL, + result_text TEXT, + scanned_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS comment_item ( + id BIGSERIAL PRIMARY KEY, + entity_type VARCHAR(50) NOT NULL, + entity_id BIGINT NOT NULL, + comment_text TEXT NOT NULL, + visibility VARCHAR(30) NOT NULL DEFAULT 'intern', + author_user_id BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS template_item ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + category VARCHAR(50) NOT NULL, + description TEXT, + content TEXT NOT NULL, + version_label VARCHAR(30) NOT NULL DEFAULT 'v1', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS notification_item ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + notification_type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + entity_type VARCHAR(50), + entity_id BIGINT, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGSERIAL PRIMARY KEY, + actor_user_id BIGINT REFERENCES app_user(id), + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50), + entity_id BIGINT, + metadata JSONB, + ip_address INET, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS auth_challenge ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + challenge_type VARCHAR(40) NOT NULL, + challenge_value TEXT NOT NULL, + metadata JSONB, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS webauthn_challenge ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + flow_type VARCHAR(40) NOT NULL, + challenge TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS webauthn_credential ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + counter BIGINT NOT NULL DEFAULT 0, + transports JSONB, + credential_device_type VARCHAR(50), + credential_backed_up BOOLEAN NOT NULL DEFAULT FALSE, + nickname VARCHAR(120), + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_totp_secret ( + user_id BIGINT PRIMARY KEY REFERENCES app_user(id) ON DELETE CASCADE, + secret_base32 TEXT NOT NULL, + is_verified BOOLEAN NOT NULL DEFAULT FALSE, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/database/migrations/003_triggers_views_and_helpers.sql b/database/migrations/003_triggers_views_and_helpers.sql new file mode 100644 index 0000000..27aa53e --- /dev/null +++ b/database/migrations/003_triggers_views_and_helpers.sql @@ -0,0 +1,89 @@ +CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_case_updated_at') THEN + CREATE TRIGGER trg_case_updated_at BEFORE UPDATE ON br_case FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_deadline_updated_at BEFORE UPDATE ON deadline FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_meeting_updated_at BEFORE UPDATE ON meeting FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_decision_updated_at BEFORE UPDATE ON decision FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_task_updated_at BEFORE UPDATE ON task FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_document_updated_at BEFORE UPDATE ON document_item FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TRIGGER trg_template_updated_at BEFORE UPDATE ON template_item FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +CREATE OR REPLACE FUNCTION audit_if_changed() RETURNS trigger AS $$ +BEGIN + INSERT INTO audit_log(actor_user_id, action, entity_type, entity_id, metadata) + VALUES ( + NULL, + TG_OP, + TG_TABLE_NAME, + COALESCE(NEW.id, OLD.id), + jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW)) + ); + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_audit_case') THEN + CREATE TRIGGER trg_audit_case AFTER INSERT OR UPDATE OR DELETE ON br_case FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + CREATE TRIGGER trg_audit_deadline AFTER INSERT OR UPDATE OR DELETE ON deadline FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + CREATE TRIGGER trg_audit_meeting AFTER INSERT OR UPDATE OR DELETE ON meeting FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + CREATE TRIGGER trg_audit_decision AFTER INSERT OR UPDATE OR DELETE ON decision FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + CREATE TRIGGER trg_audit_task AFTER INSERT OR UPDATE OR DELETE ON task FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + CREATE TRIGGER trg_audit_document AFTER INSERT OR UPDATE OR DELETE ON document_item FOR EACH ROW EXECUTE FUNCTION audit_if_changed(); + END IF; +END $$; + +CREATE OR REPLACE FUNCTION app_user_id() RETURNS BIGINT AS $$ + SELECT COALESCE(NULLIF(current_setting('app.user_id', true), ''), '0')::BIGINT; +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION app_role() RETURNS TEXT AS $$ + SELECT COALESCE(NULLIF(current_setting('app.user_role', true), ''), 'anonymous'); +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION app_committee_id() RETURNS BIGINT AS $$ + SELECT COALESCE(NULLIF(current_setting('app.committee_id', true), ''), '0')::BIGINT; +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION can_read_conf_level(level conf_level, owner BIGINT, committee BIGINT) RETURNS BOOLEAN AS $$ +BEGIN + IF app_role() = 'admin' THEN RETURN TRUE; END IF; + IF level = 'intern' THEN RETURN TRUE; END IF; + IF level = 'vertraulich' THEN + RETURN owner = app_user_id() OR committee = app_committee_id() OR app_role() IN ('chair','secretary'); + END IF; + RETURN owner = app_user_id() OR app_role() IN ('chair'); +END; +$$ LANGUAGE plpgsql STABLE; + +CREATE OR REPLACE VIEW v_case_masked AS +SELECT + id, + case_number, + title, + case_type, + status, + priority, + confidentiality_level, + CASE + WHEN confidentiality_level = 'streng_vertraulich' AND app_role() NOT IN ('admin','chair') AND owner_user_id <> app_user_id() + THEN '***' + ELSE COALESCE(affected_employee_name, '') + END AS affected_employee_name_masked, + department, + committee_id, + owner_user_id, + created_at +FROM br_case +WHERE deleted_at IS NULL; diff --git a/database/migrations/004_rls_policies.sql b/database/migrations/004_rls_policies.sql new file mode 100644 index 0000000..bf37ced --- /dev/null +++ b/database/migrations/004_rls_policies.sql @@ -0,0 +1,161 @@ +ALTER TABLE br_case ENABLE ROW LEVEL SECURITY; +ALTER TABLE deadline ENABLE ROW LEVEL SECURITY; +ALTER TABLE task ENABLE ROW LEVEL SECURITY; +ALTER TABLE document_item ENABLE ROW LEVEL SECURITY; +ALTER TABLE comment_item ENABLE ROW LEVEL SECURITY; +ALTER TABLE notification_item ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_user ENABLE ROW LEVEL SECURITY; +ALTER TABLE meeting ENABLE ROW LEVEL SECURITY; +ALTER TABLE decision ENABLE ROW LEVEL SECURITY; +ALTER TABLE template_item ENABLE ROW LEVEL SECURITY; +ALTER TABLE committee ENABLE ROW LEVEL SECURITY; + +ALTER TABLE br_case FORCE ROW LEVEL SECURITY; +ALTER TABLE deadline FORCE ROW LEVEL SECURITY; +ALTER TABLE task FORCE ROW LEVEL SECURITY; +ALTER TABLE document_item FORCE ROW LEVEL SECURITY; +ALTER TABLE comment_item FORCE ROW LEVEL SECURITY; +ALTER TABLE notification_item FORCE ROW LEVEL SECURITY; +ALTER TABLE app_user FORCE ROW LEVEL SECURITY; +ALTER TABLE meeting FORCE ROW LEVEL SECURITY; +ALTER TABLE decision FORCE ROW LEVEL SECURITY; +ALTER TABLE template_item FORCE ROW LEVEL SECURITY; +ALTER TABLE committee FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS committee_select ON committee; +DROP POLICY IF EXISTS committee_modify ON committee; +CREATE POLICY committee_select ON committee FOR SELECT USING (TRUE); +CREATE POLICY committee_modify ON committee FOR ALL USING (app_role() IN ('admin','chair')) WITH CHECK (app_role() IN ('admin','chair')); + +DROP POLICY IF EXISTS user_select ON app_user; +DROP POLICY IF EXISTS user_modify ON app_user; +CREATE POLICY user_select ON app_user FOR SELECT USING ( + app_role() IN ('admin','chair') OR id = app_user_id() +); +CREATE POLICY user_modify ON app_user FOR ALL USING ( + app_role() = 'admin' OR id = app_user_id() +) WITH CHECK ( + app_role() = 'admin' OR id = app_user_id() +); + +DROP POLICY IF EXISTS br_case_select_policy ON br_case; +DROP POLICY IF EXISTS br_case_modify_policy ON br_case; +CREATE POLICY br_case_select_policy ON br_case FOR SELECT USING ( + deleted_at IS NULL AND can_read_conf_level(confidentiality_level, owner_user_id, committee_id) +); +CREATE POLICY br_case_modify_policy ON br_case FOR ALL USING ( + deleted_at IS NULL AND ( + app_role() = 'admin' + OR owner_user_id = app_user_id() + OR (committee_id = app_committee_id() AND app_role() IN ('chair', 'secretary')) + ) +) WITH CHECK (can_read_conf_level(confidentiality_level, owner_user_id, committee_id)); + +DROP POLICY IF EXISTS deadline_select_policy ON deadline; +DROP POLICY IF EXISTS deadline_modify_policy ON deadline; +CREATE POLICY deadline_select_policy ON deadline FOR SELECT USING ( + deleted_at IS NULL AND EXISTS ( + SELECT 1 FROM br_case c WHERE c.id = deadline.case_id + AND can_read_conf_level(c.confidentiality_level, c.owner_user_id, c.committee_id) + ) +); +CREATE POLICY deadline_modify_policy ON deadline FOR ALL USING ( + deleted_at IS NULL AND EXISTS ( + SELECT 1 FROM br_case c WHERE c.id = deadline.case_id + AND ( + app_role() = 'admin' + OR c.owner_user_id = app_user_id() + OR (c.committee_id = app_committee_id() AND app_role() IN ('chair', 'secretary')) + ) + ) +) WITH CHECK (TRUE); + +DROP POLICY IF EXISTS meeting_select_policy ON meeting; +DROP POLICY IF EXISTS meeting_modify_policy ON meeting; +CREATE POLICY meeting_select_policy ON meeting FOR SELECT USING ( + committee_id IS NULL OR committee_id = app_committee_id() OR app_role() IN ('admin','chair','secretary') +); +CREATE POLICY meeting_modify_policy ON meeting FOR ALL USING ( + app_role() IN ('admin','chair','secretary') +) WITH CHECK (app_role() IN ('admin','chair','secretary')); + +DROP POLICY IF EXISTS decision_select_policy ON decision; +DROP POLICY IF EXISTS decision_modify_policy ON decision; +CREATE POLICY decision_select_policy ON decision FOR SELECT USING ( + case_id IS NULL OR EXISTS ( + SELECT 1 FROM br_case c WHERE c.id = decision.case_id + AND can_read_conf_level(c.confidentiality_level, c.owner_user_id, c.committee_id) + ) OR app_role() IN ('admin','chair','secretary') +); +CREATE POLICY decision_modify_policy ON decision FOR ALL USING ( + app_role() IN ('admin','chair','secretary') OR responsible_user_id = app_user_id() +) WITH CHECK (TRUE); + +DROP POLICY IF EXISTS task_select_policy ON task; +DROP POLICY IF EXISTS task_modify_policy ON task; +CREATE POLICY task_select_policy ON task FOR SELECT USING ( + deleted_at IS NULL AND ( + assigned_user_id = app_user_id() + OR backup_user_id = app_user_id() + OR app_role() IN ('admin','chair','secretary') + ) +); +CREATE POLICY task_modify_policy ON task FOR ALL USING ( + deleted_at IS NULL AND ( + app_role() IN ('admin','chair','secretary') + OR assigned_user_id = app_user_id() + OR created_by = app_user_id() + ) +) WITH CHECK (TRUE); + +DROP POLICY IF EXISTS document_select_policy ON document_item; +DROP POLICY IF EXISTS document_modify_policy ON document_item; +CREATE POLICY document_select_policy ON document_item FOR SELECT USING ( + deleted_at IS NULL + AND scan_status = 'clean' + AND ( + app_role() = 'admin' + OR uploaded_by = app_user_id() + OR EXISTS ( + SELECT 1 FROM br_case c + WHERE c.id = document_item.case_id + AND can_read_conf_level(document_item.confidentiality_level, c.owner_user_id, c.committee_id) + ) + OR case_id IS NULL + ) +); +CREATE POLICY document_modify_policy ON document_item FOR ALL USING ( + deleted_at IS NULL AND ( + app_role() IN ('admin','chair','secretary') + OR uploaded_by = app_user_id() + ) +) WITH CHECK (TRUE); + +DROP POLICY IF EXISTS comment_select_policy ON comment_item; +DROP POLICY IF EXISTS comment_modify_policy ON comment_item; +CREATE POLICY comment_select_policy ON comment_item FOR SELECT USING ( + deleted_at IS NULL AND ( + visibility = 'intern' + OR (visibility = 'vertraulich' AND app_role() IN ('admin','chair','secretary')) + ) +); +CREATE POLICY comment_modify_policy ON comment_item FOR ALL USING ( + deleted_at IS NULL AND ( + author_user_id = app_user_id() + OR app_role() IN ('admin','chair','secretary') + ) +) WITH CHECK (TRUE); + +DROP POLICY IF EXISTS template_select_policy ON template_item; +DROP POLICY IF EXISTS template_modify_policy ON template_item; +CREATE POLICY template_select_policy ON template_item FOR SELECT USING (TRUE); +CREATE POLICY template_modify_policy ON template_item FOR ALL USING (app_role() IN ('admin','chair','secretary')) WITH CHECK (app_role() IN ('admin','chair','secretary')); + +DROP POLICY IF EXISTS notification_select_policy ON notification_item; +DROP POLICY IF EXISTS notification_modify_policy ON notification_item; +CREATE POLICY notification_select_policy ON notification_item FOR SELECT USING ( + user_id = app_user_id() OR app_role() IN ('admin','chair','secretary') +); +CREATE POLICY notification_modify_policy ON notification_item FOR ALL USING ( + user_id = app_user_id() OR app_role() IN ('admin','chair','secretary') +) WITH CHECK (TRUE); diff --git a/database/migrations/005_roles_and_seed.sql b/database/migrations/005_roles_and_seed.sql new file mode 100644 index 0000000..10e34ec --- /dev/null +++ b/database/migrations/005_roles_and_seed.sql @@ -0,0 +1,23 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'br_app') THEN + CREATE ROLE br_app LOGIN PASSWORD 'change_me'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'br_migration') THEN + CREATE ROLE br_migration LOGIN PASSWORD 'change_me'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'br_reporting') THEN + CREATE ROLE br_reporting LOGIN PASSWORD 'change_me'; + END IF; +END$$; + +GRANT USAGE ON SCHEMA public TO br_app, br_reporting; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO br_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO br_app; +GRANT SELECT ON v_case_masked TO br_reporting; +REVOKE ALL ON audit_log FROM PUBLIC; +GRANT SELECT ON audit_log TO br_app; + +INSERT INTO committee(name, committee_type) +VALUES ('Gesamtbetriebsrat', 'general') +ON CONFLICT DO NOTHING; diff --git a/database/migrations/006_prod_login_hardening.sql b/database/migrations/006_prod_login_hardening.sql new file mode 100644 index 0000000..f1cd410 --- /dev/null +++ b/database/migrations/006_prod_login_hardening.sql @@ -0,0 +1,32 @@ +-- === LOGIN FUNCTION === + +CREATE OR REPLACE FUNCTION auth_get_user_for_login(p_email TEXT) +RETURNS TABLE ( + id BIGINT, + email VARCHAR, + password_hash TEXT, + is_active BOOLEAN, + role role_type, + committee_id BIGINT, + two_factor_enabled BOOLEAN, + locked_until TIMESTAMPTZ +) +LANGUAGE sql +SECURITY DEFINER +AS $$ + SELECT + u.id, + u.email, + u.password_hash, + u.is_active, + u.role, + u.committee_id, + u.two_factor_enabled, + u.locked_until + FROM app_user u + WHERE LOWER(u.email) = LOWER(p_email) + LIMIT 1; +$$; + +REVOKE ALL ON FUNCTION auth_get_user_for_login(TEXT) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION auth_get_user_for_login(TEXT) TO br_app; diff --git a/database/migrations/007_auth_functions.sql b/database/migrations/007_auth_functions.sql new file mode 100644 index 0000000..f1cd410 --- /dev/null +++ b/database/migrations/007_auth_functions.sql @@ -0,0 +1,32 @@ +-- === LOGIN FUNCTION === + +CREATE OR REPLACE FUNCTION auth_get_user_for_login(p_email TEXT) +RETURNS TABLE ( + id BIGINT, + email VARCHAR, + password_hash TEXT, + is_active BOOLEAN, + role role_type, + committee_id BIGINT, + two_factor_enabled BOOLEAN, + locked_until TIMESTAMPTZ +) +LANGUAGE sql +SECURITY DEFINER +AS $$ + SELECT + u.id, + u.email, + u.password_hash, + u.is_active, + u.role, + u.committee_id, + u.two_factor_enabled, + u.locked_until + FROM app_user u + WHERE LOWER(u.email) = LOWER(p_email) + LIMIT 1; +$$; + +REVOKE ALL ON FUNCTION auth_get_user_for_login(TEXT) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION auth_get_user_for_login(TEXT) TO br_app; diff --git a/database/migrations/008_case_numbers_calendar_and_committee_defaults.sql b/database/migrations/008_case_numbers_calendar_and_committee_defaults.sql new file mode 100644 index 0000000..c701650 --- /dev/null +++ b/database/migrations/008_case_numbers_calendar_and_committee_defaults.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS case_number_counter ( + period_key VARCHAR(7) PRIMARY KEY, + last_value INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION next_case_number(p_now TIMESTAMPTZ DEFAULT NOW()) +RETURNS VARCHAR(50) +LANGUAGE plpgsql +AS $$ +DECLARE + period_key VARCHAR(7); + next_value INTEGER; +BEGIN + period_key := TO_CHAR(p_now, 'YYYY_MM'); + + INSERT INTO case_number_counter (period_key, last_value, updated_at) + VALUES (period_key, 1, NOW()) + ON CONFLICT (period_key) + DO UPDATE SET + last_value = case_number_counter.last_value + 1, + updated_at = NOW() + RETURNING last_value INTO next_value; + + RETURN period_key || '_' || LPAD(next_value::TEXT, 4, '0'); +END; +$$; + +ALTER TABLE br_case + ALTER COLUMN case_number SET DEFAULT next_case_number(); + +UPDATE br_case +SET case_number = next_case_number(created_at) +WHERE case_number IS NULL OR BTRIM(case_number) = ''; + +ALTER TABLE committee + ADD COLUMN IF NOT EXISTS chair_user_id BIGINT REFERENCES app_user(id), + ADD COLUMN IF NOT EXISTS secretary_user_id BIGINT REFERENCES app_user(id), + ADD COLUMN IF NOT EXISTS default_user_id BIGINT REFERENCES app_user(id); + +CREATE INDEX IF NOT EXISTS idx_committee_chair_user_id ON committee(chair_user_id); +CREATE INDEX IF NOT EXISTS idx_committee_secretary_user_id ON committee(secretary_user_id); +CREATE INDEX IF NOT EXISTS idx_committee_default_user_id ON committee(default_user_id); +CREATE INDEX IF NOT EXISTS idx_br_case_committee_id ON br_case(committee_id); +CREATE INDEX IF NOT EXISTS idx_task_assigned_user_id ON task(assigned_user_id); +CREATE INDEX IF NOT EXISTS idx_meeting_committee_id ON meeting(committee_id); +CREATE INDEX IF NOT EXISTS idx_deadline_case_id ON deadline(case_id); diff --git a/database/migrations/008_case_type_catalog_and_case_workflow.sql b/database/migrations/008_case_type_catalog_and_case_workflow.sql new file mode 100644 index 0000000..fc2d15d --- /dev/null +++ b/database/migrations/008_case_type_catalog_and_case_workflow.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS case_type_catalog ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) UNIQUE NOT NULL, + label VARCHAR(120) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO case_type_catalog (code, label, sort_order) +VALUES + ('anhoerung', 'Anhörung', 10), + ('beschwerde', 'Beschwerde', 20), + ('einstellung', 'Einstellung', 30), + ('kuendigung', 'Kündigung', 40), + ('personalmassnahme', 'Personalmaßnahme', 50), + ('versetzung', 'Versetzung', 60), + ('sonstiges', 'Sonstiges', 999) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO case_type_catalog (code, label, sort_order) +SELECT DISTINCT + LOWER(REGEXP_REPLACE(TRIM(case_type), '[^a-zA-Z0-9]+', '_', 'g')) AS code, + TRIM(case_type) AS label, + 500 +FROM br_case +WHERE case_type IS NOT NULL + AND TRIM(case_type) <> '' +ON CONFLICT (code) DO NOTHING; + +ALTER TABLE br_case + ADD COLUMN IF NOT EXISTS case_type_id BIGINT REFERENCES case_type_catalog(id); + +UPDATE br_case c +SET case_type_id = ct.id +FROM case_type_catalog ct +WHERE c.case_type_id IS NULL + AND c.case_type IS NOT NULL + AND TRIM(c.case_type) <> '' + AND LOWER(REGEXP_REPLACE(TRIM(c.case_type), '[^a-zA-Z0-9]+', '_', 'g')) = ct.code; + +UPDATE br_case +SET case_type_id = ( + SELECT id FROM case_type_catalog WHERE code = 'sonstiges' +) +WHERE case_type_id IS NULL; + +ALTER TABLE br_case + ALTER COLUMN case_type_id SET NOT NULL; + +ALTER TABLE br_case + ALTER COLUMN priority SET DEFAULT 'mittel'; diff --git a/database/migrations/009_calendar_personal_events_and_sync.sql b/database/migrations/009_calendar_personal_events_and_sync.sql new file mode 100644 index 0000000..c1ff740 --- /dev/null +++ b/database/migrations/009_calendar_personal_events_and_sync.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS calendar_event ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + event_kind VARCHAR(30) NOT NULL DEFAULT 'personal', + source_type VARCHAR(30) NOT NULL DEFAULT 'manual', + source_uid VARCHAR(255), + source_name VARCHAR(255), + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ, + all_day BOOLEAN NOT NULL DEFAULT FALSE, + location VARCHAR(255), + status VARCHAR(30) NOT NULL DEFAULT 'geplant', + priority VARCHAR(20) NOT NULL DEFAULT 'mittel', + visibility conf_level NOT NULL DEFAULT 'intern', + owner_user_id BIGINT REFERENCES app_user(id), + committee_id BIGINT REFERENCES committee(id), + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_calendar_event_starts_at ON calendar_event(starts_at); +CREATE INDEX IF NOT EXISTS idx_calendar_event_owner_user_id ON calendar_event(owner_user_id); +CREATE INDEX IF NOT EXISTS idx_calendar_event_committee_id ON calendar_event(committee_id); +CREATE INDEX IF NOT EXISTS idx_calendar_event_source_type_uid ON calendar_event(source_type, source_uid); diff --git a/database/migrations/010_case_type_catalog_and_dropdowns.sql b/database/migrations/010_case_type_catalog_and_dropdowns.sql new file mode 100644 index 0000000..72f1a0a --- /dev/null +++ b/database/migrations/010_case_type_catalog_and_dropdowns.sql @@ -0,0 +1,54 @@ +CREATE TABLE IF NOT EXISTS case_type_catalog ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) UNIQUE NOT NULL, + label VARCHAR(120) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO case_type_catalog (code, label, sort_order) +VALUES + ('anhoerung', 'Anhörung', 10), + ('beschwerde', 'Beschwerde', 20), + ('einstellung', 'Einstellung', 30), + ('kuendigung', 'Kündigung', 40), + ('personalmassnahme', 'Personalmaßnahme', 50), + ('versetzung', 'Versetzung', 60), + ('sonstiges', 'Sonstiges', 999) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO case_type_catalog (code, label, sort_order) +SELECT DISTINCT + LOWER(REGEXP_REPLACE(TRIM(case_type), '[^a-zA-Z0-9]+', '_', 'g')) AS code, + TRIM(case_type) AS label, + 500 +FROM br_case +WHERE case_type IS NOT NULL + AND TRIM(case_type) <> '' +ON CONFLICT (code) DO NOTHING; + +ALTER TABLE br_case + ADD COLUMN IF NOT EXISTS case_type_id BIGINT REFERENCES case_type_catalog(id); + +UPDATE br_case c +SET case_type_id = ct.id +FROM case_type_catalog ct +WHERE c.case_type_id IS NULL + AND c.case_type IS NOT NULL + AND TRIM(c.case_type) <> '' + AND LOWER(REGEXP_REPLACE(TRIM(c.case_type), '[^a-zA-Z0-9]+', '_', 'g')) = ct.code; + +UPDATE br_case +SET case_type_id = ( + SELECT id FROM case_type_catalog WHERE code = 'sonstiges' +) +WHERE case_type_id IS NULL; + +ALTER TABLE br_case + ALTER COLUMN case_type_id SET NOT NULL; + +ALTER TABLE br_case + ALTER COLUMN priority SET DEFAULT 'mittel'; + +CREATE INDEX IF NOT EXISTS idx_br_case_case_type_id ON br_case(case_type_id); diff --git a/database/migrations/011_meeting_number_generator.sql b/database/migrations/011_meeting_number_generator.sql new file mode 100644 index 0000000..8a1d27a --- /dev/null +++ b/database/migrations/011_meeting_number_generator.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS meeting_number_counter ( + period_ym CHAR(7) PRIMARY KEY, + last_value INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION next_meeting_number(p_date DATE DEFAULT CURRENT_DATE) +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + v_period TEXT; + v_seq INTEGER; +BEGIN + v_period := TO_CHAR(p_date, 'YYYY_MM'); + + LOOP + UPDATE meeting_number_counter + SET last_value = last_value + 1, + updated_at = NOW() + WHERE period_ym = v_period + RETURNING last_value INTO v_seq; + + IF FOUND THEN + EXIT; + END IF; + + BEGIN + INSERT INTO meeting_number_counter(period_ym, last_value) + VALUES (v_period, 1); + v_seq := 1; + EXIT; + EXCEPTION + WHEN unique_violation THEN + -- paralleler Insert, erneut versuchen + END; + END LOOP; + + RETURN 'SITZ-' || v_period || '_' || LPAD(v_seq::TEXT, 4, '0'); +END; +$$; + +UPDATE meeting +SET meeting_number = next_meeting_number(COALESCE(meeting_date, CURRENT_DATE)) +WHERE meeting_number IS NULL + OR TRIM(meeting_number) = ''; + +ALTER TABLE meeting + ALTER COLUMN meeting_number SET DEFAULT next_meeting_number(); + +ALTER TABLE meeting + ALTER COLUMN meeting_number SET NOT NULL; diff --git a/database/migrations/012_agenda_item_pool_and_case_number_fix.sql b/database/migrations/012_agenda_item_pool_and_case_number_fix.sql new file mode 100644 index 0000000..b1a24b7 --- /dev/null +++ b/database/migrations/012_agenda_item_pool_and_case_number_fix.sql @@ -0,0 +1,30 @@ +CREATE OR REPLACE FUNCTION next_case_number(p_now TIMESTAMPTZ DEFAULT NOW()) +RETURNS VARCHAR(50) +LANGUAGE plpgsql +AS $$ +DECLARE + v_period_key VARCHAR(7); + v_next_value INTEGER; +BEGIN + v_period_key := TO_CHAR(p_now, 'YYYY_MM'); + + INSERT INTO case_number_counter (period_key, last_value, updated_at) + VALUES (v_period_key, 1, NOW()) + ON CONFLICT (period_key) + DO UPDATE SET + last_value = case_number_counter.last_value + 1, + updated_at = NOW() + RETURNING case_number_counter.last_value INTO v_next_value; + + RETURN v_period_key || '_' || LPAD(v_next_value::TEXT, 4, '0'); +END; +$$; + +ALTER TABLE meeting_agenda_item + ALTER COLUMN meeting_id DROP NOT NULL; + +ALTER TABLE meeting_agenda_item + ALTER COLUMN agenda_number DROP NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_meeting_id ON meeting_agenda_item(meeting_id); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_unassigned ON meeting_agenda_item(id) WHERE meeting_id IS NULL; diff --git a/database/migrations/013_agenda_numbers_case_links_and_decisions.sql b/database/migrations/013_agenda_numbers_case_links_and_decisions.sql new file mode 100644 index 0000000..ca23e00 --- /dev/null +++ b/database/migrations/013_agenda_numbers_case_links_and_decisions.sql @@ -0,0 +1,62 @@ +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS case_id BIGINT REFERENCES br_case(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_case_id ON meeting_agenda_item(case_id); + +CREATE TABLE IF NOT EXISTS meeting_agenda_counter ( + meeting_id BIGINT PRIMARY KEY REFERENCES meeting(id) ON DELETE CASCADE, + last_value INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION next_agenda_number(p_meeting_id BIGINT) +RETURNS TEXT +LANGUAGE plpgsql +AS $$ +DECLARE + v_seq INTEGER; +BEGIN + IF p_meeting_id IS NULL THEN + RAISE EXCEPTION 'meeting_id is required for next_agenda_number'; + END IF; + + LOOP + UPDATE meeting_agenda_counter + SET last_value = last_value + 1, + updated_at = NOW() + WHERE meeting_id = p_meeting_id + RETURNING last_value INTO v_seq; + + IF FOUND THEN + EXIT; + END IF; + + BEGIN + INSERT INTO meeting_agenda_counter(meeting_id, last_value) + VALUES (p_meeting_id, 1); + v_seq := 1; + EXIT; + EXCEPTION + WHEN unique_violation THEN + NULL; + END; + END LOOP; + + RETURN v_seq::TEXT; +END; +$$; + +INSERT INTO meeting_agenda_item(meeting_id, agenda_number, title, description, duration_minutes, case_id) +SELECT NULL, + NULL, + c.case_number || ' – ' || c.title, + COALESCE(c.summary, 'Automatisch aus Vorgang ' || c.case_number || ' erzeugt'), + NULL, + c.id + FROM br_case c + WHERE c.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM meeting_agenda_item ai + WHERE ai.case_id = c.id + ); diff --git a/database/migrations/014_decision_number_generator.sql b/database/migrations/014_decision_number_generator.sql new file mode 100644 index 0000000..2ea34b7 --- /dev/null +++ b/database/migrations/014_decision_number_generator.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS decision_number_counter ( + period_key VARCHAR(7) PRIMARY KEY, + last_value INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION next_decision_number(p_now TIMESTAMPTZ DEFAULT NOW()) +RETURNS VARCHAR(50) +LANGUAGE plpgsql +AS $$ +DECLARE + v_period_key VARCHAR(7); + v_next_value INTEGER; +BEGIN + v_period_key := TO_CHAR(p_now, 'YYYY_MM'); + + INSERT INTO decision_number_counter (period_key, last_value, updated_at) + VALUES (v_period_key, 1, NOW()) + ON CONFLICT (period_key) + DO UPDATE SET + last_value = decision_number_counter.last_value + 1, + updated_at = NOW() + RETURNING decision_number_counter.last_value INTO v_next_value; + + RETURN 'BES-' || v_period_key || '_' || LPAD(v_next_value::TEXT, 4, '0'); +END; +$$; + +UPDATE decision +SET decision_number = next_decision_number(created_at) +WHERE decision_number IS NULL + OR TRIM(decision_number) = ''; + +ALTER TABLE decision + ALTER COLUMN decision_number SET DEFAULT next_decision_number(); + +ALTER TABLE decision + ALTER COLUMN decision_number SET NOT NULL; diff --git a/database/migrations/015_document_item_deleted_at_backfill.sql b/database/migrations/015_document_item_deleted_at_backfill.sql new file mode 100644 index 0000000..3f9e5fa --- /dev/null +++ b/database/migrations/015_document_item_deleted_at_backfill.sql @@ -0,0 +1,5 @@ +ALTER TABLE IF EXISTS document_item + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_document_item_deleted_at + ON document_item (deleted_at); diff --git a/database/migrations/016_meeting_protocol_template.sql b/database/migrations/016_meeting_protocol_template.sql new file mode 100644 index 0000000..fc3ba18 --- /dev/null +++ b/database/migrations/016_meeting_protocol_template.sql @@ -0,0 +1,56 @@ +DO $body$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM template_item + WHERE category = 'meeting_protocol' + ) THEN + INSERT INTO template_item( + title, + category, + description, + content, + version_label, + is_active + ) VALUES ( + 'Sitzungsprotokoll Standard', + 'meeting_protocol', + 'Wird beim Beenden einer Sitzung zur automatischen Protokollerzeugung verwendet. Platzhalter wie {{meeting_number}} oder {{agenda_sections}} können frei umgestellt werden.', + $template$# Betriebsrat {{committee_name}} +# Protokoll der BR-Sitzung am {{meeting_date}} +VERTRAULICH + +{{meeting_title_block}} + +## Organisatorisches und Feststellungen + +Sitzungsnummer: {{meeting_number}} +Sitzungsart: {{meeting_type}} +Sitzungsbeginn: {{start_time}} +Sitzungsort: {{location}} +Sitzungsleitung: {{chair_name}} +Schriftführung: {{secretary_name}} +{{quorum_statement}} +{{attendees_block}} + +## Protokollnotizen +{{protocol_notes}} + +## Tagesordnung und Beratung +{{agenda_sections}} + +## Sitzungsende +Schließung der Sitzung um {{end_time}} Uhr. + +{{signature_place}} +______________________________ +{{chair_name}} + +______________________________ +{{secretary_name}}$template$, + 'v1', + TRUE + ); + END IF; +END +$body$; diff --git a/database/migrations/017_document_versioning.sql b/database/migrations/017_document_versioning.sql new file mode 100644 index 0000000..23ec318 --- /dev/null +++ b/database/migrations/017_document_versioning.sql @@ -0,0 +1,55 @@ +ALTER TABLE IF EXISTS document_item + ADD COLUMN IF NOT EXISTS version_number INTEGER; + +UPDATE document_item + SET version_number = 1 + WHERE version_number IS NULL; + +ALTER TABLE IF EXISTS document_item + ALTER COLUMN version_number SET DEFAULT 1; + +ALTER TABLE IF EXISTS document_item + ALTER COLUMN version_number SET NOT NULL; + +ALTER TABLE IF EXISTS document_item + ADD COLUMN IF NOT EXISTS version_group_id BIGINT; + +UPDATE document_item + SET version_group_id = id + WHERE version_group_id IS NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_document_item_version_group' + ) THEN + ALTER TABLE document_item + ADD CONSTRAINT fk_document_item_version_group + FOREIGN KEY (version_group_id) + REFERENCES document_item(id) + ON DELETE SET NULL; + END IF; +END +$$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'document_item_document_number_key' + AND conrelid = 'document_item'::regclass + ) THEN + ALTER TABLE document_item + DROP CONSTRAINT document_item_document_number_key; + END IF; +END +$$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_document_item_document_number_version_unique + ON document_item (document_number, version_number); + +CREATE INDEX IF NOT EXISTS idx_document_item_version_group_id + ON document_item (version_group_id); diff --git a/database/migrations/018_meeting_decision_report_template.sql b/database/migrations/018_meeting_decision_report_template.sql new file mode 100644 index 0000000..5c5e51a --- /dev/null +++ b/database/migrations/018_meeting_decision_report_template.sql @@ -0,0 +1,46 @@ +DO $body$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM template_item + WHERE category = 'meeting_decision_report' + ) THEN + INSERT INTO template_item( + title, + category, + description, + content, + version_label, + is_active + ) VALUES ( + 'Beschlussbericht Standard', + 'meeting_decision_report', + 'Wird beim Beenden einer Sitzung zur automatischen Berichtserzeugung für alle behandelten Beschlüsse verwendet. Platzhalter wie {{meeting_number}} oder {{decision_sections}} können frei angepasst werden.', + $template$# Beschlussbericht zur BR-Sitzung am {{meeting_date}} +VERTRAULICH + +{{meeting_title_block}} + +## Sitzungsdaten +Sitzungsnummer: {{meeting_number}} +Sitzungsart: {{meeting_type}} +Sitzungsdatum: {{meeting_date}} +Sitzungsort: {{location}} +Sitzungsleitung: {{chair_name}} +Schriftführung: {{secretary_name}} + +## Behandelte Beschlüsse ab TOP 1 +{{decision_sections}} + +{{signature_place}} +______________________________ +{{chair_name}} + +______________________________ +{{secretary_name}}$template$, + 'v1', + TRUE + ); + END IF; +END +$body$; diff --git a/database/migrations/019_committee_member.sql b/database/migrations/019_committee_member.sql new file mode 100644 index 0000000..0f76be0 --- /dev/null +++ b/database/migrations/019_committee_member.sql @@ -0,0 +1,23 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS committee_member ( + committee_id BIGINT NOT NULL REFERENCES committee(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by BIGINT NULL REFERENCES app_user(id) ON DELETE SET NULL, + PRIMARY KEY (committee_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_committee_member_user_id + ON committee_member (user_id); + +CREATE INDEX IF NOT EXISTS idx_committee_member_committee_id + ON committee_member (committee_id); + +INSERT INTO committee_member (committee_id, user_id, created_at, created_by) +SELECT u.committee_id, u.id, NOW(), NULL + FROM app_user u + WHERE u.committee_id IS NOT NULL +ON CONFLICT (committee_id, user_id) DO NOTHING; + +COMMIT; diff --git a/database/migrations/020_task_id.sql b/database/migrations/020_task_id.sql new file mode 100644 index 0000000..d3c9984 --- /dev/null +++ b/database/migrations/020_task_id.sql @@ -0,0 +1,6 @@ +ALTER TABLE task + ADD COLUMN IF NOT EXISTS committee_id BIGINT REFERENCES committee(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_task_committee_id ON task(committee_id); +CREATE INDEX IF NOT EXISTS idx_task_agenda_item_id ON task(agenda_item_id); diff --git a/database/migrations/021_meeting_quorum.sql b/database/migrations/021_meeting_quorum.sql new file mode 100644 index 0000000..4f45344 --- /dev/null +++ b/database/migrations/021_meeting_quorum.sql @@ -0,0 +1,16 @@ +ALTER TABLE meeting + ADD COLUMN IF NOT EXISTS is_quorate BOOLEAN, + ADD COLUMN IF NOT EXISTS quorum_recorded_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS quorum_recorded_by BIGINT REFERENCES app_user(id); + +UPDATE template_item + SET content = REPLACE( + content, + 'Schriftführung: {{secretary_name}}', + 'Schriftführung: {{secretary_name}} +{{quorum_statement}}' + ), + version_label = CASE WHEN version_label = 'v1' THEN 'v2' ELSE version_label END, + updated_at = NOW() + WHERE category = 'meeting_protocol' + AND content NOT LIKE '%{{quorum_statement}}%'; diff --git a/database/migrations/022_permissions_matrix.sql b/database/migrations/022_permissions_matrix.sql new file mode 100644 index 0000000..68eac7f --- /dev/null +++ b/database/migrations/022_permissions_matrix.sql @@ -0,0 +1,586 @@ +CREATE TABLE IF NOT EXISTS app_role_profile ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(80) NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL, + description TEXT, + system_role role_type UNIQUE, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_permission ( + permission_key VARCHAR(120) PRIMARY KEY, + label VARCHAR(160) NOT NULL, + category VARCHAR(80) NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_page ( + page_key VARCHAR(80) PRIMARY KEY, + path VARCHAR(120) NOT NULL UNIQUE, + label VARCHAR(120) NOT NULL, + nav_group VARCHAR(80) NOT NULL DEFAULT 'work', + sort_order INTEGER NOT NULL DEFAULT 100, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_role_permission ( + role_profile_id BIGINT NOT NULL REFERENCES app_role_profile(id) ON DELETE CASCADE, + permission_key VARCHAR(120) NOT NULL REFERENCES app_permission(permission_key) ON DELETE CASCADE, + granted BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (role_profile_id, permission_key) +); + +CREATE TABLE IF NOT EXISTS app_role_page ( + role_profile_id BIGINT NOT NULL REFERENCES app_role_profile(id) ON DELETE CASCADE, + page_key VARCHAR(80) NOT NULL REFERENCES app_page(page_key) ON DELETE CASCADE, + can_view BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (role_profile_id, page_key) +); + +CREATE TABLE IF NOT EXISTS app_user_role_profile ( + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + role_profile_id BIGINT NOT NULL REFERENCES app_role_profile(id) ON DELETE CASCADE, + assigned_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, role_profile_id) +); + +CREATE TABLE IF NOT EXISTS app_team_role ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(80) NOT NULL UNIQUE, + name VARCHAR(120) NOT NULL, + description TEXT, + can_view_cases BOOLEAN NOT NULL DEFAULT FALSE, + can_write_cases BOOLEAN NOT NULL DEFAULT FALSE, + can_view_documents BOOLEAN NOT NULL DEFAULT FALSE, + can_write_documents BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_user_team_role ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + team_role_id BIGINT NOT NULL REFERENCES app_team_role(id) ON DELETE CASCADE, + committee_id BIGINT REFERENCES committee(id) ON DELETE CASCADE, + assigned_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_app_user_team_role_global + ON app_user_team_role(user_id, team_role_id) + WHERE committee_id IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_app_user_team_role_committee + ON app_user_team_role(user_id, team_role_id, committee_id) + WHERE committee_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS br_case_team_access ( + case_id BIGINT NOT NULL REFERENCES br_case(id) ON DELETE CASCADE, + team_role_id BIGINT NOT NULL REFERENCES app_team_role(id) ON DELETE CASCADE, + can_read BOOLEAN NOT NULL DEFAULT TRUE, + can_write BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (case_id, team_role_id) +); + +CREATE TABLE IF NOT EXISTS document_team_access ( + document_id BIGINT NOT NULL REFERENCES document_item(id) ON DELETE CASCADE, + team_role_id BIGINT NOT NULL REFERENCES app_team_role(id) ON DELETE CASCADE, + can_read BOOLEAN NOT NULL DEFAULT TRUE, + can_write BOOLEAN NOT NULL DEFAULT FALSE, + created_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (document_id, team_role_id) +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_app_role_profile_updated_at') THEN + CREATE TRIGGER trg_app_role_profile_updated_at BEFORE UPDATE ON app_role_profile FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_app_team_role_updated_at') THEN + CREATE TRIGGER trg_app_team_role_updated_at BEFORE UPDATE ON app_team_role FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +INSERT INTO app_role_profile(code, name, description, system_role, is_system) +VALUES + ('admin', 'admin', 'Systemverwaltung', 'admin', TRUE), + ('chair', 'chair', 'Sitzungen, TOPs, Beschlüsse, Vorlagen, Ausschussverwaltung', 'chair', TRUE), + ('secretary', 'secretary', 'Protokolle, Sitzungen, TOPs, Dokumente', 'secretary', TRUE), + ('member', 'member', 'Eigene Vorgänge, Aufgaben, Kommentare, Kalender', 'member', TRUE), + ('advisor', 'advisor', 'Lesender/beratender Zugriff auf freigegebene Inhalte', 'advisor', TRUE) +ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name, + description = EXCLUDED.description, + system_role = EXCLUDED.system_role, + is_system = TRUE, + is_active = TRUE, + updated_at = NOW(); + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES + ('dashboard', '/', 'Dashboard', 'dashboard', 0, 'Startseite und Überblick'), + ('cases', '/cases', 'Vorgänge', 'work', 10, 'Vorgangsverwaltung'), + ('deadlines', '/deadlines', 'Fristen', 'work', 20, 'Fristenverwaltung'), + ('meetings', '/meetings', 'Sitzungen', 'work', 30, 'Sitzungen planen, starten und protokollieren'), + ('agenda-items', '/agenda-items', 'TOP-Bestand', 'work', 40, 'Tagesordnungspunkte'), + ('decisions', '/decisions', 'Beschlüsse', 'work', 50, 'Beschlussverwaltung'), + ('reports', '/reports', 'Reports', 'work', 60, 'Berichte und Auswertungen'), + ('tasks', '/tasks', 'Aufgaben', 'work', 70, 'Aufgabenverwaltung'), + ('calendar', '/calendar', 'Kalender', 'work', 80, 'Kalender'), + ('documents', '/documents', 'Dokumente', 'work', 90, 'Dokumentenverwaltung'), + ('comments', '/comments', 'Kommentare', 'work', 100, 'Kommentare'), + ('notifications', '/notifications', 'Benachrichtigungen', 'work', 110, 'Hinweise'), + ('users', '/users', 'Benutzer', 'admin', 200, 'Benutzerverwaltung'), + ('committees', '/committees', 'Ausschüsse', 'admin', 210, 'Ausschussverwaltung'), + ('templates', '/templates', 'Vorlagen / Formular-Editor', 'admin', 220, 'Vorlagenverwaltung und HTML-/Formular-Editor'), + ('permissions', '/permissions', 'Rollen & Rechte', 'admin', 230, 'Rechtematrix und Teamrollen'), + ('security', '/security', 'Sicherheit', 'admin', 240, 'MFA und Sicherheit'), + ('audit', '/audit', 'Audit', 'admin', 250, 'Audit-Log') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('system.admin', 'Systemverwaltung', 'System', 'Voller Systemzugriff'), + ('system.users', 'Benutzer verwalten', 'System', 'Benutzer anlegen, ändern und deaktivieren'), + ('system.permissions', 'Rollen und Rechte verwalten', 'System', 'Rechtematrix, Custom-Rollen und Teamrollen verwalten'), + ('system.security', 'Sicherheit verwalten', 'System', 'Sicherheits- und MFA-Funktionen verwalten'), + ('system.audit', 'Audit lesen', 'System', 'Audit-Log anzeigen'), + ('cases.read.own', 'Eigene Vorgänge lesen', 'Vorgänge', 'Eigene/erstellte Vorgänge lesen'), + ('cases.read.all', 'Alle Vorgänge lesen', 'Vorgänge', 'Alle Vorgänge rollenbasiert lesen'), + ('cases.write', 'Vorgänge bearbeiten', 'Vorgänge', 'Vorgänge anlegen und bearbeiten'), + ('cases.write.all', 'Alle Vorgänge bearbeiten', 'Vorgänge', 'Nicht nur eigene Vorgänge bearbeiten'), + ('cases.delete', 'Vorgänge löschen', 'Vorgänge', 'Vorgänge löschen/archivieren'), + ('deadlines.write', 'Fristen bearbeiten', 'Fristen', 'Fristen anlegen und bearbeiten'), + ('meetings.write', 'Sitzungen bearbeiten', 'Sitzungen', 'Sitzungen planen, starten, stoppen und löschen'), + ('agenda.write', 'TOPs bearbeiten', 'Sitzungen', 'Tagesordnungspunkte bearbeiten'), + ('decisions.write', 'Beschlüsse bearbeiten', 'Beschlüsse', 'Beschlüsse anlegen und ändern'), + ('reports.read', 'Reports lesen', 'Reports', 'Berichte anzeigen'), + ('tasks.write', 'Aufgaben bearbeiten', 'Aufgaben', 'Aufgaben anlegen und ändern'), + ('calendar.write', 'Kalender bearbeiten', 'Kalender', 'Kalendereinträge anlegen und ändern'), + ('documents.read.released', 'Freigegebene Dokumente lesen', 'Dokumente', 'Freigegebene/saubere interne Dokumente lesen'), + ('documents.read.all', 'Alle Dokumente lesen', 'Dokumente', 'Alle Dokumente rollenbasiert lesen'), + ('documents.write', 'Dokumente bearbeiten', 'Dokumente', 'Dokumente hochladen, versionieren und ändern'), + ('documents.write.all', 'Alle Dokumente bearbeiten', 'Dokumente', 'Nicht nur eigene Dokumente bearbeiten'), + ('comments.write', 'Kommentare schreiben', 'Kommentare', 'Kommentare anlegen und bearbeiten'), + ('notifications.write', 'Benachrichtigungen bearbeiten', 'Benachrichtigungen', 'Benachrichtigungen anlegen/ändern'), + ('templates.write', 'Vorlagen bearbeiten', 'Vorlagen', 'Vorlagen anlegen und ändern'), + ('committees.write', 'Ausschüsse bearbeiten', 'Administration', 'Ausschüsse und Mitgliedschaften verwalten') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +CREATE OR REPLACE FUNCTION grant_role_pages(role_code TEXT, page_keys TEXT[]) RETURNS VOID AS $$ +DECLARE + role_id BIGINT; +BEGIN + SELECT id INTO role_id FROM app_role_profile WHERE code = role_code; + IF role_id IS NULL THEN RETURN; END IF; + + DELETE FROM app_role_page WHERE role_profile_id = role_id; + + INSERT INTO app_role_page(role_profile_id, page_key, can_view) + SELECT role_id, unnest(page_keys), TRUE + ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION grant_role_permissions(role_code TEXT, permission_keys TEXT[]) RETURNS VOID AS $$ +DECLARE + role_id BIGINT; +BEGIN + SELECT id INTO role_id FROM app_role_profile WHERE code = role_code; + IF role_id IS NULL THEN RETURN; END IF; + + DELETE FROM app_role_permission WHERE role_profile_id = role_id; + + INSERT INTO app_role_permission(role_profile_id, permission_key, granted) + SELECT role_id, unnest(permission_keys), TRUE + ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; +END; +$$ LANGUAGE plpgsql; + +SELECT grant_role_pages('admin', ARRAY[ + 'dashboard','cases','deadlines','meetings','agenda-items','decisions','reports','tasks','calendar','documents','comments','notifications', + 'users','committees','templates','permissions','security','audit' +]); +SELECT grant_role_pages('chair', ARRAY[ + 'dashboard','cases','deadlines','meetings','agenda-items','decisions','reports','tasks','calendar','documents','comments','notifications','committees','templates' +]); +SELECT grant_role_pages('secretary', ARRAY[ + 'dashboard','meetings','agenda-items','decisions','reports','tasks','calendar','documents','comments','notifications' +]); +SELECT grant_role_pages('member', ARRAY[ + 'dashboard','cases','deadlines','tasks','calendar','documents','comments','notifications' +]); +SELECT grant_role_pages('advisor', ARRAY[ + 'dashboard','calendar','documents','comments','notifications' +]); + +SELECT grant_role_permissions('admin', ARRAY( + SELECT permission_key FROM app_permission +)); +SELECT grant_role_permissions('chair', ARRAY[ + 'cases.read.all','cases.write','cases.write.all','cases.delete','deadlines.write','meetings.write','agenda.write','decisions.write', + 'reports.read','tasks.write','calendar.write','documents.read.all','documents.write','documents.write.all','comments.write','notifications.write','templates.write','committees.write' +]); +SELECT grant_role_permissions('secretary', ARRAY[ + 'meetings.write','agenda.write','decisions.write','reports.read','tasks.write','calendar.write','documents.write','comments.write','notifications.write' +]); +SELECT grant_role_permissions('member', ARRAY[ + 'cases.read.own','cases.write','deadlines.write','tasks.write','calendar.write','documents.write','comments.write','notifications.write' +]); +SELECT grant_role_permissions('advisor', ARRAY[ + 'documents.read.released' +]); + +DROP FUNCTION IF EXISTS grant_role_pages(TEXT, TEXT[]); +DROP FUNCTION IF EXISTS grant_role_permissions(TEXT, TEXT[]); + +CREATE OR REPLACE FUNCTION app_has_permission(permission TEXT, target_user_id BIGINT DEFAULT app_user_id(), target_role TEXT DEFAULT app_role()) RETURNS BOOLEAN AS $$ +DECLARE + allowed BOOLEAN; +BEGIN + IF target_role = 'admin' THEN + RETURN TRUE; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM app_role_profile rp + JOIN app_role_permission rperm ON rperm.role_profile_id = rp.id AND rperm.granted = TRUE + WHERE rperm.permission_key = permission + AND rp.is_active = TRUE + AND ( + rp.system_role::TEXT = target_role + OR EXISTS ( + SELECT 1 + FROM app_user_role_profile urp + WHERE urp.role_profile_id = rp.id + AND urp.user_id = target_user_id + ) + ) + ) INTO allowed; + + RETURN COALESCE(allowed, FALSE); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_can_view_page(page_key TEXT, target_user_id BIGINT DEFAULT app_user_id(), target_role TEXT DEFAULT app_role()) RETURNS BOOLEAN AS $$ +DECLARE + allowed BOOLEAN; +BEGIN + IF target_role = 'admin' THEN + RETURN TRUE; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM app_role_profile rp + JOIN app_role_page rpage ON rpage.role_profile_id = rp.id AND rpage.can_view = TRUE + WHERE rpage.page_key = app_can_view_page.page_key + AND rp.is_active = TRUE + AND ( + rp.system_role::TEXT = target_role + OR EXISTS ( + SELECT 1 + FROM app_user_role_profile urp + WHERE urp.role_profile_id = rp.id + AND urp.user_id = target_user_id + ) + ) + ) INTO allowed; + + RETURN COALESCE(allowed, FALSE); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_user_has_case_team_access(target_case_id BIGINT, write_access BOOLEAN DEFAULT FALSE, target_user_id BIGINT DEFAULT app_user_id()) RETURNS BOOLEAN AS $$ +DECLARE + allowed BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM br_case c + JOIN app_user_team_role utr ON utr.user_id = target_user_id AND (utr.committee_id IS NULL OR utr.committee_id = c.committee_id) + JOIN app_team_role tr ON tr.id = utr.team_role_id AND tr.is_active = TRUE + LEFT JOIN br_case_team_access cta ON cta.case_id = c.id AND cta.team_role_id = tr.id + WHERE c.id = target_case_id + AND ( + (write_access = FALSE AND (tr.can_view_cases = TRUE OR cta.can_read = TRUE OR cta.can_write = TRUE)) + OR + (write_access = TRUE AND (tr.can_write_cases = TRUE OR cta.can_write = TRUE)) + ) + ) INTO allowed; + + RETURN COALESCE(allowed, FALSE); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_user_has_document_team_access(target_document_id BIGINT, write_access BOOLEAN DEFAULT FALSE, target_user_id BIGINT DEFAULT app_user_id()) RETURNS BOOLEAN AS $$ +DECLARE + allowed BOOLEAN; +BEGIN + SELECT EXISTS ( + SELECT 1 + FROM document_item d + LEFT JOIN br_case c ON c.id = d.case_id + JOIN app_user_team_role utr ON utr.user_id = target_user_id AND (utr.committee_id IS NULL OR utr.committee_id = COALESCE(c.committee_id, 0) OR c.id IS NULL) + JOIN app_team_role tr ON tr.id = utr.team_role_id AND tr.is_active = TRUE + LEFT JOIN document_team_access dta ON dta.document_id = d.id AND dta.team_role_id = tr.id + WHERE d.id = target_document_id + AND ( + (write_access = FALSE AND (tr.can_view_documents = TRUE OR dta.can_read = TRUE OR dta.can_write = TRUE)) + OR + (write_access = TRUE AND (tr.can_write_documents = TRUE OR dta.can_write = TRUE)) + ) + ) INTO allowed; + + RETURN COALESCE(allowed, FALSE); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_can_read_case(target_case_id BIGINT) RETURNS BOOLEAN AS $$ +DECLARE + row br_case%ROWTYPE; +BEGIN + SELECT * INTO row FROM br_case WHERE id = target_case_id AND deleted_at IS NULL; + IF NOT FOUND THEN RETURN FALSE; END IF; + + IF app_role() IN ('admin', 'chair') OR app_has_permission('cases.read.all') THEN RETURN TRUE; END IF; + IF row.owner_user_id = app_user_id() OR row.created_by = app_user_id() THEN RETURN TRUE; END IF; + IF app_user_has_case_team_access(row.id, FALSE) THEN RETURN TRUE; END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_can_write_case(target_case_id BIGINT) RETURNS BOOLEAN AS $$ +DECLARE + row br_case%ROWTYPE; +BEGIN + SELECT * INTO row FROM br_case WHERE id = target_case_id AND deleted_at IS NULL; + IF NOT FOUND THEN RETURN FALSE; END IF; + + IF app_role() IN ('admin', 'chair') OR app_has_permission('cases.write.all') THEN RETURN TRUE; END IF; + IF (row.owner_user_id = app_user_id() OR row.created_by = app_user_id()) AND app_has_permission('cases.write') THEN RETURN TRUE; END IF; + IF app_user_has_case_team_access(row.id, TRUE) AND app_has_permission('cases.write') THEN RETURN TRUE; END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_can_read_document(target_document_id BIGINT) RETURNS BOOLEAN AS $$ +DECLARE + row document_item%ROWTYPE; +BEGIN + SELECT * INTO row FROM document_item WHERE id = target_document_id AND deleted_at IS NULL; + IF NOT FOUND THEN RETURN FALSE; END IF; + IF row.scan_status <> 'clean' THEN RETURN FALSE; END IF; + + IF app_role() IN ('admin', 'chair') OR app_has_permission('documents.read.all') THEN RETURN TRUE; END IF; + IF row.uploaded_by = app_user_id() THEN RETURN TRUE; END IF; + IF row.case_id IS NOT NULL AND app_can_read_case(row.case_id) THEN RETURN TRUE; END IF; + IF app_user_has_document_team_access(row.id, FALSE) THEN RETURN TRUE; END IF; + IF row.case_id IS NULL AND row.decision_id IS NULL AND row.confidentiality_level = 'intern' AND app_has_permission('documents.read.released') THEN RETURN TRUE; END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION app_can_write_document(target_document_id BIGINT) RETURNS BOOLEAN AS $$ +DECLARE + row document_item%ROWTYPE; +BEGIN + SELECT * INTO row FROM document_item WHERE id = target_document_id AND deleted_at IS NULL; + IF NOT FOUND THEN RETURN FALSE; END IF; + + IF app_role() IN ('admin', 'chair') OR app_has_permission('documents.write.all') THEN RETURN TRUE; END IF; + IF row.uploaded_by = app_user_id() AND app_has_permission('documents.write') THEN RETURN TRUE; END IF; + IF row.case_id IS NOT NULL AND app_can_write_case(row.case_id) AND app_has_permission('documents.write') THEN RETURN TRUE; END IF; + IF app_user_has_document_team_access(row.id, TRUE) AND app_has_permission('documents.write') THEN RETURN TRUE; END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION can_read_conf_level(level conf_level, owner BIGINT, committee BIGINT) RETURNS BOOLEAN AS $$ +BEGIN + IF app_role() IN ('admin', 'chair') OR app_has_permission('cases.read.all') THEN RETURN TRUE; END IF; + IF owner = app_user_id() THEN RETURN TRUE; END IF; + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE; + +DROP POLICY IF EXISTS br_case_select_policy ON br_case; +DROP POLICY IF EXISTS br_case_modify_policy ON br_case; +CREATE POLICY br_case_select_policy ON br_case FOR SELECT USING (app_can_read_case(id)); +CREATE POLICY br_case_modify_policy ON br_case FOR ALL USING (app_can_write_case(id)) WITH CHECK ( + app_role() IN ('admin','chair') + OR app_has_permission('cases.write.all') + OR (app_has_permission('cases.write') AND (owner_user_id = app_user_id() OR created_by = app_user_id())) +); + +DROP POLICY IF EXISTS deadline_select_policy ON deadline; +DROP POLICY IF EXISTS deadline_modify_policy ON deadline; +CREATE POLICY deadline_select_policy ON deadline FOR SELECT USING ( + deleted_at IS NULL AND EXISTS (SELECT 1 FROM br_case c WHERE c.id = deadline.case_id AND app_can_read_case(c.id)) +); +CREATE POLICY deadline_modify_policy ON deadline FOR ALL USING ( + deleted_at IS NULL AND EXISTS (SELECT 1 FROM br_case c WHERE c.id = deadline.case_id AND app_can_write_case(c.id)) +) WITH CHECK (app_has_permission('deadlines.write')); + +DROP POLICY IF EXISTS decision_select_policy ON decision; +DROP POLICY IF EXISTS decision_modify_policy ON decision; +CREATE POLICY decision_select_policy ON decision FOR SELECT USING ( + case_id IS NULL OR app_can_read_case(case_id) OR app_has_permission('decisions.write') +); +CREATE POLICY decision_modify_policy ON decision FOR ALL USING ( + app_has_permission('decisions.write') OR responsible_user_id = app_user_id() +) WITH CHECK (app_has_permission('decisions.write') OR responsible_user_id = app_user_id()); + +DROP POLICY IF EXISTS document_select_policy ON document_item; +DROP POLICY IF EXISTS document_modify_policy ON document_item; +CREATE POLICY document_select_policy ON document_item FOR SELECT USING (app_can_read_document(id)); +CREATE POLICY document_modify_policy ON document_item FOR ALL USING (app_can_write_document(id)) WITH CHECK ( + app_role() IN ('admin','chair') + OR app_has_permission('documents.write.all') + OR (app_has_permission('documents.write') AND uploaded_by = app_user_id()) + OR (case_id IS NOT NULL AND app_can_write_case(case_id) AND app_has_permission('documents.write')) +); + +DROP POLICY IF EXISTS meeting_select_policy ON meeting; +DROP POLICY IF EXISTS meeting_modify_policy ON meeting; +CREATE POLICY meeting_select_policy ON meeting FOR SELECT USING ( + app_can_view_page('meetings') OR app_has_permission('meetings.write') OR committee_id IS NULL OR committee_id = app_committee_id() +); +CREATE POLICY meeting_modify_policy ON meeting FOR ALL USING (app_has_permission('meetings.write')) WITH CHECK (app_has_permission('meetings.write')); + +DROP POLICY IF EXISTS template_select_policy ON template_item; +DROP POLICY IF EXISTS template_modify_policy ON template_item; +CREATE POLICY template_select_policy ON template_item FOR SELECT USING (app_can_view_page('templates')); +CREATE POLICY template_modify_policy ON template_item FOR ALL USING (app_has_permission('templates.write')) WITH CHECK (app_has_permission('templates.write')); + +DROP POLICY IF EXISTS committee_modify ON committee; +CREATE POLICY committee_modify ON committee FOR ALL USING (app_has_permission('committees.write')) WITH CHECK (app_has_permission('committees.write')); + +ALTER TABLE app_role_profile ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_permission ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_page ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_role_permission ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_role_page ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_user_role_profile ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_team_role ENABLE ROW LEVEL SECURITY; +ALTER TABLE app_user_team_role ENABLE ROW LEVEL SECURITY; +ALTER TABLE br_case_team_access ENABLE ROW LEVEL SECURITY; +ALTER TABLE document_team_access ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS app_role_profile_select ON app_role_profile; +DROP POLICY IF EXISTS app_role_profile_modify ON app_role_profile; +CREATE POLICY app_role_profile_select ON app_role_profile FOR SELECT USING ( + app_has_permission('system.permissions') + OR system_role::TEXT = app_role() + OR EXISTS ( + SELECT 1 FROM app_user_role_profile urp + WHERE urp.role_profile_id = app_role_profile.id + AND urp.user_id = app_user_id() + ) +); +CREATE POLICY app_role_profile_modify ON app_role_profile FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_permission_select ON app_permission; +DROP POLICY IF EXISTS app_permission_modify ON app_permission; +CREATE POLICY app_permission_select ON app_permission FOR SELECT USING (TRUE); +CREATE POLICY app_permission_modify ON app_permission FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_page_select ON app_page; +DROP POLICY IF EXISTS app_page_modify ON app_page; +CREATE POLICY app_page_select ON app_page FOR SELECT USING (TRUE); +CREATE POLICY app_page_modify ON app_page FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_role_permission_select ON app_role_permission; +DROP POLICY IF EXISTS app_role_permission_modify ON app_role_permission; +CREATE POLICY app_role_permission_select ON app_role_permission FOR SELECT USING ( + app_has_permission('system.permissions') + OR EXISTS ( + SELECT 1 FROM app_role_profile rp + WHERE rp.id = app_role_permission.role_profile_id + AND ( + rp.system_role::TEXT = app_role() + OR EXISTS ( + SELECT 1 FROM app_user_role_profile urp + WHERE urp.role_profile_id = rp.id + AND urp.user_id = app_user_id() + ) + ) + ) +); +CREATE POLICY app_role_permission_modify ON app_role_permission FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_role_page_select ON app_role_page; +DROP POLICY IF EXISTS app_role_page_modify ON app_role_page; +CREATE POLICY app_role_page_select ON app_role_page FOR SELECT USING ( + app_has_permission('system.permissions') + OR EXISTS ( + SELECT 1 FROM app_role_profile rp + WHERE rp.id = app_role_page.role_profile_id + AND ( + rp.system_role::TEXT = app_role() + OR EXISTS ( + SELECT 1 FROM app_user_role_profile urp + WHERE urp.role_profile_id = rp.id + AND urp.user_id = app_user_id() + ) + ) + ) +); +CREATE POLICY app_role_page_modify ON app_role_page FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_user_role_profile_select ON app_user_role_profile; +DROP POLICY IF EXISTS app_user_role_profile_modify ON app_user_role_profile; +CREATE POLICY app_user_role_profile_select ON app_user_role_profile FOR SELECT USING (app_has_permission('system.permissions') OR user_id = app_user_id()); +CREATE POLICY app_user_role_profile_modify ON app_user_role_profile FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_team_role_select ON app_team_role; +DROP POLICY IF EXISTS app_team_role_modify ON app_team_role; +CREATE POLICY app_team_role_select ON app_team_role FOR SELECT USING (app_has_permission('system.permissions') OR TRUE); +CREATE POLICY app_team_role_modify ON app_team_role FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS app_user_team_role_select ON app_user_team_role; +DROP POLICY IF EXISTS app_user_team_role_modify ON app_user_team_role; +CREATE POLICY app_user_team_role_select ON app_user_team_role FOR SELECT USING (app_has_permission('system.permissions') OR user_id = app_user_id()); +CREATE POLICY app_user_team_role_modify ON app_user_team_role FOR ALL USING (app_has_permission('system.permissions')) WITH CHECK (app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS br_case_team_access_select ON br_case_team_access; +DROP POLICY IF EXISTS br_case_team_access_modify ON br_case_team_access; +CREATE POLICY br_case_team_access_select ON br_case_team_access FOR SELECT USING (app_has_permission('system.permissions') OR app_can_write_case(case_id)); +CREATE POLICY br_case_team_access_modify ON br_case_team_access FOR ALL USING (app_can_write_case(case_id) OR app_has_permission('system.permissions')) WITH CHECK (app_can_write_case(case_id) OR app_has_permission('system.permissions')); + +DROP POLICY IF EXISTS document_team_access_select ON document_team_access; +DROP POLICY IF EXISTS document_team_access_modify ON document_team_access; +CREATE POLICY document_team_access_select ON document_team_access FOR SELECT USING (app_has_permission('system.permissions') OR app_can_write_document(document_id)); +CREATE POLICY document_team_access_modify ON document_team_access FOR ALL USING (app_can_write_document(document_id) OR app_has_permission('system.permissions')) WITH CHECK (app_can_write_document(document_id) OR app_has_permission('system.permissions')); + +GRANT SELECT, INSERT, UPDATE, DELETE ON app_role_profile, app_permission, app_page, app_role_permission, app_role_page, app_user_role_profile, app_team_role, app_user_team_role, br_case_team_access, document_team_access TO br_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO br_app; diff --git a/database/migrations/023_finance.sql b/database/migrations/023_finance.sql new file mode 100644 index 0000000..e155edf --- /dev/null +++ b/database/migrations/023_finance.sql @@ -0,0 +1,197 @@ +CREATE TABLE IF NOT EXISTS finance_project ( + id BIGSERIAL PRIMARY KEY, + project_code VARCHAR(50) UNIQUE, + name VARCHAR(180) NOT NULL, + description TEXT, + status VARCHAR(30) NOT NULL DEFAULT 'aktiv' CHECK (status IN ('aktiv','pausiert','abgeschlossen','archiviert')), + calculation_mode VARCHAR(30) NOT NULL DEFAULT 'sum_only' CHECK (calculation_mode IN ('sum_only','project_budget','annual_budget')), + committee_id BIGINT REFERENCES committee(id) ON DELETE SET NULL, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS finance_project_budget ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES finance_project(id) ON DELETE CASCADE, + budget_type VARCHAR(20) NOT NULL CHECK (budget_type IN ('project','annual')), + budget_year INTEGER, + amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (amount >= 0), + currency CHAR(3) NOT NULL DEFAULT 'EUR', + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT finance_project_budget_year_check CHECK ( + (budget_type = 'project' AND budget_year IS NULL) + OR + (budget_type = 'annual' AND budget_year IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_finance_project_budget_project + ON finance_project_budget(project_id) + WHERE budget_type = 'project'; + +CREATE UNIQUE INDEX IF NOT EXISTS ux_finance_project_budget_annual + ON finance_project_budget(project_id, budget_year) + WHERE budget_type = 'annual'; + +CREATE TABLE IF NOT EXISTS finance_expense ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES finance_project(id) ON DELETE CASCADE, + expense_date DATE NOT NULL DEFAULT CURRENT_DATE, + budget_year INTEGER NOT NULL DEFAULT EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER, + title VARCHAR(255) NOT NULL, + vendor VARCHAR(180), + cost_category VARCHAR(120), + net_amount NUMERIC(14,2) NOT NULL CHECK (net_amount >= 0), + tax_rate_percent NUMERIC(6,3) NOT NULL DEFAULT 19 CHECK (tax_rate_percent >= 0), + tax_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + gross_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (gross_amount >= 0), + currency CHAR(3) NOT NULL DEFAULT 'EUR', + payment_status VARCHAR(30) NOT NULL DEFAULT 'offen' CHECK (payment_status IN ('geplant','offen','bezahlt','storniert')), + notes TEXT, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_finance_expense_project ON finance_expense(project_id); +CREATE INDEX IF NOT EXISTS idx_finance_expense_budget_year ON finance_expense(budget_year); +CREATE INDEX IF NOT EXISTS idx_finance_expense_date ON finance_expense(expense_date); + +CREATE TABLE IF NOT EXISTS finance_expense_document ( + expense_id BIGINT NOT NULL REFERENCES finance_expense(id) ON DELETE CASCADE, + document_id BIGINT NOT NULL REFERENCES document_item(id) ON DELETE CASCADE, + created_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (expense_id, document_id) +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_project_updated_at') THEN + CREATE TRIGGER trg_finance_project_updated_at BEFORE UPDATE ON finance_project FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_project_budget_updated_at') THEN + CREATE TRIGGER trg_finance_project_budget_updated_at BEFORE UPDATE ON finance_project_budget FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_expense_updated_at') THEN + CREATE TRIGGER trg_finance_expense_updated_at BEFORE UPDATE ON finance_expense FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES ('finance', '/finance', 'Finanzen', 'work', 65, 'Kostenüberwachung, Budgets, Jahresbudgets und Belege') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('finance.read', 'Finanzen lesen', 'Finanzen', 'Finanzprojekte, Budgets, Ausgaben und Belege anzeigen'), + ('finance.write', 'Finanzen bearbeiten', 'Finanzen', 'Finanzprojekte, Budgets, Ausgaben und Belege anlegen und ändern'), + ('finance.delete', 'Finanzdaten löschen', 'Finanzen', 'Finanzprojekte und Ausgaben archivieren/löschen'), + ('finance.budget', 'Budgets verwalten', 'Finanzen', 'Projektbudgets und Jahresbudgets pflegen') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_page(role_profile_id, page_key, can_view) +SELECT rp.id, 'finance', TRUE + FROM app_role_profile rp + WHERE rp.code IN ('admin','chair') +ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT rp.id, perm.permission_key, TRUE + FROM app_role_profile rp + CROSS JOIN (VALUES ('finance.read'), ('finance.write'), ('finance.delete'), ('finance.budget')) AS perm(permission_key) + WHERE rp.code IN ('admin','chair') +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +ALTER TABLE finance_project ENABLE ROW LEVEL SECURITY; +ALTER TABLE finance_project_budget ENABLE ROW LEVEL SECURITY; +ALTER TABLE finance_expense ENABLE ROW LEVEL SECURITY; +ALTER TABLE finance_expense_document ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS finance_project_select ON finance_project; +DROP POLICY IF EXISTS finance_project_modify ON finance_project; +CREATE POLICY finance_project_select ON finance_project FOR SELECT USING ( + deleted_at IS NULL AND app_has_permission('finance.read') +); +CREATE POLICY finance_project_modify ON finance_project FOR ALL USING ( + app_has_permission('finance.write') +) WITH CHECK (app_has_permission('finance.write')); + +DROP POLICY IF EXISTS finance_project_budget_select ON finance_project_budget; +DROP POLICY IF EXISTS finance_project_budget_modify ON finance_project_budget; +CREATE POLICY finance_project_budget_select ON finance_project_budget FOR SELECT USING ( + app_has_permission('finance.read') +); +CREATE POLICY finance_project_budget_modify ON finance_project_budget FOR ALL USING ( + app_has_permission('finance.budget') OR app_has_permission('finance.write') +) WITH CHECK (app_has_permission('finance.budget') OR app_has_permission('finance.write')); + +DROP POLICY IF EXISTS finance_expense_select ON finance_expense; +DROP POLICY IF EXISTS finance_expense_modify ON finance_expense; +CREATE POLICY finance_expense_select ON finance_expense FOR SELECT USING ( + deleted_at IS NULL AND app_has_permission('finance.read') +); +CREATE POLICY finance_expense_modify ON finance_expense FOR ALL USING ( + app_has_permission('finance.write') +) WITH CHECK (app_has_permission('finance.write')); + +DROP POLICY IF EXISTS finance_expense_document_select ON finance_expense_document; +DROP POLICY IF EXISTS finance_expense_document_modify ON finance_expense_document; +CREATE POLICY finance_expense_document_select ON finance_expense_document FOR SELECT USING ( + app_has_permission('finance.read') +); +CREATE POLICY finance_expense_document_modify ON finance_expense_document FOR ALL USING ( + app_has_permission('finance.write') +) WITH CHECK (app_has_permission('finance.write')); + +DROP POLICY IF EXISTS document_select_policy ON document_item; +DROP POLICY IF EXISTS document_modify_policy ON document_item; +CREATE POLICY document_select_policy ON document_item FOR SELECT USING ( + app_can_read_document(id) + OR EXISTS ( + SELECT 1 + FROM finance_expense_document fed + JOIN finance_expense fe ON fe.id = fed.expense_id AND fe.deleted_at IS NULL + WHERE fed.document_id = document_item.id + AND app_has_permission('finance.read') + ) +); +CREATE POLICY document_modify_policy ON document_item FOR ALL USING ( + app_can_write_document(id) + OR (document_item.document_type = 'finance_receipt' AND document_item.uploaded_by = app_user_id() AND app_has_permission('finance.write')) + OR EXISTS ( + SELECT 1 + FROM finance_expense_document fed + JOIN finance_expense fe ON fe.id = fed.expense_id AND fe.deleted_at IS NULL + WHERE fed.document_id = document_item.id + AND app_has_permission('finance.write') + ) +) WITH CHECK ( + app_role() IN ('admin','chair') + OR app_has_permission('documents.write.all') + OR (app_has_permission('documents.write') AND uploaded_by = app_user_id()) + OR (case_id IS NOT NULL AND app_can_write_case(case_id) AND app_has_permission('documents.write')) + OR (document_item.document_type = 'finance_receipt' AND document_item.uploaded_by = app_user_id() AND app_has_permission('finance.write')) + OR app_has_permission('finance.write') +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON finance_project, finance_project_budget, finance_expense, finance_expense_document TO br_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO br_app; diff --git a/database/migrations/024_decision_voting.sql b/database/migrations/024_decision_voting.sql new file mode 100644 index 0000000..4ac76a6 --- /dev/null +++ b/database/migrations/024_decision_voting.sql @@ -0,0 +1,175 @@ +BEGIN; + +ALTER TABLE decision + ADD COLUMN IF NOT EXISTS voting_open BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS voting_started_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS voting_started_by BIGINT REFERENCES app_user(id), + ADD COLUMN IF NOT EXISTS voting_closed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS voting_closed_by BIGINT REFERENCES app_user(id); + +CREATE TABLE IF NOT EXISTS decision_vote ( + decision_id BIGINT NOT NULL REFERENCES decision(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + vote_value VARCHAR(20) NOT NULL CHECK (vote_value IN ('yes', 'no', 'abstain')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (decision_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_decision_vote_decision_id + ON decision_vote(decision_id); + +CREATE INDEX IF NOT EXISTS idx_decision_vote_user_id + ON decision_vote(user_id); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_decision_vote_updated_at') THEN + CREATE TRIGGER trg_decision_vote_updated_at + BEFORE UPDATE ON decision_vote + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +ALTER TABLE decision_vote ENABLE ROW LEVEL SECURITY; +ALTER TABLE decision_vote FORCE ROW LEVEL SECURITY; + +-- Benutzer desselben Gremiums dürfen sich gegenseitig für Abstimmungslisten sehen. +-- Admin/chair behalten den bisherigen erweiterten Zugriff; jeder Benutzer sieht sich weiterhin selbst. +DROP POLICY IF EXISTS user_select ON app_user; +CREATE POLICY user_select ON app_user FOR SELECT USING ( + app_role() IN ('admin','chair') + OR id = app_user_id() + OR EXISTS ( + SELECT 1 + FROM committee_member mine + JOIN committee_member other_member ON other_member.committee_id = mine.committee_id + WHERE mine.user_id = app_user_id() + AND other_member.user_id = app_user.id + ) +); + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('decisions.vote.manage', 'Abstimmungen verwalten', 'Beschlüsse', 'Beschluss-Abstimmungen öffnen, schließen und auswerten') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT id, 'decisions.vote.manage', TRUE + FROM app_role_profile + WHERE code IN ('admin', 'chair', 'secretary') +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +DROP POLICY IF EXISTS decision_modify_policy ON decision; +CREATE POLICY decision_modify_policy ON decision FOR ALL USING ( + app_has_permission('decisions.write') + OR app_has_permission('decisions.vote.manage') + OR responsible_user_id = app_user_id() + OR EXISTS ( + SELECT 1 + FROM meeting m + JOIN committee_member cm ON cm.committee_id = m.committee_id + WHERE m.id = decision.meeting_id + AND cm.user_id = app_user_id() + ) +) WITH CHECK ( + app_has_permission('decisions.write') + OR app_has_permission('decisions.vote.manage') + OR responsible_user_id = app_user_id() + OR EXISTS ( + SELECT 1 + FROM meeting m + JOIN committee_member cm ON cm.committee_id = m.committee_id + WHERE m.id = decision.meeting_id + AND cm.user_id = app_user_id() + ) +); + +DROP POLICY IF EXISTS decision_vote_select_policy ON decision_vote; +DROP POLICY IF EXISTS decision_vote_insert_policy ON decision_vote; +DROP POLICY IF EXISTS decision_vote_update_policy ON decision_vote; +DROP POLICY IF EXISTS decision_vote_delete_policy ON decision_vote; + +CREATE POLICY decision_vote_select_policy ON decision_vote FOR SELECT USING ( + user_id = app_user_id() + OR app_has_permission('decisions.vote.manage') + OR EXISTS ( + SELECT 1 + FROM decision d + JOIN meeting m ON m.id = d.meeting_id + JOIN committee_member cm ON cm.committee_id = m.committee_id + WHERE d.id = decision_vote.decision_id + AND cm.user_id = app_user_id() + ) +); + +CREATE POLICY decision_vote_insert_policy ON decision_vote FOR INSERT WITH CHECK ( + user_id = app_user_id() + AND app_role() <> 'advisor' + AND EXISTS ( + SELECT 1 + FROM decision d + JOIN meeting m ON m.id = d.meeting_id + JOIN committee_member cm ON cm.committee_id = m.committee_id + WHERE d.id = decision_vote.decision_id + AND d.voting_open = TRUE + AND m.status = 'gestartet' + AND cm.user_id = app_user_id() + ) +); + +CREATE POLICY decision_vote_update_policy ON decision_vote FOR UPDATE USING ( + user_id = app_user_id() + AND app_role() <> 'advisor' + AND EXISTS ( + SELECT 1 + FROM decision d + JOIN meeting m ON m.id = d.meeting_id + JOIN committee_member cm ON cm.committee_id = m.committee_id + WHERE d.id = decision_vote.decision_id + AND d.voting_open = TRUE + AND m.status = 'gestartet' + AND cm.user_id = app_user_id() + ) +) WITH CHECK ( + user_id = app_user_id() + AND app_role() <> 'advisor' +); + +CREATE POLICY decision_vote_delete_policy ON decision_vote FOR DELETE USING ( + app_has_permission('decisions.vote.manage') +); + + +CREATE OR REPLACE FUNCTION sync_decision_vote_counts(p_decision_id BIGINT) +RETURNS TABLE(votes_yes INTEGER, votes_no INTEGER, votes_abstain INTEGER) AS $$ +DECLARE + yes_count INTEGER; + no_count INTEGER; + abstain_count INTEGER; +BEGIN + SELECT COUNT(*) FILTER (WHERE vote_value = 'yes')::int, + COUNT(*) FILTER (WHERE vote_value = 'no')::int, + COUNT(*) FILTER (WHERE vote_value = 'abstain')::int + INTO yes_count, no_count, abstain_count + FROM decision_vote + WHERE decision_id = p_decision_id; + + UPDATE decision + SET votes_yes = COALESCE(yes_count, 0), + votes_no = COALESCE(no_count, 0), + votes_abstain = COALESCE(abstain_count, 0), + updated_at = NOW() + WHERE id = p_decision_id; + + RETURN QUERY SELECT COALESCE(yes_count, 0), COALESCE(no_count, 0), COALESCE(abstain_count, 0); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +GRANT EXECUTE ON FUNCTION sync_decision_vote_counts(BIGINT) TO PUBLIC; + +COMMIT; diff --git a/database/migrations/025_kummerkasten.sql b/database/migrations/025_kummerkasten.sql new file mode 100644 index 0000000..3c92ead --- /dev/null +++ b/database/migrations/025_kummerkasten.sql @@ -0,0 +1,82 @@ +CREATE TABLE IF NOT EXISTS kummerkasten_setting ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled BOOLEAN NOT NULL DEFAULT FALSE, + access_password_hash TEXT, + intro_text TEXT NOT NULL DEFAULT 'Reichen Sie hier vertraulich einen Vorschlag oder ein Anliegen für die Tagesordnung ein.', + target_committee_id BIGINT REFERENCES committee(id) ON DELETE SET NULL, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO kummerkasten_setting(id) +VALUES (1) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS kummerkasten_submission ( + id BIGSERIAL PRIMARY KEY, + agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + submitter_name VARCHAR(255), + submitter_contact VARCHAR(255), + target_committee_id BIGINT REFERENCES committee(id) ON DELETE SET NULL, + status VARCHAR(40) NOT NULL DEFAULT 'converted_to_agenda', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_kummerkasten_submission_created_at ON kummerkasten_submission(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_kummerkasten_submission_agenda_item ON kummerkasten_submission(agenda_item_id); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_kummerkasten_setting_updated_at') THEN + CREATE TRIGGER trg_kummerkasten_setting_updated_at + BEFORE UPDATE ON kummerkasten_setting + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES + ('kummerkasten-admin', '/kummerkasten-admin', 'Kummerkasten', 'admin', 245, 'Öffentliche Kummerkasten-Seite konfigurieren') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('kummerkasten.configure', 'Kummerkasten konfigurieren', 'Kummerkasten', 'Öffentliche Kummerkasten-Seite einschalten und Kennwort verwalten') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_page(role_profile_id, page_key, can_view) +SELECT id, 'kummerkasten-admin', TRUE + FROM app_role_profile + WHERE code = 'admin' +ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT id, 'kummerkasten.configure', TRUE + FROM app_role_profile + WHERE code = 'admin' +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +ALTER TABLE kummerkasten_setting ENABLE ROW LEVEL SECURITY; +ALTER TABLE kummerkasten_submission ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS kummerkasten_setting_select ON kummerkasten_setting; +DROP POLICY IF EXISTS kummerkasten_setting_modify ON kummerkasten_setting; +CREATE POLICY kummerkasten_setting_select ON kummerkasten_setting FOR SELECT USING (app_has_permission('kummerkasten.configure')); +CREATE POLICY kummerkasten_setting_modify ON kummerkasten_setting FOR ALL USING (app_has_permission('kummerkasten.configure')) WITH CHECK (app_has_permission('kummerkasten.configure')); + +DROP POLICY IF EXISTS kummerkasten_submission_select ON kummerkasten_submission; +DROP POLICY IF EXISTS kummerkasten_submission_modify ON kummerkasten_submission; +CREATE POLICY kummerkasten_submission_select ON kummerkasten_submission FOR SELECT USING (app_has_permission('kummerkasten.configure')); +CREATE POLICY kummerkasten_submission_modify ON kummerkasten_submission FOR ALL USING (app_has_permission('kummerkasten.configure')) WITH CHECK (app_has_permission('kummerkasten.configure')); diff --git a/database/migrations/026_remove_kummerkasten_default_duration.sql b/database/migrations/026_remove_kummerkasten_default_duration.sql new file mode 100644 index 0000000..a53b466 --- /dev/null +++ b/database/migrations/026_remove_kummerkasten_default_duration.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS kummerkasten_setting + DROP COLUMN IF EXISTS default_duration_minutes; diff --git a/database/migrations/027_remove_kummerkasten_duration_minutes.sql b/database/migrations/027_remove_kummerkasten_duration_minutes.sql new file mode 100644 index 0000000..fa21541 --- /dev/null +++ b/database/migrations/027_remove_kummerkasten_duration_minutes.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS kummerkasten_submission + DROP COLUMN IF EXISTS duration_minutes; diff --git a/database/migrations/028_finance_invoice_lines_tax_codes.sql b/database/migrations/028_finance_invoice_lines_tax_codes.sql new file mode 100644 index 0000000..6220e55 --- /dev/null +++ b/database/migrations/028_finance_invoice_lines_tax_codes.sql @@ -0,0 +1,180 @@ +CREATE TABLE IF NOT EXISTS finance_tax_code ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(160) NOT NULL UNIQUE, + label VARCHAR(220) NOT NULL, + tax_rate_percent NUMERIC(6,3) NOT NULL DEFAULT 0 CHECK (tax_rate_percent >= 0 AND tax_rate_percent <= 100), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 1000, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS finance_expense_line ( + id BIGSERIAL PRIMARY KEY, + expense_id BIGINT NOT NULL REFERENCES finance_expense(id) ON DELETE CASCADE, + line_no INTEGER NOT NULL DEFAULT 1, + description VARCHAR(255) NOT NULL, + tax_code_id BIGINT REFERENCES finance_tax_code(id) ON DELETE SET NULL, + tax_code_key VARCHAR(160), + net_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (net_amount >= 0), + tax_rate_percent NUMERIC(6,3) NOT NULL DEFAULT 0 CHECK (tax_rate_percent >= 0 AND tax_rate_percent <= 100), + tax_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (tax_amount >= 0), + gross_amount NUMERIC(14,2) NOT NULL DEFAULT 0 CHECK (gross_amount >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT finance_expense_line_line_no_unique UNIQUE (expense_id, line_no) +); + +CREATE INDEX IF NOT EXISTS idx_finance_expense_line_expense ON finance_expense_line(expense_id); +CREATE INDEX IF NOT EXISTS idx_finance_expense_line_tax_code ON finance_expense_line(tax_code_id); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_tax_code_updated_at') THEN + CREATE TRIGGER trg_finance_tax_code_updated_at BEFORE UPDATE ON finance_tax_code FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_finance_expense_line_updated_at') THEN + CREATE TRIGGER trg_finance_expense_line_updated_at BEFORE UPDATE ON finance_expense_line FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +INSERT INTO finance_tax_code(code, label, tax_rate_percent, is_active, sort_order) +VALUES + ('Kfz_Betriebskosten_19%', 'Kfz Betriebskosten 19%', 19.000, TRUE, 10), + ('Mietwagen_Betanken_19%', 'Mietwagen Betanken 19%', 19.000, TRUE, 11), + ('Mietwagen_Ausland', 'Mietwagen Ausland', 0.000, TRUE, 12), + ('Bahn_ueber_50_km_19%', 'Bahn ueber 50 km 19%', 19.000, TRUE, 13), + ('Bahn_ueber_50_km', 'Bahn ueber 50 km', 0.000, TRUE, 14), + ('Bahn_unter_50_km_7%', 'Bahn unter 50 km 7%', 7.000, TRUE, 15), + ('Bahn_unter_50_km', 'Bahn unter 50 km', 0.000, TRUE, 16), + ('Bewirtungskosten_19%', 'Bewirtungskosten 19%', 19.000, TRUE, 17), + ('Bewirtungskosten_7%', 'Bewirtungskosten 7%', 7.000, TRUE, 18), + ('Bewirtungskosten', 'Bewirtungskosten', 0.000, TRUE, 19), + ('Bewirtungskosten_30_19%', 'Bewirtungskosten 30 19%', 19.000, TRUE, 20), + ('Bewirtungskosten_30_7%', 'Bewirtungskosten 30 7%', 7.000, TRUE, 21), + ('Bewirtungskosten_30', 'Bewirtungskosten 30', 0.000, TRUE, 22), + ('Buerobedarf_19%', 'Buerobedarf 19%', 19.000, TRUE, 23), + ('Buerobedarf', 'Buerobedarf', 0.000, TRUE, 24), + ('Bus_ueber_50_km_19%', 'Bus ueber 50 km 19%', 19.000, TRUE, 25), + ('Bus_ueber_50_km', 'Bus ueber 50 km', 0.000, TRUE, 26), + ('Bus_unter_50_km_7%', 'Bus unter 50 km 7%', 7.000, TRUE, 27), + ('Bus_unter_50_km', 'Bus unter 50 km', 0.000, TRUE, 28), + ('Flug_19%', 'Flug 19%', 19.000, TRUE, 29), + ('Flug', 'Flug', 0.000, TRUE, 30), + ('Fruehstueck_(Hotel_Pension)_19%', 'Fruehstueck (Hotel Pension) 19%', 19.000, TRUE, 31), + ('Fruehstueck_(Hotel_Pension)', 'Fruehstueck (Hotel Pension)', 0.000, TRUE, 32), + ('Mittagessen_(Hotel_Pension)_19%', 'Mittagessen (Hotel Pension) 19%', 19.000, TRUE, 33), + ('Mittagessen_(Hotel_Pension)', 'Mittagessen (Hotel Pension)', 0.000, TRUE, 34), + ('Abendessen_(Hotel_Pension)_19%', 'Abendessen (Hotel Pension) 19%', 19.000, TRUE, 35), + ('Abendessen_(Hotel_Pension)', 'Abendessen (Hotel Pension)', 0.000, TRUE, 36), + ('Handy_19%', 'Handy 19%', 19.000, TRUE, 37), + ('Handy', 'Handy', 0.000, TRUE, 38), + ('Hotel_Pension_7%', 'Hotel Pension 7%', 7.000, TRUE, 39), + ('Hotel_Pension', 'Hotel Pension', 0.000, TRUE, 40), + ('Internet_19%', 'Internet 19%', 19.000, TRUE, 41), + ('Internet', 'Internet', 0.000, TRUE, 42), + ('Lebensmittel_19%%', 'Lebensmittel 19%%', 19.000, TRUE, 43), + ('Lebensmittel_7%', 'Lebensmittel 7%', 7.000, TRUE, 44), + ('Lebensmittel', 'Lebensmittel', 0.000, TRUE, 45), + ('Mietwagen_19%', 'Mietwagen 19%', 19.000, TRUE, 46), + ('Mietwagen', 'Mietwagen', 0.000, TRUE, 47), + ('Mietwohnung_Appartement_19%', 'Mietwohnung Appartement 19%', 19.000, TRUE, 48), + ('Mietwohnung_Appartement', 'Mietwohnung Appartement', 0.000, TRUE, 49), + ('Parkgebuehren_19%', 'Parkgebuehren 19%', 19.000, TRUE, 50), + ('Parkgebuehren', 'Parkgebuehren', 0.000, TRUE, 51), + ('Porto_19%', 'Porto 19%', 19.000, TRUE, 52), + ('Porto', 'Porto', 0.000, TRUE, 53), + ('Reservierung_19%', 'Reservierung 19%', 19.000, TRUE, 54), + ('Reservierung', 'Reservierung', 0.000, TRUE, 55), + ('Sonstiges_(19_%_MwSt.)', 'Sonstiges (19 % MwSt.)', 19.000, TRUE, 56), + ('Sonstiges_(7_%_MwSt.)', 'Sonstiges (7 % MwSt.)', 7.000, TRUE, 57), + ('Sonstiges', 'Sonstiges', 0.000, TRUE, 58), + ('Tankbelege_19%', 'Tankbelege 19%', 19.000, TRUE, 59), + ('Tankbelege', 'Tankbelege', 0.000, TRUE, 60), + ('Taxi_ueber_50_km_19%', 'Taxi ueber 50 km 19%', 19.000, TRUE, 61), + ('Taxi_ueber_50_km', 'Taxi ueber 50 km', 0.000, TRUE, 62), + ('Taxi_unter_50_km_7%', 'Taxi unter 50 km 7%', 7.000, TRUE, 63), + ('Taxi_unter_50_km', 'Taxi unter 50 km', 0.000, TRUE, 64), + ('Telefonkosten_19%', 'Telefonkosten 19%', 19.000, TRUE, 65), + ('Telefonkosten', 'Telefonkosten', 0.000, TRUE, 66), + ('Abendessen_(Hotel/Pension)_19%', 'Abendessen (Hotel/Pension) 19%', 19.000, TRUE, 67) +ON CONFLICT (code) DO UPDATE + SET label = EXCLUDED.label, + tax_rate_percent = EXCLUDED.tax_rate_percent, + sort_order = EXCLUDED.sort_order, + updated_at = NOW(); + + + +INSERT INTO finance_expense_line( + expense_id, line_no, description, tax_code_key, + net_amount, tax_rate_percent, tax_amount, gross_amount +) +SELECT e.id, + 1, + COALESCE(NULLIF(e.cost_category, ''), e.title, 'Rechnungsposition'), + e.cost_category, + e.net_amount, + e.tax_rate_percent, + e.tax_amount, + e.gross_amount + FROM finance_expense e + WHERE NOT EXISTS ( + SELECT 1 FROM finance_expense_line l WHERE l.expense_id = e.id + ); + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES ('finance-tax-codes', '/finance-tax-codes', 'MwSt.-Sätze Finanzen', 'admin', 55, 'Kostenarten und MwSt.-Sätze für Rechnungspositionen pflegen') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('finance.tax_codes', 'MwSt.-Sätze verwalten', 'Finanzen', 'Kostenarten und MwSt.-Sätze für Finanz-Rechnungspositionen pflegen') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_page(role_profile_id, page_key, can_view) +SELECT rp.id, 'finance-tax-codes', TRUE + FROM app_role_profile rp + WHERE rp.code IN ('admin','chair') +ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT rp.id, 'finance.tax_codes', TRUE + FROM app_role_profile rp + WHERE rp.code IN ('admin','chair') +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +ALTER TABLE finance_tax_code ENABLE ROW LEVEL SECURITY; +ALTER TABLE finance_expense_line ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS finance_tax_code_select ON finance_tax_code; +DROP POLICY IF EXISTS finance_tax_code_modify ON finance_tax_code; +CREATE POLICY finance_tax_code_select ON finance_tax_code FOR SELECT USING ( + app_has_permission('finance.read') OR app_has_permission('finance.tax_codes') +); +CREATE POLICY finance_tax_code_modify ON finance_tax_code FOR ALL USING ( + app_has_permission('finance.tax_codes') OR app_has_permission('finance.budget') +) WITH CHECK (app_has_permission('finance.tax_codes') OR app_has_permission('finance.budget')); + +DROP POLICY IF EXISTS finance_expense_line_select ON finance_expense_line; +DROP POLICY IF EXISTS finance_expense_line_modify ON finance_expense_line; +CREATE POLICY finance_expense_line_select ON finance_expense_line FOR SELECT USING ( + app_has_permission('finance.read') +); +CREATE POLICY finance_expense_line_modify ON finance_expense_line FOR ALL USING ( + app_has_permission('finance.write') +) WITH CHECK (app_has_permission('finance.write')); + +GRANT SELECT, INSERT, UPDATE, DELETE ON finance_tax_code, finance_expense_line TO br_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO br_app; diff --git a/database/migrations/029_department_catalog.sql b/database/migrations/029_department_catalog.sql new file mode 100644 index 0000000..9fc1249 --- /dev/null +++ b/database/migrations/029_department_catalog.sql @@ -0,0 +1,95 @@ +CREATE TABLE IF NOT EXISTS department_catalog ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(80) NOT NULL UNIQUE, + label VARCHAR(120) NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 1000, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_department_catalog_updated_at') THEN + CREATE TRIGGER trg_department_catalog_updated_at + BEFORE UPDATE ON department_catalog + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +INSERT INTO department_catalog(code, label, sort_order) +VALUES + ('it', 'IT', 10), + ('personal', 'Personal', 20), + ('fertigung', 'Fertigung', 30), + ('qualitaet', 'Qualität', 40), + ('logistik', 'Logistik', 50), + ('einkauf', 'Einkauf', 60), + ('verwaltung', 'Verwaltung', 70), + ('sonstiges', 'Sonstiges', 999) +ON CONFLICT (code) DO UPDATE + SET label = EXCLUDED.label, + sort_order = EXCLUDED.sort_order, + is_active = TRUE; + +INSERT INTO department_catalog(code, label, sort_order) +SELECT DISTINCT + lower(regexp_replace(regexp_replace(trim(department), '[^[:alnum:]]+', '_', 'g'), '^_+|_+$', '', 'g')) AS code, + trim(department) AS label, + 500 + FROM br_case + WHERE department IS NOT NULL + AND trim(department) <> '' + AND NOT EXISTS ( + SELECT 1 + FROM department_catalog d + WHERE d.code = lower(regexp_replace(regexp_replace(trim(br_case.department), '[^[:alnum:]]+', '_', 'g'), '^_+|_+$', '', 'g')) + OR d.code = trim(br_case.department) + ) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES ('departments', '/departments', 'Abteilungen', 'admin', 215, 'Abteilungen für Vorgänge pflegen') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES ('departments.manage', 'Abteilungen verwalten', 'Administration', 'Abteilungen anlegen, bearbeiten und deaktivieren/löschen') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_page(role_profile_id, page_key, can_view) +SELECT id, 'departments', TRUE + FROM app_role_profile + WHERE code IN ('admin', 'chair') +ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT id, 'departments.manage', TRUE + FROM app_role_profile + WHERE code IN ('admin', 'chair') +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +ALTER TABLE department_catalog ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS department_catalog_select ON department_catalog; +DROP POLICY IF EXISTS department_catalog_modify ON department_catalog; +CREATE POLICY department_catalog_select ON department_catalog FOR SELECT USING ( + app_can_view_page('departments') + OR app_has_permission('departments.manage') + OR app_has_permission('cases.write') + OR app_can_view_page('cases') +); +CREATE POLICY department_catalog_modify ON department_catalog FOR ALL USING (app_has_permission('departments.manage')) WITH CHECK (app_has_permission('departments.manage')); + +GRANT SELECT, INSERT, UPDATE, DELETE ON department_catalog TO br_app; +GRANT USAGE, SELECT ON SEQUENCE department_catalog_id_seq TO br_app; diff --git a/database/migrations/030_conf_level_oeffentlich.sql b/database/migrations/030_conf_level_oeffentlich.sql new file mode 100644 index 0000000..fd78a80 --- /dev/null +++ b/database/migrations/030_conf_level_oeffentlich.sql @@ -0,0 +1,12 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + WHERE t.typname = 'conf_level' + AND e.enumlabel = 'oeffentlich' + ) THEN + ALTER TYPE conf_level ADD VALUE 'oeffentlich'; + END IF; +END $$; diff --git a/database/migrations/031_document_categories_public_documents.sql b/database/migrations/031_document_categories_public_documents.sql new file mode 100644 index 0000000..6eee397 --- /dev/null +++ b/database/migrations/031_document_categories_public_documents.sql @@ -0,0 +1,152 @@ +CREATE TABLE IF NOT EXISTS document_category_catalog ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(80) NOT NULL UNIQUE, + label VARCHAR(120) NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 1000, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public_document_setting ( + id INTEGER PRIMARY KEY DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + intro_text TEXT NOT NULL DEFAULT 'Vom Betriebsrat freigegebene Dokumente', + created_by BIGINT REFERENCES app_user(id), + updated_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT public_document_setting_singleton CHECK (id = 1) +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_document_category_catalog_updated_at') THEN + CREATE TRIGGER trg_document_category_catalog_updated_at + BEFORE UPDATE ON document_category_catalog + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_public_document_setting_updated_at') THEN + CREATE TRIGGER trg_public_document_setting_updated_at + BEFORE UPDATE ON public_document_setting + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END $$; + +ALTER TABLE document_item + ADD COLUMN IF NOT EXISTS document_category_id BIGINT REFERENCES document_category_catalog(id); + +INSERT INTO document_category_catalog(code, label, description, sort_order) +VALUES + ('allgemein', 'Allgemein', 'Allgemeine Dokumente', 10), + ('protokoll', 'Protokoll', 'Sitzungs- und Gesprächsprotokolle', 20), + ('beschluss', 'Beschluss', 'Beschlussbezogene Dokumente', 30), + ('rechnung', 'Rechnung', 'Rechnungen und Belege', 40), + ('vertrag', 'Vertrag', 'Verträge und Vereinbarungen', 50), + ('vorlage', 'Vorlage', 'Vorlagen und Muster', 60), + ('oeffentlich', 'Öffentlich', 'Öffentlich freigegebene Dokumente', 70), + ('sonstiges', 'Sonstiges', 'Sonstige Dokumente', 999) +ON CONFLICT (code) DO UPDATE + SET label = EXCLUDED.label, + description = EXCLUDED.description, + sort_order = EXCLUDED.sort_order, + is_active = TRUE; + +UPDATE document_item d + SET document_category_id = c.id + FROM document_category_catalog c + WHERE d.document_category_id IS NULL + AND c.code = CASE + WHEN lower(COALESCE(d.document_type, '')) LIKE '%protokoll%' THEN 'protokoll' + WHEN lower(COALESCE(d.document_type, '')) LIKE '%beschluss%' THEN 'beschluss' + WHEN lower(COALESCE(d.document_type, '')) LIKE '%rechnung%' OR lower(COALESCE(d.document_type, '')) LIKE '%beleg%' THEN 'rechnung' + WHEN lower(COALESCE(d.document_type, '')) LIKE '%vertrag%' THEN 'vertrag' + WHEN lower(COALESCE(d.document_type, '')) LIKE '%vorlage%' THEN 'vorlage' + ELSE 'allgemein' + END; + +INSERT INTO public_document_setting(id, enabled, intro_text) +VALUES (1, FALSE, 'Vom Betriebsrat freigegebene Dokumente') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO app_page(page_key, path, label, nav_group, sort_order, description) +VALUES + ('document_categories', '/document-categories', 'Dokumentenkategorien', 'admin', 216, 'Kategorien für Dokumente pflegen'), + ('public_documents_admin', '/public-documents-admin', 'Öffentliche Dokumente', 'admin', 217, 'Öffentliche Dokumentenseite konfigurieren') +ON CONFLICT (page_key) DO UPDATE + SET path = EXCLUDED.path, + label = EXCLUDED.label, + nav_group = EXCLUDED.nav_group, + sort_order = EXCLUDED.sort_order, + description = EXCLUDED.description; + +INSERT INTO app_permission(permission_key, label, category, description) +VALUES + ('document_categories.manage', 'Dokumentenkategorien verwalten', 'Administration', 'Dokumentenkategorien anlegen, bearbeiten und deaktivieren/löschen'), + ('public_documents.configure', 'Öffentliche Dokumente konfigurieren', 'Administration', 'Öffentliche Dokumentenseite ein- und ausschalten sowie Beschreibung pflegen') +ON CONFLICT (permission_key) DO UPDATE + SET label = EXCLUDED.label, + category = EXCLUDED.category, + description = EXCLUDED.description; + +INSERT INTO app_role_page(role_profile_id, page_key, can_view) +SELECT id, page_key, TRUE + FROM app_role_profile + CROSS JOIN (VALUES ('document_categories'), ('public_documents_admin')) AS p(page_key) + WHERE code IN ('admin', 'chair') +ON CONFLICT (role_profile_id, page_key) DO UPDATE SET can_view = TRUE; + +INSERT INTO app_role_permission(role_profile_id, permission_key, granted) +SELECT id, permission_key, TRUE + FROM app_role_profile + CROSS JOIN (VALUES ('document_categories.manage'), ('public_documents.configure')) AS p(permission_key) + WHERE code IN ('admin', 'chair') +ON CONFLICT (role_profile_id, permission_key) DO UPDATE SET granted = TRUE; + +CREATE OR REPLACE FUNCTION app_can_read_document(target_document_id BIGINT) RETURNS BOOLEAN AS $$ +DECLARE + row document_item%ROWTYPE; +BEGIN + SELECT * INTO row FROM document_item WHERE id = target_document_id AND deleted_at IS NULL; + IF NOT FOUND THEN RETURN FALSE; END IF; + IF row.scan_status <> 'clean' THEN RETURN FALSE; END IF; + + IF row.confidentiality_level = 'oeffentlich' THEN RETURN TRUE; END IF; + IF app_role() IN ('admin', 'chair') OR app_has_permission('documents.read.all') THEN RETURN TRUE; END IF; + IF row.uploaded_by = app_user_id() THEN RETURN TRUE; END IF; + IF row.case_id IS NOT NULL AND app_can_read_case(row.case_id) THEN RETURN TRUE; END IF; + IF app_user_has_document_team_access(row.id, FALSE) THEN RETURN TRUE; END IF; + IF row.case_id IS NULL AND row.decision_id IS NULL AND row.confidentiality_level = 'intern' AND app_has_permission('documents.read.released') THEN RETURN TRUE; END IF; + + RETURN FALSE; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public; + +ALTER TABLE document_category_catalog ENABLE ROW LEVEL SECURITY; +ALTER TABLE public_document_setting ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS document_category_catalog_select ON document_category_catalog; +DROP POLICY IF EXISTS document_category_catalog_modify ON document_category_catalog; +CREATE POLICY document_category_catalog_select ON document_category_catalog FOR SELECT USING ( + is_active = TRUE + OR app_can_view_page('documents') + OR app_can_view_page('document_categories') + OR app_has_permission('documents.write') + OR app_has_permission('document_categories.manage') +); +CREATE POLICY document_category_catalog_modify ON document_category_catalog FOR ALL USING (app_has_permission('document_categories.manage')) WITH CHECK (app_has_permission('document_categories.manage')); + +DROP POLICY IF EXISTS public_document_setting_select ON public_document_setting; +DROP POLICY IF EXISTS public_document_setting_modify ON public_document_setting; +CREATE POLICY public_document_setting_select ON public_document_setting FOR SELECT USING (TRUE); +CREATE POLICY public_document_setting_modify ON public_document_setting FOR ALL USING (app_has_permission('public_documents.configure')) WITH CHECK (app_has_permission('public_documents.configure')); + +CREATE INDEX IF NOT EXISTS idx_document_item_category ON document_item(document_category_id); +CREATE INDEX IF NOT EXISTS idx_document_item_public ON document_item(confidentiality_level, scan_status, deleted_at); + +GRANT SELECT, INSERT, UPDATE, DELETE ON document_category_catalog, public_document_setting TO br_app; +GRANT USAGE, SELECT ON SEQUENCE document_category_catalog_id_seq TO br_app; diff --git a/database/migrations/032_public_documents_password.sql b/database/migrations/032_public_documents_password.sql new file mode 100644 index 0000000..38b8d8b --- /dev/null +++ b/database/migrations/032_public_documents_password.sql @@ -0,0 +1,8 @@ +ALTER TABLE public_document_setting + ADD COLUMN IF NOT EXISTS access_password_hash TEXT; + +COMMENT ON COLUMN public_document_setting.access_password_hash IS 'Argon2-Hash des globalen Kennworts für die fast öffentliche Dokumentenseite.'; + +UPDATE app_permission + SET description = 'Öffentliche Dokumentenseite ein- und ausschalten, Kennwort setzen sowie Beschreibung pflegen' + WHERE permission_key = 'public_documents.configure'; diff --git a/database/migrations/033_public_documents_intro_default.sql b/database/migrations/033_public_documents_intro_default.sql new file mode 100644 index 0000000..92aa175 --- /dev/null +++ b/database/migrations/033_public_documents_intro_default.sql @@ -0,0 +1,4 @@ +UPDATE public_document_setting + SET intro_text = 'Vom Betriebsrat freigegebene Dokumente' + WHERE id = 1 + AND intro_text = 'Hier finden Sie öffentlich freigegebene Dokumente.'; diff --git a/database/migrations/034_comment_protocol_toggle.sql b/database/migrations/034_comment_protocol_toggle.sql new file mode 100644 index 0000000..d0e3ab1 --- /dev/null +++ b/database/migrations/034_comment_protocol_toggle.sql @@ -0,0 +1,6 @@ +ALTER TABLE comment_item + ADD COLUMN IF NOT EXISTS include_in_protocol BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE INDEX IF NOT EXISTS idx_comment_item_agenda_protocol + ON comment_item(entity_type, entity_id, include_in_protocol) + WHERE deleted_at IS NULL; diff --git a/database/migrations/035_meeting_protocol_attendees.sql b/database/migrations/035_meeting_protocol_attendees.sql new file mode 100644 index 0000000..d3013e3 --- /dev/null +++ b/database/migrations/035_meeting_protocol_attendees.sql @@ -0,0 +1,18 @@ +UPDATE template_item + SET content = CASE + WHEN content LIKE '%{{quorum_statement}}%' THEN + REPLACE(content, '{{quorum_statement}}', '{{quorum_statement}} +{{attendees_block}}') + WHEN content LIKE '%## Protokollnotizen%' THEN + REPLACE(content, '## Protokollnotizen', '{{attendees_block}} + +## Protokollnotizen') + ELSE + content || ' + +{{attendees_block}}' + END, + version_label = CASE WHEN version_label IN ('v1', 'v2') THEN 'v3' ELSE version_label END, + updated_at = NOW() + WHERE category = 'meeting_protocol' + AND content NOT LIKE '%{{attendees_block}}%'; diff --git a/database/migrations/036_agenda_item_return_workflow.sql b/database/migrations/036_agenda_item_return_workflow.sql new file mode 100644 index 0000000..eaa8833 --- /dev/null +++ b/database/migrations/036_agenda_item_return_workflow.sql @@ -0,0 +1,49 @@ +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS status VARCHAR(30) NOT NULL DEFAULT 'offen'; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS returned_from_meeting_id BIGINT REFERENCES meeting(id) ON DELETE SET NULL; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS returned_at TIMESTAMPTZ; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS returned_by BIGINT REFERENCES app_user(id) ON DELETE SET NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_meeting_agenda_item_status' + AND conrelid = 'meeting_agenda_item'::regclass + ) THEN + ALTER TABLE meeting_agenda_item + ADD CONSTRAINT chk_meeting_agenda_item_status + CHECK (status IN ('offen', 'in_sitzung', 'rueckfrage', 'klaerung', 'erledigt')); + END IF; +END $$; + +UPDATE meeting_agenda_item + SET status = CASE WHEN meeting_id IS NULL THEN 'offen' ELSE 'in_sitzung' END + WHERE status IS NULL + OR status NOT IN ('offen', 'in_sitzung', 'rueckfrage', 'klaerung', 'erledigt') + OR (meeting_id IS NOT NULL AND status = 'offen'); + +CREATE TABLE IF NOT EXISTS meeting_agenda_item_return_log ( + id BIGSERIAL PRIMARY KEY, + agenda_item_id BIGINT NOT NULL REFERENCES meeting_agenda_item(id) ON DELETE CASCADE, + meeting_id BIGINT NOT NULL REFERENCES meeting(id) ON DELETE CASCADE, + agenda_number_at_return VARCHAR(20), + title_at_return VARCHAR(255) NOT NULL, + return_type VARCHAR(30) NOT NULL DEFAULT 'rueckfrage', + remark TEXT NOT NULL, + include_in_protocol BOOLEAN NOT NULL DEFAULT TRUE, + returned_by BIGINT REFERENCES app_user(id) ON DELETE SET NULL, + returned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_meeting_agenda_item_return_type CHECK (return_type IN ('rueckfrage', 'klaerung', 'bemerkung')) +); + +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_status ON meeting_agenda_item(status); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_return_log_meeting ON meeting_agenda_item_return_log(meeting_id, returned_at DESC); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_return_log_agenda ON meeting_agenda_item_return_log(agenda_item_id, returned_at DESC); diff --git a/database/migrations/037_agenda_item_parent_child_followups.sql b/database/migrations/037_agenda_item_parent_child_followups.sql new file mode 100644 index 0000000..9a4f651 --- /dev/null +++ b/database/migrations/037_agenda_item_parent_child_followups.sql @@ -0,0 +1,45 @@ +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS parent_agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS root_agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_type VARCHAR(30); + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_note TEXT; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_created_from_meeting_id BIGINT REFERENCES meeting(id) ON DELETE SET NULL; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_created_from_agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_created_at TIMESTAMPTZ; + +ALTER TABLE meeting_agenda_item + ADD COLUMN IF NOT EXISTS follow_up_created_by BIGINT REFERENCES app_user(id) ON DELETE SET NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_meeting_agenda_item_follow_up_type' + AND conrelid = 'meeting_agenda_item'::regclass + ) THEN + ALTER TABLE meeting_agenda_item + ADD CONSTRAINT chk_meeting_agenda_item_follow_up_type + CHECK (follow_up_type IS NULL OR follow_up_type IN ('rueckfrage', 'klaerung', 'bemerkung')); + END IF; +END $$; + +ALTER TABLE meeting_agenda_item_return_log + ADD COLUMN IF NOT EXISTS follow_up_agenda_item_id BIGINT REFERENCES meeting_agenda_item(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_parent ON meeting_agenda_item(parent_agenda_item_id); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_root ON meeting_agenda_item(root_agenda_item_id); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_follow_up_source ON meeting_agenda_item(follow_up_created_from_meeting_id, follow_up_created_from_agenda_item_id); +CREATE INDEX IF NOT EXISTS idx_meeting_agenda_item_return_log_follow_up ON meeting_agenda_item_return_log(follow_up_agenda_item_id); diff --git a/database/migrations/038_agenda_item_followup_klaerung.sql b/database/migrations/038_agenda_item_followup_klaerung.sql new file mode 100644 index 0000000..50d96a4 --- /dev/null +++ b/database/migrations/038_agenda_item_followup_klaerung.sql @@ -0,0 +1,23 @@ +-- Erweitert den Parent/Child-Folge-TOP-Workflow um den Typ "Klärung". +-- Diese Migration ist für bestehende Datenbanken nötig, in denen 036/037 bereits ausgeführt wurden. + +ALTER TABLE meeting_agenda_item + DROP CONSTRAINT IF EXISTS chk_meeting_agenda_item_status; + +ALTER TABLE meeting_agenda_item + ADD CONSTRAINT chk_meeting_agenda_item_status + CHECK (status IN ('offen', 'in_sitzung', 'rueckfrage', 'klaerung', 'erledigt')); + +ALTER TABLE meeting_agenda_item_return_log + DROP CONSTRAINT IF EXISTS chk_meeting_agenda_item_return_type; + +ALTER TABLE meeting_agenda_item_return_log + ADD CONSTRAINT chk_meeting_agenda_item_return_type + CHECK (return_type IN ('rueckfrage', 'klaerung', 'bemerkung')); + +ALTER TABLE meeting_agenda_item + DROP CONSTRAINT IF EXISTS chk_meeting_agenda_item_follow_up_type; + +ALTER TABLE meeting_agenda_item + ADD CONSTRAINT chk_meeting_agenda_item_follow_up_type + CHECK (follow_up_type IS NULL OR follow_up_type IN ('rueckfrage', 'klaerung', 'bemerkung')); diff --git a/database/migrations/039_template_html_editor_assets.sql b/database/migrations/039_template_html_editor_assets.sql new file mode 100644 index 0000000..9005768 --- /dev/null +++ b/database/migrations/039_template_html_editor_assets.sql @@ -0,0 +1,80 @@ +CREATE TABLE IF NOT EXISTS template_asset ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + file_name VARCHAR(255) NOT NULL, + storage_path TEXT NOT NULL, + storage_provider VARCHAR(30) NOT NULL DEFAULT 'local', + mime_type VARCHAR(120) NOT NULL, + size_bytes BIGINT NOT NULL DEFAULT 0, + sha256_hash VARCHAR(64) NOT NULL, + uploaded_by BIGINT REFERENCES app_user(id), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE template_item + ADD COLUMN IF NOT EXISTS content_format VARCHAR(20) NOT NULL DEFAULT 'markdown'; + +ALTER TABLE template_item + ADD COLUMN IF NOT EXISTS logo_asset_id BIGINT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'template_item_logo_asset_id_fkey' + ) THEN + ALTER TABLE template_item + ADD CONSTRAINT template_item_logo_asset_id_fkey + FOREIGN KEY (logo_asset_id) REFERENCES template_asset(id) ON DELETE SET NULL; + END IF; +END$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'template_item_content_format_check' + ) THEN + ALTER TABLE template_item + ADD CONSTRAINT template_item_content_format_check + CHECK (content_format IN ('markdown', 'html')); + END IF; +END$$; + +CREATE INDEX IF NOT EXISTS idx_template_item_logo_asset_id ON template_item(logo_asset_id); +CREATE INDEX IF NOT EXISTS idx_template_asset_active ON template_asset(is_active, created_at DESC); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_template_asset_updated_at' + ) THEN + CREATE TRIGGER trg_template_asset_updated_at + BEFORE UPDATE ON template_asset + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + END IF; +END$$; + +ALTER TABLE template_asset ENABLE ROW LEVEL SECURITY; +ALTER TABLE template_asset FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS template_asset_select_policy ON template_asset; +DROP POLICY IF EXISTS template_asset_modify_policy ON template_asset; +CREATE POLICY template_asset_select_policy ON template_asset + FOR SELECT USING (app_can_view_page('templates')); +CREATE POLICY template_asset_modify_policy ON template_asset + FOR ALL USING (app_has_permission('templates.write')) + WITH CHECK (app_has_permission('templates.write')); + +UPDATE template_item + SET content = '{{template_logo}} + +' || content, + updated_at = NOW() + WHERE category = 'meeting_protocol' + AND content NOT LIKE '%{{template_logo}}%'; diff --git a/database/migrations/040_formular_editor_navigation.sql b/database/migrations/040_formular_editor_navigation.sql new file mode 100644 index 0000000..cbb5da4 --- /dev/null +++ b/database/migrations/040_formular_editor_navigation.sql @@ -0,0 +1,4 @@ +UPDATE app_page + SET label = 'Vorlagen / Formular-Editor', + description = 'Vorlagenverwaltung und HTML-/Formular-Editor' + WHERE page_key = 'templates'; diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..b7b13d9 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,52 @@ +version: '3.9' +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: br_office + POSTGRES_USER: br_app + POSTGRES_PASSWORD: change_me + ports: ["5432:5432"] + volumes: + - pgdata:/var/lib/postgresql/data + + frontend: + image: node:20 + working_dir: /app + command: sh -c "npm install && npm run dev -- --host 0.0.0.0" + volumes: + - ./frontend:/app + ports: ["5173:5173"] + environment: + VITE_API_BASE_URL: http://localhost:8080/api + + backend: + image: node:20 + working_dir: /app + command: sh -c "npm install && npm run dev" + volumes: + - ./backend:/app + - ./data/uploads:/app/uploads + ports: ["8080:8080"] + environment: + DATABASE_URL: postgres://br_app:change_me@postgres:5432/br_office + CORS_ORIGIN: http://localhost:5173 + RP_ID: localhost + RP_ORIGIN: http://localhost:5173 + JWT_ACCESS_SECRET: change-me-access + JWT_REFRESH_SECRET: change-me-refresh + COOKIE_SECURE: "false" + UPLOAD_DIR: /app/uploads + FILE_ENCRYPTION_ENABLED: "true" + FILE_ENCRYPTION_KEY: please-change-this-encryption-key + SMTP_HOST: smtp.example.com + SMTP_PORT: "587" + SMTP_USER: smtp-user@example.com + SMTP_PASS: please-change-this-password + SMTP_FROM: BR Office + SMTP_SECURE: "false" + SMTP_REQUIRE_TLS: "true" + depends_on: [postgres] + +volumes: + pgdata: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..18e2734 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,22 @@ +# Deployment + +## Lokal +1. PostgreSQL 15+ bereitstellen +2. Migrationen aus `database/migrations` anwenden +3. `.env.example` nach `.env` kopieren und Werte setzen +4. Backend: + - `cd backend` + - `npm install` + - `npm run dev` +5. Frontend: + - `cd frontend` + - `npm install` + - `npm run dev` + +## Produktion +- Reverse Proxy vor Backend/Frontend +- TLS strikt erzwingen +- PostgreSQL getrennt betreiben +- Upload-Storage getrennt vom App-Container +- ClamAV/clamd als separater Dienst +- DB-Backups, PITR und Restore-Tests einplanen diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..72c8756 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,23 @@ +# Security + +## Authentisierung +- Passwort-Login mit Argon2 +- Zweiter Faktor per TOTP oder WebAuthn +- MFA für privilegierte Rollen erzwungen +- Refresh-Token in HttpOnly-Cookie + +## Autorisierung +- Rollenprüfung im Backend +- PostgreSQL Row-Level Security als zweite Schutzschicht +- getrennte DB-Rollen für App, Migration, Reporting + +## Dokumentensicherheit +- Virenscan-Hook +- SHA-256 Hash je Datei +- Download nur bei `scan_status = clean` +- Audit-Eintrag bei Upload/Download + +## Datenschutz +- vertrauliche Vorgänge per RLS eingeschränkt +- optionale Feldmaskierung via View `v_case_masked` +- Soft-Delete für fachliche Objekte diff --git a/frontend/dist/assets/br-office-logo-DlHO8fHZ.png b/frontend/dist/assets/br-office-logo-DlHO8fHZ.png new file mode 100644 index 0000000..b69dc8f Binary files /dev/null and b/frontend/dist/assets/br-office-logo-DlHO8fHZ.png differ diff --git a/frontend/dist/assets/index-BkJ2qG11.css b/frontend/dist/assets/index-BkJ2qG11.css new file mode 100644 index 0000000..878a378 --- /dev/null +++ b/frontend/dist/assets/index-BkJ2qG11.css @@ -0,0 +1 @@ +:root{--bg: linear-gradient(180deg, #eef4ff 0%, #f9fbff 40%, #f5f7fb 100%);--glass: rgba(255,255,255,.72);--glass-border: rgba(255,255,255,.65);--shadow: 0 24px 60px rgba(18, 38, 63, .12);--text: #172031;--muted: #5e6b84;--accent: #2c6bed;--danger: #d94d4d;--success: #2e9e6f}*{box-sizing:border-box}body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,sans-serif;color:var(--text);background:var(--bg);min-height:100vh}a{color:inherit;text-decoration:none}img{max-width:100%}.field-error{margin-top:6px;color:#c62828;font-size:.9rem}.field-hint{margin-top:6px;color:#666;font-size:.9rem}.btn[disabled]{opacity:.7;cursor:not-allowed}.btn-link{background:transparent;border:0;color:#2563eb;padding:0;margin-top:12px}.app-shell{display:grid;grid-template-columns:280px 1fr;min-height:100vh;gap:20px;padding:20px}.glass{background:var(--glass);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid var(--glass-border);box-shadow:var(--shadow)}.sidebar{border-radius:28px;padding:20px;display:flex;flex-direction:column}.brand{display:flex;gap:14px;align-items:center;margin-bottom:22px}.brand-badge{width:52px;height:52px;border-radius:18px;display:grid;place-items:center;background:linear-gradient(180deg,#2c6bed,#709df6);color:#fff;font-weight:700}.brand h1,.section-header h2{margin:0;font-size:1.1rem}.brand p,.section-header p{margin:4px 0 0;color:var(--muted);font-size:.92rem}.nav-item{display:block;padding:12px 14px;border-radius:16px;color:var(--muted);margin-bottom:8px;transition:.2s ease}.nav-item:hover,.nav-item.active{background:#ffffffe0;color:var(--text)}.sidebar-footer{margin-top:auto}.user-card{display:flex;flex-direction:column;margin-bottom:12px;padding:14px;border-radius:18px;background:#ffffffb3}.content{padding-right:6px}.stack{display:grid;gap:18px}.page-grid{display:grid;grid-template-columns:1.5fr 1fr;gap:18px}.section-card{border-radius:28px;padding:20px}.section-header{display:flex;justify-content:space-between;gap:16px;align-items:start;margin-bottom:16px}.section-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.input,textarea,input[type=file],select{width:100%;border:1px solid rgba(148,164,186,.3);background:#fffc;border-radius:16px;padding:12px 14px;color:var(--text)}.textarea{min-height:110px;resize:vertical}.form-grid{display:grid;gap:14px}.form-grid label span{display:block;margin-bottom:6px;font-size:.9rem;color:var(--muted)}.btn,.mini-btn{border:none;cursor:pointer;border-radius:16px;padding:12px 16px;background:var(--accent);color:#fff;font-weight:600}.btn.secondary,.mini-btn{background:#ffffffe6;color:var(--text);border:1px solid rgba(148,164,186,.25)}.mini-btn{padding:8px 10px}.mini-btn.danger{color:var(--danger)}.table-wrap{overflow:auto;border-radius:20px;border:1px solid rgba(148,164,186,.2)}.table{width:100%;border-collapse:collapse;background:#ffffffbf}.table th,.table td{text-align:left;padding:12px 14px;border-bottom:1px solid rgba(148,164,186,.16);font-size:.95rem;vertical-align:top}.table tbody tr:hover{background:#fffffff2}.row-actions{display:flex;gap:8px;flex-wrap:wrap}.alert{padding:12px 14px;border-radius:16px;margin-bottom:12px}.alert.error{background:#d94d4d1f;color:#7f2630}.alert.success{background:#2e9e6f24;color:#176542}.stats-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.stat-card{padding:18px;border-radius:22px;background:#ffffffd9;display:flex;flex-direction:column;gap:8px}.stat-card span{color:var(--muted)}.stat-card strong{font-size:1.8rem}.search{min-width:240px}.login-wrapper{min-height:100vh;display:grid;place-items:center;padding:20px}.login-card{width:min(460px,100%);padding:28px;border-radius:28px;background:#fffc;border:1px solid rgba(255,255,255,.7);box-shadow:var(--shadow)}.security-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.pane{padding:18px;border-radius:24px}.bullet-list{margin:0;padding-left:20px;display:grid;gap:8px}.qr{width:180px;margin:14px 0;border-radius:18px}code{display:inline-block;padding:8px 10px;border-radius:12px;background:#ffffffd9}@media (max-width: 1100px){.app-shell,.page-grid,.security-grid,.stats-grid{grid-template-columns:1fr}}.calendar-page-grid{grid-template-columns:1.7fr 1fr}.calendar-toolbar{display:grid;gap:14px;margin-bottom:16px}.calendar-toolbar-actions,.calendar-view-switch{display:flex;gap:8px;flex-wrap:wrap}.calendar-view-switch .mini-btn.active{background:var(--accent);color:#fff}.calendar-filter-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.calendar-grid{display:grid;gap:10px}.calendar-grid.month{grid-template-columns:repeat(7,minmax(0,1fr))}.calendar-grid.week{grid-template-columns:repeat(7,minmax(0,1fr));align-items:start}.calendar-grid-head{font-size:.88rem;color:var(--muted);text-transform:uppercase;padding:0 6px}.calendar-day-cell,.calendar-week-column{border:1px solid rgba(148,164,186,.22);background:#ffffffc7;border-radius:18px;min-height:150px;padding:10px;text-align:left}.calendar-day-cell.muted{opacity:.55}.calendar-day-cell.selected,.calendar-week-column.selected,.calendar-detail-item.selected{outline:2px solid rgba(44,107,237,.4)}.calendar-day-header{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:10px}.calendar-day-header.large{align-items:start;flex-direction:column}.calendar-event-stack{display:grid;gap:6px}.calendar-badge{display:block;border-radius:12px;padding:7px 9px;font-size:.84rem;cursor:pointer;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.calendar-badge.calendar{background:#2c6bed1f;color:#1d4fd8}.calendar-badge.meeting{background:#2e9e6f24;color:#176542}.calendar-badge.deadline{background:#d94d4d1f;color:#8a2332}.calendar-badge.task{background:#ffac332e;color:#8a4f00}.calendar-more{font-size:.82rem;color:var(--muted);padding:0 2px}.calendar-detail-list{display:grid;gap:8px}.calendar-detail-item{display:flex;justify-content:space-between;gap:12px;text-align:left;border:1px solid rgba(148,164,186,.2);background:#ffffffc7;border-radius:16px;padding:12px 14px}@media (max-width: 1300px){.calendar-filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width: 1100px){.calendar-page-grid,.calendar-grid.month,.calendar-grid.week{grid-template-columns:1fr}}.app-shell-modern{grid-template-columns:290px minmax(0,1fr);gap:20px;padding:20px;background:radial-gradient(circle at top left,rgba(44,107,237,.06),transparent 30%),linear-gradient(180deg,#f3f6fb,#f7f9fc)}.sidebar-modern{border-radius:24px;padding:16px 14px;display:flex;flex-direction:column;gap:14px;background:#ffffffd1;border:1px solid rgba(15,23,42,.08);box-shadow:0 16px 42px #0f172a14}.brand-modern{display:flex;align-items:center;gap:12px;margin-bottom:6px;padding:4px 4px 14px;border-bottom:1px solid rgba(15,23,42,.08)}.brand-logo{width:58px;height:auto;flex:0 0 auto;object-fit:contain}.brand-copy{min-width:0;flex:1}.brand-copy h1{margin:0;font-size:1.5rem;line-height:1.05;letter-spacing:-.02em}.brand-copy p{margin:4px 0 0;color:var(--muted);font-size:.9rem}.sidebar-groups{display:flex;flex-direction:column;gap:10px}.sidebar-group{border:1px solid rgba(15,23,42,.07);border-radius:16px;overflow:hidden;background:#ffffff8c}.sidebar-group.standalone{padding:6px}.sidebar-group-toggle{width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;border:0;background:transparent;padding:12px 14px;cursor:pointer;font-weight:700;font-size:.98rem;text-align:left}.sidebar-group-toggle:hover{background:#0f172a0a}.sidebar-chevron{display:inline-flex;align-items:center;justify-content:center;transform:rotate(180deg);transition:transform .18s ease;opacity:.65}.sidebar-chevron.open{transform:rotate(0)}.sidebar-links{display:grid;gap:6px;padding:0 8px 10px}.sidebar-group.standalone .sidebar-links{padding:0}.sidebar-link{display:flex;align-items:center;min-height:42px;padding:10px 12px;border-radius:12px;color:#334155;font-weight:500;transition:background .16s ease,color .16s ease}.sidebar-link:hover{background:#0f172a0d;color:var(--text)}.sidebar-link.active{background:#2563eb1c;color:#1d4ed8;font-weight:700}.user-card-modern{background:#ffffffdb;border:1px solid rgba(15,23,42,.05);border-radius:14px}.content-modern{min-width:0}.app-topbar{display:none}.menu-btn,.sidebar-close-btn{border:1px solid rgba(15,23,42,.1);background:#fff;border-radius:10px;padding:8px 10px;cursor:pointer;font-weight:700}.sidebar-close-btn,.sidebar-backdrop{display:none}.mobile-brand-inline{display:flex;align-items:center;gap:10px}.mobile-brand-logo{width:28px;height:auto}.dashboard-stack{gap:20px}.dashboard-kpi-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px}.dashboard-kpi-card{display:flex;align-items:center;gap:16px;min-height:120px;padding:20px;border-radius:24px;background:#ffffffeb;border:1px solid rgba(148,164,186,.14)}.dashboard-kpi-icon{width:62px;height:62px;border-radius:18px;background:#2563eb14;flex:0 0 auto}.dashboard-kpi-content{display:grid;gap:4px}.dashboard-kpi-content span{color:var(--muted);font-size:.95rem}.dashboard-kpi-content strong{font-size:2rem;line-height:1}.dashboard-kpi-content small{color:#2563eb;font-size:.92rem}.tone-green .dashboard-kpi-icon{background:#22c55e1f}.tone-purple .dashboard-kpi-icon{background:#a855f71f}.tone-amber .dashboard-kpi-icon{background:#f59e0b1f}.dashboard-two-col{display:grid;grid-template-columns:1fr 1fr;gap:18px}.dashboard-list{display:grid;gap:10px}.dashboard-list-item{display:grid;grid-template-columns:72px minmax(0,1fr) auto;gap:14px;align-items:center;padding:12px;border-radius:18px;background:#ffffffc7;border:1px solid rgba(148,164,186,.18)}.dashboard-date-box{display:grid;place-items:center;min-height:64px;border-radius:14px;background:#0f172a0a;text-align:center}.dashboard-date-box strong{font-size:1rem}.dashboard-date-box span{font-size:.8rem;color:var(--muted)}.dashboard-list-main{min-width:0}.dashboard-table td strong{display:block}.dashboard-notification-list{display:grid;gap:10px}.dashboard-notification-row{display:flex;justify-content:space-between;gap:14px;align-items:center;padding:14px 16px;border-radius:18px;background:#ffffffc7;border:1px solid rgba(148,164,186,.16)}.status-chip{display:inline-flex;align-items:center;justify-content:center;min-height:30px;padding:6px 12px;border-radius:999px;font-size:.85rem;font-weight:700;background:#94a3b824;color:#475569;white-space:nowrap}.status-geplant,.status-neu,.status-offen,.status-gestartet{background:#2563eb1f;color:#1d4ed8}.status-in_bearbeitung,.status-rueckfrage,.status-ungeplant{background:#f59e0b2e;color:#9a5b00}.status-abgeschlossen,.status-beendet{background:#22c55e24;color:#15803d}.status-neutral{background:#94a3b824;color:#475569}@media (max-width: 1280px){.dashboard-kpi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width: 980px){.app-shell-modern{grid-template-columns:1fr;padding:0;gap:0}.sidebar-modern{position:fixed;inset:0 auto 0 0;width:min(320px,88vw);height:100vh;border-radius:0 22px 22px 0;transform:translate(-100%);transition:transform .24s ease;z-index:40;overflow:auto}.sidebar-modern.open{transform:translate(0)}.sidebar-backdrop{display:block;position:fixed;top:0;right:0;bottom:0;left:0;background:#0f172a73;z-index:35}.app-topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;background:#ffffffe6;border-bottom:1px solid rgba(15,23,42,.08);position:sticky;top:0;z-index:25}.sidebar-close-btn{display:inline-flex;margin-left:auto}.content-modern{padding:14px}.dashboard-two-col{grid-template-columns:1fr}}.deadline-datepicker-textfield .ms-TextField-fieldGroup{background:#fffffff5!important;border:1px solid rgba(148,163,184,.35)!important;border-radius:10px!important;min-height:40px!important;box-shadow:none!important}.deadline-datepicker-textfield .ms-TextField-field{background:#fff!important;color:#0f172a!important}.deadline-datepicker-textfield .ms-TextField-field::placeholder{color:#64748b!important;opacity:1!important}@media (max-width: 760px){.dashboard-kpi-grid,.dashboard-list-item{grid-template-columns:1fr}.dashboard-notification-row{flex-direction:column;align-items:flex-start}}.glass-panel,.glass-modal,.glass-surface{background:#ffffffd1;backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,.22);box-shadow:0 24px 80px #0f172a2e;border-radius:20px}.glass-section{background:#ffffffb8;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,.18);box-shadow:0 18px 50px #0f172a1f;border-radius:18px}.page-glass{min-height:100%}.page-glass .table-wrap,.page-glass .pane,.page-glass .stack>*,.page-glass .page-grid>*,.page-glass .section-card,.page-glass .glass,.page-glass .glass-card{background:#ffffffb8;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.16);box-shadow:0 14px 38px #0f172a1a;border-radius:18px} diff --git a/frontend/dist/assets/index-DfTZCsJJ.js b/frontend/dist/assets/index-DfTZCsJJ.js new file mode 100644 index 0000000..539814e --- /dev/null +++ b/frontend/dist/assets/index-DfTZCsJJ.js @@ -0,0 +1,1818 @@ +function zp(e,t){for(var n=0;ni[s]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))i(s);new MutationObserver(s=>{for(const a of s)if(a.type==="childList")for(const o of a.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function n(s){const a={};return s.integrity&&(a.integrity=s.integrity),s.referrerPolicy&&(a.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?a.credentials="include":s.crossOrigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function i(s){if(s.ep)return;s.ep=!0;const a=n(s);fetch(s.href,a)}})();function Bp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Tp={exports:{}},wo={},Pp={exports:{}},xe={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var zs=Symbol.for("react.element"),Xv=Symbol.for("react.portal"),Qv=Symbol.for("react.fragment"),Zv=Symbol.for("react.strict_mode"),Yv=Symbol.for("react.profiler"),Jv=Symbol.for("react.provider"),eb=Symbol.for("react.context"),tb=Symbol.for("react.forward_ref"),nb=Symbol.for("react.suspense"),rb=Symbol.for("react.memo"),ib=Symbol.for("react.lazy"),Wd=Symbol.iterator;function sb(e){return e===null||typeof e!="object"?null:(e=Wd&&e[Wd]||e["@@iterator"],typeof e=="function"?e:null)}var Ep={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Dp=Object.assign,Ap={};function gi(e,t,n){this.props=e,this.context=t,this.refs=Ap,this.updater=n||Ep}gi.prototype.isReactComponent={};gi.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};gi.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Rp(){}Rp.prototype=gi.prototype;function Du(e,t,n){this.props=e,this.context=t,this.refs=Ap,this.updater=n||Ep}var Au=Du.prototype=new Rp;Au.constructor=Du;Dp(Au,gi.prototype);Au.isPureReactComponent=!0;var Gd=Array.isArray,Op=Object.prototype.hasOwnProperty,Ru={current:null},Ip={key:!0,ref:!0,__self:!0,__source:!0};function Lp(e,t,n){var i,s={},a=null,o=null;if(t!=null)for(i in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(a=""+t.key),t)Op.call(t,i)&&!Ip.hasOwnProperty(i)&&(s[i]=t[i]);var l=arguments.length-2;if(l===1)s.children=n;else if(1>>1,J=R[ae];if(0>>1;aes(ee,X))les(ge,ee)?(R[ae]=ge,R[le]=X,ae=le):(R[ae]=ee,R[pe]=X,ae=pe);else if(les(ge,X))R[ae]=ge,R[le]=X,ae=le;else break e}}return K}function s(R,K){var X=R.sortIndex-K.sortIndex;return X!==0?X:R.id-K.id}if(typeof performance=="object"&&typeof performance.now=="function"){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,l=o.now();e.unstable_now=function(){return o.now()-l}}var c=[],u=[],m=1,d=null,h=3,x=!1,g=!1,p=!1,w=typeof setTimeout=="function"?setTimeout:null,b=typeof clearTimeout=="function"?clearTimeout:null,v=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(R){for(var K=n(u);K!==null;){if(K.callback===null)i(u);else if(K.startTime<=R)i(u),K.sortIndex=K.expirationTime,t(c,K);else break;K=n(u)}}function j(R){if(p=!1,y(R),!g)if(n(c)!==null)g=!0,$(k);else{var K=n(u);K!==null&&Z(j,K.startTime-R)}}function k(R,K){g=!1,p&&(p=!1,b(S),S=-1),x=!0;var X=h;try{for(y(K),d=n(c);d!==null&&(!(d.expirationTime>K)||R&&!F());){var ae=d.callback;if(typeof ae=="function"){d.callback=null,h=d.priorityLevel;var J=ae(d.expirationTime<=K);K=e.unstable_now(),typeof J=="function"?d.callback=J:d===n(c)&&i(c),y(K)}else i(c);d=n(c)}if(d!==null)var ue=!0;else{var pe=n(u);pe!==null&&Z(j,pe.startTime-K),ue=!1}return ue}finally{d=null,h=X,x=!1}}var N=!1,_=null,S=-1,P=5,D=-1;function F(){return!(e.unstable_now()-DR||125ae?(R.sortIndex=X,t(u,R),n(c)===null&&R===n(u)&&(p?(b(S),S=-1):p=!0,Z(j,X-ae))):(R.sortIndex=J,t(c,R),g||x||(g=!0,$(k))),R},e.unstable_shouldYield=F,e.unstable_wrapCallback=function(R){var K=h;return function(){var X=h;h=K;try{return R.apply(this,arguments)}finally{h=X}}}})(Up);Vp.exports=Up;var xc=Vp.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var vb=f,St=xc;function W(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),vc=Object.prototype.hasOwnProperty,bb=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Qd={},Zd={};function yb(e){return vc.call(Zd,e)?!0:vc.call(Qd,e)?!1:bb.test(e)?Zd[e]=!0:(Qd[e]=!0,!1)}function jb(e,t,n,i){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return i?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function _b(e,t,n,i){if(t===null||typeof t>"u"||jb(e,t,n,i))return!0;if(i)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function dt(e,t,n,i,s,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=i,this.attributeNamespace=s,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var nt={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){nt[e]=new dt(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];nt[t]=new dt(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){nt[e]=new dt(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){nt[e]=new dt(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){nt[e]=new dt(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){nt[e]=new dt(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){nt[e]=new dt(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){nt[e]=new dt(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){nt[e]=new dt(e,5,!1,e.toLowerCase(),null,!1,!1)});var Iu=/[\-:]([a-z])/g;function Lu(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Iu,Lu);nt[t]=new dt(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Iu,Lu);nt[t]=new dt(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Iu,Lu);nt[t]=new dt(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){nt[e]=new dt(e,1,!1,e.toLowerCase(),null,!1,!1)});nt.xlinkHref=new dt("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){nt[e]=new dt(e,1,!1,e.toLowerCase(),null,!0,!0)});function Mu(e,t,n,i){var s=nt.hasOwnProperty(t)?nt[t]:null;(s!==null?s.type!==0:i||!(2l||s[o]!==a[l]){var c=` +`+s[o].replace(" at new "," at ");return e.displayName&&c.includes("")&&(c=c.replace("",e.displayName)),c}while(1<=o&&0<=l);break}}}finally{al=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Fi(e):""}function wb(e){switch(e.tag){case 5:return Fi(e.type);case 16:return Fi("Lazy");case 13:return Fi("Suspense");case 19:return Fi("SuspenseList");case 0:case 2:case 15:return e=ol(e.type,!1),e;case 11:return e=ol(e.type.render,!1),e;case 1:return e=ol(e.type,!0),e;default:return""}}function _c(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Ar:return"Fragment";case Dr:return"Portal";case bc:return"Profiler";case Fu:return"StrictMode";case yc:return"Suspense";case jc:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Wp:return(e.displayName||"Context")+".Consumer";case Kp:return(e._context.displayName||"Context")+".Provider";case $u:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case qu:return t=e.displayName||null,t!==null?t:_c(e.type)||"Memo";case An:t=e._payload,e=e._init;try{return _c(e(t))}catch{}}return null}function kb(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return _c(t);case 8:return t===Fu?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function er(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Xp(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Sb(e){var t=Xp(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),i=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var s=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return s.call(this)},set:function(o){i=""+o,a.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return i},setValue:function(o){i=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Gs(e){e._valueTracker||(e._valueTracker=Sb(e))}function Qp(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),i="";return e&&(i=Xp(e)?e.checked?"true":"false":e.value),e=i,e!==n?(t.setValue(e),!0):!1}function $a(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function wc(e,t){var n=t.checked;return Le({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Jd(e,t){var n=t.defaultValue==null?"":t.defaultValue,i=t.checked!=null?t.checked:t.defaultChecked;n=er(t.value!=null?t.value:n),e._wrapperState={initialChecked:i,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Zp(e,t){t=t.checked,t!=null&&Mu(e,"checked",t,!1)}function kc(e,t){Zp(e,t);var n=er(t.value),i=t.type;if(n!=null)i==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(i==="submit"||i==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Sc(e,t.type,n):t.hasOwnProperty("defaultValue")&&Sc(e,t.type,er(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ef(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var i=t.type;if(!(i!=="submit"&&i!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Sc(e,t,n){(t!=="number"||$a(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var $i=Array.isArray;function Gr(e,t,n,i){if(e=e.options,t){t={};for(var s=0;s"+t.valueOf().toString()+"",t=Xs.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function is(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Hi={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Nb=["Webkit","ms","Moz","O"];Object.keys(Hi).forEach(function(e){Nb.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Hi[t]=Hi[e]})});function th(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Hi.hasOwnProperty(e)&&Hi[e]?(""+t).trim():t+"px"}function nh(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var i=n.indexOf("--")===0,s=th(n,t[n],i);n==="float"&&(n="cssFloat"),i?e.setProperty(n,s):e[n]=s}}var Cb=Le({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function zc(e,t){if(t){if(Cb[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(W(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(W(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(W(61))}if(t.style!=null&&typeof t.style!="object")throw Error(W(62))}}function Bc(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Tc=null;function Vu(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Pc=null,Xr=null,Qr=null;function rf(e){if(e=Ps(e)){if(typeof Pc!="function")throw Error(W(280));var t=e.stateNode;t&&(t=zo(t),Pc(e.stateNode,e.type,t))}}function rh(e){Xr?Qr?Qr.push(e):Qr=[e]:Xr=e}function ih(){if(Xr){var e=Xr,t=Qr;if(Qr=Xr=null,rf(e),t)for(e=0;e>>=0,e===0?32:31-(Lb(e)/Mb|0)|0}var Qs=64,Zs=4194304;function qi(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Ha(e,t){var n=e.pendingLanes;if(n===0)return 0;var i=0,s=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var l=o&~s;l!==0?i=qi(l):(a&=o,a!==0&&(i=qi(a)))}else o=n&~s,o!==0?i=qi(o):a!==0&&(i=qi(a));if(i===0)return 0;if(t!==0&&t!==i&&!(t&s)&&(s=i&-i,a=t&-t,s>=a||s===16&&(a&4194240)!==0))return t;if(i&4&&(i|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=i;0n;n++)t.push(e);return t}function Bs(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Gt(t),e[t]=n}function Vb(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var i=e.eventTimes;for(e=e.expirationTimes;0=Wi),mf=" ",pf=!1;function kh(e,t){switch(e){case"keyup":return xy.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Sh(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Rr=!1;function by(e,t){switch(e){case"compositionend":return Sh(t);case"keypress":return t.which!==32?null:(pf=!0,mf);case"textInput":return e=t.data,e===mf&&pf?null:e;default:return null}}function yy(e,t){if(Rr)return e==="compositionend"||!Zu&&kh(e,t)?(e=_h(),Na=Gu=Ln=null,Rr=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=i}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=vf(n)}}function Bh(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Bh(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Th(){for(var e=window,t=$a();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=$a(e.document)}return t}function Yu(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function By(e){var t=Th(),n=e.focusedElem,i=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Bh(n.ownerDocument.documentElement,n)){if(i!==null&&Yu(n)){if(t=i.start,e=i.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var s=n.textContent.length,a=Math.min(i.start,s);i=i.end===void 0?a:Math.min(i.end,s),!e.extend&&a>i&&(s=i,i=a,a=s),s=bf(n,a);var o=bf(n,i);s&&o&&(e.rangeCount!==1||e.anchorNode!==s.node||e.anchorOffset!==s.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(s.node,s.offset),e.removeAllRanges(),a>i?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Or=null,Ic=null,Xi=null,Lc=!1;function yf(e,t,n){var i=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Lc||Or==null||Or!==$a(i)||(i=Or,"selectionStart"in i&&Yu(i)?i={start:i.selectionStart,end:i.selectionEnd}:(i=(i.ownerDocument&&i.ownerDocument.defaultView||window).getSelection(),i={anchorNode:i.anchorNode,anchorOffset:i.anchorOffset,focusNode:i.focusNode,focusOffset:i.focusOffset}),Xi&&us(Xi,i)||(Xi=i,i=Ga(Ic,"onSelect"),0Mr||(e.current=Uc[Mr],Uc[Mr]=null,Mr--)}function Ce(e,t){Mr++,Uc[Mr]=e.current,e.current=t}var tr={},ot=sr(tr),ht=sr(!1),yr=tr;function ri(e,t){var n=e.type.contextTypes;if(!n)return tr;var i=e.stateNode;if(i&&i.__reactInternalMemoizedUnmaskedChildContext===t)return i.__reactInternalMemoizedMaskedChildContext;var s={},a;for(a in n)s[a]=t[a];return i&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=s),s}function gt(e){return e=e.childContextTypes,e!=null}function Qa(){Te(ht),Te(ot)}function Cf(e,t,n){if(ot.current!==tr)throw Error(W(168));Ce(ot,t),Ce(ht,n)}function Mh(e,t,n){var i=e.stateNode;if(t=t.childContextTypes,typeof i.getChildContext!="function")return n;i=i.getChildContext();for(var s in i)if(!(s in t))throw Error(W(108,kb(e)||"Unknown",s));return Le({},n,i)}function Za(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||tr,yr=ot.current,Ce(ot,e),Ce(ht,ht.current),!0}function zf(e,t,n){var i=e.stateNode;if(!i)throw Error(W(169));n?(e=Mh(e,t,yr),i.__reactInternalMemoizedMergedChildContext=e,Te(ht),Te(ot),Ce(ot,e)):Te(ht),Ce(ht,n)}var hn=null,Bo=!1,jl=!1;function Fh(e){hn===null?hn=[e]:hn.push(e)}function $y(e){Bo=!0,Fh(e)}function ar(){if(!jl&&hn!==null){jl=!0;var e=0,t=we;try{var n=hn;for(we=1;e>=o,s-=o,gn=1<<32-Gt(t)+s|n<S?(P=_,_=null):P=_.sibling;var D=h(b,_,y[S],j);if(D===null){_===null&&(_=P);break}e&&_&&D.alternate===null&&t(b,_),v=a(D,v,S),N===null?k=D:N.sibling=D,N=D,_=P}if(S===y.length)return n(b,_),De&&cr(b,S),k;if(_===null){for(;SS?(P=_,_=null):P=_.sibling;var F=h(b,_,D.value,j);if(F===null){_===null&&(_=P);break}e&&_&&F.alternate===null&&t(b,_),v=a(F,v,S),N===null?k=F:N.sibling=F,N=F,_=P}if(D.done)return n(b,_),De&&cr(b,S),k;if(_===null){for(;!D.done;S++,D=y.next())D=d(b,D.value,j),D!==null&&(v=a(D,v,S),N===null?k=D:N.sibling=D,N=D);return De&&cr(b,S),k}for(_=i(b,_);!D.done;S++,D=y.next())D=x(_,b,S,D.value,j),D!==null&&(e&&D.alternate!==null&&_.delete(D.key===null?S:D.key),v=a(D,v,S),N===null?k=D:N.sibling=D,N=D);return e&&_.forEach(function(T){return t(b,T)}),De&&cr(b,S),k}function w(b,v,y,j){if(typeof y=="object"&&y!==null&&y.type===Ar&&y.key===null&&(y=y.props.children),typeof y=="object"&&y!==null){switch(y.$$typeof){case Ws:e:{for(var k=y.key,N=v;N!==null;){if(N.key===k){if(k=y.type,k===Ar){if(N.tag===7){n(b,N.sibling),v=s(N,y.props.children),v.return=b,b=v;break e}}else if(N.elementType===k||typeof k=="object"&&k!==null&&k.$$typeof===An&&Pf(k)===N.type){n(b,N.sibling),v=s(N,y.props),v.ref=Di(b,N,y),v.return=b,b=v;break e}n(b,N);break}else t(b,N);N=N.sibling}y.type===Ar?(v=xr(y.props.children,b.mode,j,y.key),v.return=b,b=v):(j=Aa(y.type,y.key,y.props,null,b.mode,j),j.ref=Di(b,v,y),j.return=b,b=j)}return o(b);case Dr:e:{for(N=y.key;v!==null;){if(v.key===N)if(v.tag===4&&v.stateNode.containerInfo===y.containerInfo&&v.stateNode.implementation===y.implementation){n(b,v.sibling),v=s(v,y.children||[]),v.return=b,b=v;break e}else{n(b,v);break}else t(b,v);v=v.sibling}v=Bl(y,b.mode,j),v.return=b,b=v}return o(b);case An:return N=y._init,w(b,v,N(y._payload),j)}if($i(y))return g(b,v,y,j);if(zi(y))return p(b,v,y,j);ia(b,y)}return typeof y=="string"&&y!==""||typeof y=="number"?(y=""+y,v!==null&&v.tag===6?(n(b,v.sibling),v=s(v,y),v.return=b,b=v):(n(b,v),v=zl(y,b.mode,j),v.return=b,b=v),o(b)):n(b,v)}return w}var si=Uh(!0),Hh=Uh(!1),eo=sr(null),to=null,qr=null,nd=null;function rd(){nd=qr=to=null}function id(e){var t=eo.current;Te(eo),e._currentValue=t}function Wc(e,t,n){for(;e!==null;){var i=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,i!==null&&(i.childLanes|=t)):i!==null&&(i.childLanes&t)!==t&&(i.childLanes|=t),e===n)break;e=e.return}}function Yr(e,t){to=e,nd=qr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(pt=!0),e.firstContext=null)}function Lt(e){var t=e._currentValue;if(nd!==e)if(e={context:e,memoizedValue:t,next:null},qr===null){if(to===null)throw Error(W(308));qr=e,to.dependencies={lanes:0,firstContext:e}}else qr=qr.next=e;return t}var mr=null;function sd(e){mr===null?mr=[e]:mr.push(e)}function Kh(e,t,n,i){var s=t.interleaved;return s===null?(n.next=n,sd(t)):(n.next=s.next,s.next=n),t.interleaved=n,jn(e,i)}function jn(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Rn=!1;function ad(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Wh(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function vn(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Wn(e,t,n){var i=e.updateQueue;if(i===null)return null;if(i=i.shared,be&2){var s=i.pending;return s===null?t.next=t:(t.next=s.next,s.next=t),i.pending=t,jn(e,n)}return s=i.interleaved,s===null?(t.next=t,sd(i)):(t.next=s.next,s.next=t),i.interleaved=t,jn(e,n)}function za(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var i=t.lanes;i&=e.pendingLanes,n|=i,t.lanes=n,Hu(e,n)}}function Ef(e,t){var n=e.updateQueue,i=e.alternate;if(i!==null&&(i=i.updateQueue,n===i)){var s=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?s=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?s=a=t:a=a.next=t}else s=a=t;n={baseState:i.baseState,firstBaseUpdate:s,lastBaseUpdate:a,shared:i.shared,effects:i.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function no(e,t,n,i){var s=e.updateQueue;Rn=!1;var a=s.firstBaseUpdate,o=s.lastBaseUpdate,l=s.shared.pending;if(l!==null){s.shared.pending=null;var c=l,u=c.next;c.next=null,o===null?a=u:o.next=u,o=c;var m=e.alternate;m!==null&&(m=m.updateQueue,l=m.lastBaseUpdate,l!==o&&(l===null?m.firstBaseUpdate=u:l.next=u,m.lastBaseUpdate=c))}if(a!==null){var d=s.baseState;o=0,m=u=c=null,l=a;do{var h=l.lane,x=l.eventTime;if((i&h)===h){m!==null&&(m=m.next={eventTime:x,lane:0,tag:l.tag,payload:l.payload,callback:l.callback,next:null});e:{var g=e,p=l;switch(h=t,x=n,p.tag){case 1:if(g=p.payload,typeof g=="function"){d=g.call(x,d,h);break e}d=g;break e;case 3:g.flags=g.flags&-65537|128;case 0:if(g=p.payload,h=typeof g=="function"?g.call(x,d,h):g,h==null)break e;d=Le({},d,h);break e;case 2:Rn=!0}}l.callback!==null&&l.lane!==0&&(e.flags|=64,h=s.effects,h===null?s.effects=[l]:h.push(l))}else x={eventTime:x,lane:h,tag:l.tag,payload:l.payload,callback:l.callback,next:null},m===null?(u=m=x,c=d):m=m.next=x,o|=h;if(l=l.next,l===null){if(l=s.shared.pending,l===null)break;h=l,l=h.next,h.next=null,s.lastBaseUpdate=h,s.shared.pending=null}}while(!0);if(m===null&&(c=d),s.baseState=c,s.firstBaseUpdate=u,s.lastBaseUpdate=m,t=s.shared.interleaved,t!==null){s=t;do o|=s.lane,s=s.next;while(s!==t)}else a===null&&(s.shared.lanes=0);wr|=o,e.lanes=o,e.memoizedState=d}}function Df(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var i=wl.transition;wl.transition={};try{e(!1),t()}finally{we=n,wl.transition=i}}function ug(){return Mt().memoizedState}function Hy(e,t,n){var i=Xn(e);if(n={lane:i,action:n,hasEagerState:!1,eagerState:null,next:null},dg(e))fg(t,n);else if(n=Kh(e,t,n,i),n!==null){var s=ct();Xt(n,e,i,s),mg(n,t,i)}}function Ky(e,t,n){var i=Xn(e),s={lane:i,action:n,hasEagerState:!1,eagerState:null,next:null};if(dg(e))fg(t,s);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,l=a(o,n);if(s.hasEagerState=!0,s.eagerState=l,Qt(l,o)){var c=t.interleaved;c===null?(s.next=s,sd(t)):(s.next=c.next,c.next=s),t.interleaved=s;return}}catch{}finally{}n=Kh(e,t,s,i),n!==null&&(s=ct(),Xt(n,e,i,s),mg(n,t,i))}}function dg(e){var t=e.alternate;return e===Oe||t!==null&&t===Oe}function fg(e,t){Qi=io=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function mg(e,t,n){if(n&4194240){var i=t.lanes;i&=e.pendingLanes,n|=i,t.lanes=n,Hu(e,n)}}var so={readContext:Lt,useCallback:rt,useContext:rt,useEffect:rt,useImperativeHandle:rt,useInsertionEffect:rt,useLayoutEffect:rt,useMemo:rt,useReducer:rt,useRef:rt,useState:rt,useDebugValue:rt,useDeferredValue:rt,useTransition:rt,useMutableSource:rt,useSyncExternalStore:rt,useId:rt,unstable_isNewReconciler:!1},Wy={readContext:Lt,useCallback:function(e,t){return nn().memoizedState=[e,t===void 0?null:t],e},useContext:Lt,useEffect:Rf,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Ta(4194308,4,sg.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ta(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ta(4,2,e,t)},useMemo:function(e,t){var n=nn();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var i=nn();return t=n!==void 0?n(t):t,i.memoizedState=i.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},i.queue=e,e=e.dispatch=Hy.bind(null,Oe,e),[i.memoizedState,e]},useRef:function(e){var t=nn();return e={current:e},t.memoizedState=e},useState:Af,useDebugValue:pd,useDeferredValue:function(e){return nn().memoizedState=e},useTransition:function(){var e=Af(!1),t=e[0];return e=Uy.bind(null,e[1]),nn().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var i=Oe,s=nn();if(De){if(n===void 0)throw Error(W(407));n=n()}else{if(n=t(),Ze===null)throw Error(W(349));_r&30||Zh(i,t,n)}s.memoizedState=n;var a={value:n,getSnapshot:t};return s.queue=a,Rf(Jh.bind(null,i,a,e),[e]),i.flags|=2048,vs(9,Yh.bind(null,i,a,n,t),void 0,null),n},useId:function(){var e=nn(),t=Ze.identifierPrefix;if(De){var n=xn,i=gn;n=(i&~(1<<32-Gt(i)-1)).toString(32)+n,t=":"+t+"R"+n,n=gs++,0<\/script>",e=e.removeChild(e.firstChild)):typeof i.is=="string"?e=o.createElement(n,{is:i.is}):(e=o.createElement(n),n==="select"&&(o=e,i.multiple?o.multiple=!0:i.size&&(o.size=i.size))):e=o.createElementNS(e,n),e[rn]=t,e[ms]=i,wg(e,t,!1,!1),t.stateNode=e;e:{switch(o=Bc(n,i),n){case"dialog":Be("cancel",e),Be("close",e),s=i;break;case"iframe":case"object":case"embed":Be("load",e),s=i;break;case"video":case"audio":for(s=0;sli&&(t.flags|=128,i=!0,Ai(a,!1),t.lanes=4194304)}else{if(!i)if(e=ro(o),e!==null){if(t.flags|=128,i=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Ai(a,!0),a.tail===null&&a.tailMode==="hidden"&&!o.alternate&&!De)return it(t),null}else 2*$e()-a.renderingStartTime>li&&n!==1073741824&&(t.flags|=128,i=!0,Ai(a,!1),t.lanes=4194304);a.isBackwards?(o.sibling=t.child,t.child=o):(n=a.last,n!==null?n.sibling=o:t.child=o,a.last=o)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=$e(),t.sibling=null,n=Ae.current,Ce(Ae,i?n&1|2:n&1),t):(it(t),null);case 22:case 23:return yd(),i=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==i&&(t.flags|=8192),i&&t.mode&1?bt&1073741824&&(it(t),t.subtreeFlags&6&&(t.flags|=8192)):it(t),null;case 24:return null;case 25:return null}throw Error(W(156,t.tag))}function t0(e,t){switch(ed(t),t.tag){case 1:return gt(t.type)&&Qa(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ai(),Te(ht),Te(ot),cd(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return ld(t),null;case 13:if(Te(Ae),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(W(340));ii()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Te(Ae),null;case 4:return ai(),null;case 10:return id(t.type._context),null;case 22:case 23:return yd(),null;case 24:return null;default:return null}}var aa=!1,st=!1,n0=typeof WeakSet=="function"?WeakSet:Set,ie=null;function Vr(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(i){Fe(e,t,i)}else n.current=null}function nu(e,t,n){try{n()}catch(i){Fe(e,t,i)}}var Kf=!1;function r0(e,t){if(Mc=Ka,e=Th(),Yu(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var i=n.getSelection&&n.getSelection();if(i&&i.rangeCount!==0){n=i.anchorNode;var s=i.anchorOffset,a=i.focusNode;i=i.focusOffset;try{n.nodeType,a.nodeType}catch{n=null;break e}var o=0,l=-1,c=-1,u=0,m=0,d=e,h=null;t:for(;;){for(var x;d!==n||s!==0&&d.nodeType!==3||(l=o+s),d!==a||i!==0&&d.nodeType!==3||(c=o+i),d.nodeType===3&&(o+=d.nodeValue.length),(x=d.firstChild)!==null;)h=d,d=x;for(;;){if(d===e)break t;if(h===n&&++u===s&&(l=o),h===a&&++m===i&&(c=o),(x=d.nextSibling)!==null)break;d=h,h=d.parentNode}d=x}n=l===-1||c===-1?null:{start:l,end:c}}else n=null}n=n||{start:0,end:0}}else n=null;for(Fc={focusedElem:e,selectionRange:n},Ka=!1,ie=t;ie!==null;)if(t=ie,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,ie=e;else for(;ie!==null;){t=ie;try{var g=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(g!==null){var p=g.memoizedProps,w=g.memoizedState,b=t.stateNode,v=b.getSnapshotBeforeUpdate(t.elementType===t.type?p:Vt(t.type,p),w);b.__reactInternalSnapshotBeforeUpdate=v}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent="":y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(W(163))}}catch(j){Fe(t,t.return,j)}if(e=t.sibling,e!==null){e.return=t.return,ie=e;break}ie=t.return}return g=Kf,Kf=!1,g}function Zi(e,t,n){var i=t.updateQueue;if(i=i!==null?i.lastEffect:null,i!==null){var s=i=i.next;do{if((s.tag&e)===e){var a=s.destroy;s.destroy=void 0,a!==void 0&&nu(t,n,a)}s=s.next}while(s!==i)}}function Eo(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var i=n.create;n.destroy=i()}n=n.next}while(n!==t)}}function ru(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Ng(e){var t=e.alternate;t!==null&&(e.alternate=null,Ng(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[rn],delete t[ms],delete t[Vc],delete t[My],delete t[Fy])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Cg(e){return e.tag===5||e.tag===3||e.tag===4}function Wf(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Cg(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function iu(e,t,n){var i=e.tag;if(i===5||i===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Xa));else if(i!==4&&(e=e.child,e!==null))for(iu(e,t,n),e=e.sibling;e!==null;)iu(e,t,n),e=e.sibling}function su(e,t,n){var i=e.tag;if(i===5||i===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(i!==4&&(e=e.child,e!==null))for(su(e,t,n),e=e.sibling;e!==null;)su(e,t,n),e=e.sibling}var Je=null,Ut=!1;function En(e,t,n){for(n=n.child;n!==null;)zg(e,t,n),n=n.sibling}function zg(e,t,n){if(on&&typeof on.onCommitFiberUnmount=="function")try{on.onCommitFiberUnmount(ko,n)}catch{}switch(n.tag){case 5:st||Vr(n,t);case 6:var i=Je,s=Ut;Je=null,En(e,t,n),Je=i,Ut=s,Je!==null&&(Ut?(e=Je,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Je.removeChild(n.stateNode));break;case 18:Je!==null&&(Ut?(e=Je,n=n.stateNode,e.nodeType===8?yl(e.parentNode,n):e.nodeType===1&&yl(e,n),ls(e)):yl(Je,n.stateNode));break;case 4:i=Je,s=Ut,Je=n.stateNode.containerInfo,Ut=!0,En(e,t,n),Je=i,Ut=s;break;case 0:case 11:case 14:case 15:if(!st&&(i=n.updateQueue,i!==null&&(i=i.lastEffect,i!==null))){s=i=i.next;do{var a=s,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&nu(n,t,o),s=s.next}while(s!==i)}En(e,t,n);break;case 1:if(!st&&(Vr(n,t),i=n.stateNode,typeof i.componentWillUnmount=="function"))try{i.props=n.memoizedProps,i.state=n.memoizedState,i.componentWillUnmount()}catch(l){Fe(n,t,l)}En(e,t,n);break;case 21:En(e,t,n);break;case 22:n.mode&1?(st=(i=st)||n.memoizedState!==null,En(e,t,n),st=i):En(e,t,n);break;default:En(e,t,n)}}function Gf(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new n0),t.forEach(function(i){var s=f0.bind(null,e,i);n.has(i)||(n.add(i),i.then(s,s))})}}function $t(e,t){var n=t.deletions;if(n!==null)for(var i=0;is&&(s=o),i&=~a}if(i=s,i=$e()-i,i=(120>i?120:480>i?480:1080>i?1080:1920>i?1920:3e3>i?3e3:4320>i?4320:1960*s0(i/1960))-i,10e?16:e,Mn===null)var i=!1;else{if(e=Mn,Mn=null,lo=0,be&6)throw Error(W(331));var s=be;for(be|=4,ie=e.current;ie!==null;){var a=ie,o=a.child;if(ie.flags&16){var l=a.deletions;if(l!==null){for(var c=0;c$e()-vd?gr(e,0):xd|=n),xt(e,t)}function Og(e,t){t===0&&(e.mode&1?(t=Zs,Zs<<=1,!(Zs&130023424)&&(Zs=4194304)):t=1);var n=ct();e=jn(e,t),e!==null&&(Bs(e,t,n),xt(e,n))}function d0(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Og(e,n)}function f0(e,t){var n=0;switch(e.tag){case 13:var i=e.stateNode,s=e.memoizedState;s!==null&&(n=s.retryLane);break;case 19:i=e.stateNode;break;default:throw Error(W(314))}i!==null&&i.delete(t),Og(e,n)}var Ig;Ig=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ht.current)pt=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return pt=!1,Jy(e,t,n);pt=!!(e.flags&131072)}else pt=!1,De&&t.flags&1048576&&$h(t,Ja,t.index);switch(t.lanes=0,t.tag){case 2:var i=t.type;Pa(e,t),e=t.pendingProps;var s=ri(t,ot.current);Yr(t,n),s=dd(null,t,i,e,s,n);var a=fd();return t.flags|=1,typeof s=="object"&&s!==null&&typeof s.render=="function"&&s.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,gt(i)?(a=!0,Za(t)):a=!1,t.memoizedState=s.state!==null&&s.state!==void 0?s.state:null,ad(t),s.updater=Po,t.stateNode=s,s._reactInternals=t,Xc(t,i,e,n),t=Yc(null,t,i,!0,a,n)):(t.tag=0,De&&a&&Ju(t),lt(null,t,s,n),t=t.child),t;case 16:i=t.elementType;e:{switch(Pa(e,t),e=t.pendingProps,s=i._init,i=s(i._payload),t.type=i,s=t.tag=p0(i),e=Vt(i,e),s){case 0:t=Zc(null,t,i,e,n);break e;case 1:t=Vf(null,t,i,e,n);break e;case 11:t=$f(null,t,i,e,n);break e;case 14:t=qf(null,t,i,Vt(i.type,e),n);break e}throw Error(W(306,i,""))}return t;case 0:return i=t.type,s=t.pendingProps,s=t.elementType===i?s:Vt(i,s),Zc(e,t,i,s,n);case 1:return i=t.type,s=t.pendingProps,s=t.elementType===i?s:Vt(i,s),Vf(e,t,i,s,n);case 3:e:{if(yg(t),e===null)throw Error(W(387));i=t.pendingProps,a=t.memoizedState,s=a.element,Wh(e,t),no(t,i,null,n);var o=t.memoizedState;if(i=o.element,a.isDehydrated)if(a={element:i,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=a,t.memoizedState=a,t.flags&256){s=oi(Error(W(423)),t),t=Uf(e,t,i,n,s);break e}else if(i!==s){s=oi(Error(W(424)),t),t=Uf(e,t,i,n,s);break e}else for(_t=Kn(t.stateNode.containerInfo.firstChild),wt=t,De=!0,Kt=null,n=Hh(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(ii(),i===s){t=_n(e,t,n);break e}lt(e,t,i,n)}t=t.child}return t;case 5:return Gh(t),e===null&&Kc(t),i=t.type,s=t.pendingProps,a=e!==null?e.memoizedProps:null,o=s.children,$c(i,s)?o=null:a!==null&&$c(i,a)&&(t.flags|=32),bg(e,t),lt(e,t,o,n),t.child;case 6:return e===null&&Kc(t),null;case 13:return jg(e,t,n);case 4:return od(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=si(t,null,i,n):lt(e,t,i,n),t.child;case 11:return i=t.type,s=t.pendingProps,s=t.elementType===i?s:Vt(i,s),$f(e,t,i,s,n);case 7:return lt(e,t,t.pendingProps,n),t.child;case 8:return lt(e,t,t.pendingProps.children,n),t.child;case 12:return lt(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(i=t.type._context,s=t.pendingProps,a=t.memoizedProps,o=s.value,Ce(eo,i._currentValue),i._currentValue=o,a!==null)if(Qt(a.value,o)){if(a.children===s.children&&!ht.current){t=_n(e,t,n);break e}}else for(a=t.child,a!==null&&(a.return=t);a!==null;){var l=a.dependencies;if(l!==null){o=a.child;for(var c=l.firstContext;c!==null;){if(c.context===i){if(a.tag===1){c=vn(-1,n&-n),c.tag=2;var u=a.updateQueue;if(u!==null){u=u.shared;var m=u.pending;m===null?c.next=c:(c.next=m.next,m.next=c),u.pending=c}}a.lanes|=n,c=a.alternate,c!==null&&(c.lanes|=n),Wc(a.return,n,t),l.lanes|=n;break}c=c.next}}else if(a.tag===10)o=a.type===t.type?null:a.child;else if(a.tag===18){if(o=a.return,o===null)throw Error(W(341));o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),Wc(o,n,t),o=a.sibling}else o=a.child;if(o!==null)o.return=a;else for(o=a;o!==null;){if(o===t){o=null;break}if(a=o.sibling,a!==null){a.return=o.return,o=a;break}o=o.return}a=o}lt(e,t,s.children,n),t=t.child}return t;case 9:return s=t.type,i=t.pendingProps.children,Yr(t,n),s=Lt(s),i=i(s),t.flags|=1,lt(e,t,i,n),t.child;case 14:return i=t.type,s=Vt(i,t.pendingProps),s=Vt(i.type,s),qf(e,t,i,s,n);case 15:return xg(e,t,t.type,t.pendingProps,n);case 17:return i=t.type,s=t.pendingProps,s=t.elementType===i?s:Vt(i,s),Pa(e,t),t.tag=1,gt(i)?(e=!0,Za(t)):e=!1,Yr(t,n),pg(t,i,s),Xc(t,i,s,n),Yc(null,t,i,!0,e,n);case 19:return _g(e,t,n);case 22:return vg(e,t,n)}throw Error(W(156,t.tag))};function Lg(e,t){return dh(e,t)}function m0(e,t,n,i){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=i,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Rt(e,t,n,i){return new m0(e,t,n,i)}function _d(e){return e=e.prototype,!(!e||!e.isReactComponent)}function p0(e){if(typeof e=="function")return _d(e)?1:0;if(e!=null){if(e=e.$$typeof,e===$u)return 11;if(e===qu)return 14}return 2}function Qn(e,t){var n=e.alternate;return n===null?(n=Rt(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Aa(e,t,n,i,s,a){var o=2;if(i=e,typeof e=="function")_d(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Ar:return xr(n.children,s,a,t);case Fu:o=8,s|=8;break;case bc:return e=Rt(12,n,t,s|2),e.elementType=bc,e.lanes=a,e;case yc:return e=Rt(13,n,t,s),e.elementType=yc,e.lanes=a,e;case jc:return e=Rt(19,n,t,s),e.elementType=jc,e.lanes=a,e;case Gp:return Ao(n,s,a,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Kp:o=10;break e;case Wp:o=9;break e;case $u:o=11;break e;case qu:o=14;break e;case An:o=16,i=null;break e}throw Error(W(130,e==null?e:typeof e,""))}return t=Rt(o,n,t,s),t.elementType=e,t.type=i,t.lanes=a,t}function xr(e,t,n,i){return e=Rt(7,e,i,t),e.lanes=n,e}function Ao(e,t,n,i){return e=Rt(22,e,i,t),e.elementType=Gp,e.lanes=n,e.stateNode={isHidden:!1},e}function zl(e,t,n){return e=Rt(6,e,null,t),e.lanes=n,e}function Bl(e,t,n){return t=Rt(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function h0(e,t,n,i,s){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=cl(0),this.expirationTimes=cl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=cl(0),this.identifierPrefix=i,this.onRecoverableError=s,this.mutableSourceEagerHydrationData=null}function wd(e,t,n,i,s,a,o,l,c){return e=new h0(e,t,n,l,c),t===1?(t=1,a===!0&&(t|=8)):t=0,a=Rt(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:i,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},ad(a),e}function g0(e,t,n){var i=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(qg)}catch(e){console.error(e)}}qg(),qp.exports=Ct;var fo=qp.exports,nm=fo;gc.createRoot=nm.createRoot,gc.hydrateRoot=nm.hydrateRoot;/** + * @remix-run/router v1.23.2 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function ys(){return ys=Object.assign?Object.assign.bind():function(e){for(var t=1;t"u")throw new Error(t)}function Cd(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function _0(){return Math.random().toString(36).substr(2,8)}function im(e,t){return{usr:e.state,key:e.key,idx:t}}function uu(e,t,n,i){return n===void 0&&(n=null),ys({pathname:typeof e=="string"?e:e.pathname,search:"",hash:""},typeof t=="string"?bi(t):t,{state:n,key:t&&t.key||i||_0()})}function mo(e){let{pathname:t="/",search:n="",hash:i=""}=e;return n&&n!=="?"&&(t+=n.charAt(0)==="?"?n:"?"+n),i&&i!=="#"&&(t+=i.charAt(0)==="#"?i:"#"+i),t}function bi(e){let t={};if(e){let n=e.indexOf("#");n>=0&&(t.hash=e.substr(n),e=e.substr(0,n));let i=e.indexOf("?");i>=0&&(t.search=e.substr(i),e=e.substr(0,i)),e&&(t.pathname=e)}return t}function w0(e,t,n,i){i===void 0&&(i={});let{window:s=document.defaultView,v5Compat:a=!1}=i,o=s.history,l=Fn.Pop,c=null,u=m();u==null&&(u=0,o.replaceState(ys({},o.state,{idx:u}),""));function m(){return(o.state||{idx:null}).idx}function d(){l=Fn.Pop;let w=m(),b=w==null?null:w-u;u=w,c&&c({action:l,location:p.location,delta:b})}function h(w,b){l=Fn.Push;let v=uu(p.location,w,b);u=m()+1;let y=im(v,u),j=p.createHref(v);try{o.pushState(y,"",j)}catch(k){if(k instanceof DOMException&&k.name==="DataCloneError")throw k;s.location.assign(j)}a&&c&&c({action:l,location:p.location,delta:1})}function x(w,b){l=Fn.Replace;let v=uu(p.location,w,b);u=m();let y=im(v,u),j=p.createHref(v);o.replaceState(y,"",j),a&&c&&c({action:l,location:p.location,delta:0})}function g(w){let b=s.location.origin!=="null"?s.location.origin:s.location.href,v=typeof w=="string"?w:mo(w);return v=v.replace(/ $/,"%20"),Ie(b,"No window.location.(origin|href) available to create URL for href: "+v),new URL(v,b)}let p={get action(){return l},get location(){return e(s,o)},listen(w){if(c)throw new Error("A history only accepts one active listener");return s.addEventListener(rm,d),c=w,()=>{s.removeEventListener(rm,d),c=null}},createHref(w){return t(s,w)},createURL:g,encodeLocation(w){let b=g(w);return{pathname:b.pathname,search:b.search,hash:b.hash}},push:h,replace:x,go(w){return o.go(w)}};return p}var sm;(function(e){e.data="data",e.deferred="deferred",e.redirect="redirect",e.error="error"})(sm||(sm={}));function k0(e,t,n){return n===void 0&&(n="/"),S0(e,t,n)}function S0(e,t,n,i){let s=typeof t=="string"?bi(t):t,a=ci(s.pathname||"/",n);if(a==null)return null;let o=Vg(e);N0(o);let l=null;for(let c=0;l==null&&c{let c={relativePath:l===void 0?a.path||"":l,caseSensitive:a.caseSensitive===!0,childrenIndex:o,route:a};c.relativePath.startsWith("/")&&(Ie(c.relativePath.startsWith(i),'Absolute route path "'+c.relativePath+'" nested under path '+('"'+i+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),c.relativePath=c.relativePath.slice(i.length));let u=Zn([i,c.relativePath]),m=n.concat(c);a.children&&a.children.length>0&&(Ie(a.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+u+'".')),Vg(a.children,t,m,u)),!(a.path==null&&!a.index)&&t.push({path:u,score:D0(u,a.index),routesMeta:m})};return e.forEach((a,o)=>{var l;if(a.path===""||!((l=a.path)!=null&&l.includes("?")))s(a,o);else for(let c of Ug(a.path))s(a,o,c)}),t}function Ug(e){let t=e.split("/");if(t.length===0)return[];let[n,...i]=t,s=n.endsWith("?"),a=n.replace(/\?$/,"");if(i.length===0)return s?[a,""]:[a];let o=Ug(i.join("/")),l=[];return l.push(...o.map(c=>c===""?a:[a,c].join("/"))),s&&l.push(...o),l.map(c=>e.startsWith("/")&&c===""?"/":c)}function N0(e){e.sort((t,n)=>t.score!==n.score?n.score-t.score:A0(t.routesMeta.map(i=>i.childrenIndex),n.routesMeta.map(i=>i.childrenIndex)))}const C0=/^:[\w-]+$/,z0=3,B0=2,T0=1,P0=10,E0=-2,am=e=>e==="*";function D0(e,t){let n=e.split("/"),i=n.length;return n.some(am)&&(i+=E0),t&&(i+=B0),n.filter(s=>!am(s)).reduce((s,a)=>s+(C0.test(a)?z0:a===""?T0:P0),i)}function A0(e,t){return e.length===t.length&&e.slice(0,-1).every((i,s)=>i===t[s])?e[e.length-1]-t[t.length-1]:0}function R0(e,t,n){let{routesMeta:i}=e,s={},a="/",o=[];for(let l=0;l{let{paramName:h,isOptional:x}=m;if(h==="*"){let p=l[d]||"";o=a.slice(0,a.length-p.length).replace(/(.)\/+$/,"$1")}const g=l[d];return x&&!g?u[h]=void 0:u[h]=(g||"").replace(/%2F/g,"/"),u},{}),pathname:a,pathnameBase:o,pattern:e}}function O0(e,t,n){t===void 0&&(t=!1),n===void 0&&(n=!0),Cd(e==="*"||!e.endsWith("*")||e.endsWith("/*"),'Route path "'+e+'" will be treated as if it were '+('"'+e.replace(/\*$/,"/*")+'" because the `*` character must ')+"always follow a `/` in the pattern. To get rid of this warning, "+('please change the route path to "'+e.replace(/\*$/,"/*")+'".'));let i=[],s="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(o,l,c)=>(i.push({paramName:l,isOptional:c!=null}),c?"/?([^\\/]+)?":"/([^\\/]+)"));return e.endsWith("*")?(i.push({paramName:"*"}),s+=e==="*"||e==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?s+="\\/*$":e!==""&&e!=="/"&&(s+="(?:(?=\\/|$))"),[new RegExp(s,t?void 0:"i"),i]}function I0(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return Cd(!1,'The URL path "'+e+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent '+("encoding ("+t+").")),e}}function ci(e,t){if(t==="/")return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith("/")?t.length-1:t.length,i=e.charAt(n);return i&&i!=="/"?null:e.slice(n)||"/"}const L0=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,M0=e=>L0.test(e);function F0(e,t){t===void 0&&(t="/");let{pathname:n,search:i="",hash:s=""}=typeof e=="string"?bi(e):e,a;if(n)if(M0(n))a=n;else{if(n.includes("//")){let o=n;n=n.replace(/\/\/+/g,"/"),Cd(!1,"Pathnames cannot have embedded double slashes - normalizing "+(o+" -> "+n))}n.startsWith("/")?a=om(n.substring(1),"/"):a=om(n,t)}else a=t;return{pathname:a,search:V0(i),hash:U0(s)}}function om(e,t){let n=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(s=>{s===".."?n.length>1&&n.pop():s!=="."&&n.push(s)}),n.length>1?n.join("/"):"/"}function Tl(e,t,n,i){return"Cannot include a '"+e+"' character in a manually specified "+("`to."+t+"` field ["+JSON.stringify(i)+"]. Please separate it out to the ")+("`to."+n+"` field. Alternatively you may provide the full path as ")+'a string in and the router will parse it for you.'}function $0(e){return e.filter((t,n)=>n===0||t.route.path&&t.route.path.length>0)}function zd(e,t){let n=$0(e);return t?n.map((i,s)=>s===n.length-1?i.pathname:i.pathnameBase):n.map(i=>i.pathnameBase)}function Bd(e,t,n,i){i===void 0&&(i=!1);let s;typeof e=="string"?s=bi(e):(s=ys({},e),Ie(!s.pathname||!s.pathname.includes("?"),Tl("?","pathname","search",s)),Ie(!s.pathname||!s.pathname.includes("#"),Tl("#","pathname","hash",s)),Ie(!s.search||!s.search.includes("#"),Tl("#","search","hash",s)));let a=e===""||s.pathname==="",o=a?"/":s.pathname,l;if(o==null)l=n;else{let d=t.length-1;if(!i&&o.startsWith("..")){let h=o.split("/");for(;h[0]==="..";)h.shift(),d-=1;s.pathname=h.join("/")}l=d>=0?t[d]:"/"}let c=F0(s,l),u=o&&o!=="/"&&o.endsWith("/"),m=(a||o===".")&&n.endsWith("/");return!c.pathname.endsWith("/")&&(u||m)&&(c.pathname+="/"),c}const Zn=e=>e.join("/").replace(/\/\/+/g,"/"),q0=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),V0=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,U0=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e;function H0(e){return e!=null&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.internal=="boolean"&&"data"in e}const Hg=["post","put","patch","delete"];new Set(Hg);const K0=["get",...Hg];new Set(K0);/** + * React Router v6.30.3 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function js(){return js=Object.assign?Object.assign.bind():function(e){for(var t=1;t{l.current=!0}),f.useCallback(function(u,m){if(m===void 0&&(m={}),!l.current)return;if(typeof u=="number"){i.go(u);return}let d=Bd(u,JSON.parse(o),a,m.relative==="path");e==null&&t!=="/"&&(d.pathname=d.pathname==="/"?t:Zn([t,d.pathname])),(m.replace?i.replace:i.push)(d,m.state,m)},[t,i,o,a,e])}function qo(e,t){let{relative:n}=t===void 0?{}:t,{future:i}=f.useContext(Cn),{matches:s}=f.useContext(or),{pathname:a}=Jt(),o=JSON.stringify(zd(s,i.v7_relativeSplatPath));return f.useMemo(()=>Bd(e,JSON.parse(o),a,n==="path"),[e,o,a,n])}function X0(e,t){return Q0(e,t)}function Q0(e,t,n,i){yi()||Ie(!1);let{navigator:s}=f.useContext(Cn),{matches:a}=f.useContext(or),o=a[a.length-1],l=o?o.params:{};o&&o.pathname;let c=o?o.pathnameBase:"/";o&&o.route;let u=Jt(),m;if(t){var d;let w=typeof t=="string"?bi(t):t;c==="/"||(d=w.pathname)!=null&&d.startsWith(c)||Ie(!1),m=w}else m=u;let h=m.pathname||"/",x=h;if(c!=="/"){let w=c.replace(/^\//,"").split("/");x="/"+h.replace(/^\//,"").split("/").slice(w.length).join("/")}let g=k0(e,{pathname:x}),p=t1(g&&g.map(w=>Object.assign({},w,{params:Object.assign({},l,w.params),pathname:Zn([c,s.encodeLocation?s.encodeLocation(w.pathname).pathname:w.pathname]),pathnameBase:w.pathnameBase==="/"?c:Zn([c,s.encodeLocation?s.encodeLocation(w.pathnameBase).pathname:w.pathnameBase])})),a,n,i);return t&&p?f.createElement(Fo.Provider,{value:{location:js({pathname:"/",search:"",hash:"",state:null,key:"default"},m),navigationType:Fn.Pop}},p):p}function Z0(){let e=s1(),t=H0(e)?e.status+" "+e.statusText:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,s={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return f.createElement(f.Fragment,null,f.createElement("h2",null,"Unexpected Application Error!"),f.createElement("h3",{style:{fontStyle:"italic"}},t),n?f.createElement("pre",{style:s},n):null,null)}const Y0=f.createElement(Z0,null);class J0 extends f.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,n){return n.location!==t.location||n.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:n.error,location:n.location,revalidation:t.revalidation||n.revalidation}}componentDidCatch(t,n){console.error("React Router caught the following error during render",t,n)}render(){return this.state.error!==void 0?f.createElement(or.Provider,{value:this.props.routeContext},f.createElement(Wg.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function e1(e){let{routeContext:t,match:n,children:i}=e,s=f.useContext(Mo);return s&&s.static&&s.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(s.staticContext._deepestRenderedBoundaryId=n.route.id),f.createElement(or.Provider,{value:t},i)}function t1(e,t,n,i){var s;if(t===void 0&&(t=[]),n===void 0&&(n=null),i===void 0&&(i=null),e==null){var a;if(!n)return null;if(n.errors)e=n.matches;else if((a=i)!=null&&a.v7_partialHydration&&t.length===0&&!n.initialized&&n.matches.length>0)e=n.matches;else return null}let o=e,l=(s=n)==null?void 0:s.errors;if(l!=null){let m=o.findIndex(d=>d.route.id&&(l==null?void 0:l[d.route.id])!==void 0);m>=0||Ie(!1),o=o.slice(0,Math.min(o.length,m+1))}let c=!1,u=-1;if(n&&i&&i.v7_partialHydration)for(let m=0;m=0?o=o.slice(0,u+1):o=[o[0]];break}}}return o.reduceRight((m,d,h)=>{let x,g=!1,p=null,w=null;n&&(x=l&&d.route.id?l[d.route.id]:void 0,p=d.route.errorElement||Y0,c&&(u<0&&h===0?(o1("route-fallback"),g=!0,w=null):u===h&&(g=!0,w=d.route.hydrateFallbackElement||null)));let b=t.concat(o.slice(0,h+1)),v=()=>{let y;return x?y=p:g?y=w:d.route.Component?y=f.createElement(d.route.Component,null):d.route.element?y=d.route.element:y=m,f.createElement(e1,{match:d,routeContext:{outlet:m,matches:b,isDataRoute:n!=null},children:y})};return n&&(d.route.ErrorBoundary||d.route.errorElement||h===0)?f.createElement(J0,{location:n.location,revalidation:n.revalidation,component:p,error:x,children:v(),routeContext:{outlet:null,matches:b,isDataRoute:!0}}):v()},null)}var Xg=function(e){return e.UseBlocker="useBlocker",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e}(Xg||{}),Qg=function(e){return e.UseBlocker="useBlocker",e.UseLoaderData="useLoaderData",e.UseActionData="useActionData",e.UseRouteError="useRouteError",e.UseNavigation="useNavigation",e.UseRouteLoaderData="useRouteLoaderData",e.UseMatches="useMatches",e.UseRevalidator="useRevalidator",e.UseNavigateStable="useNavigate",e.UseRouteId="useRouteId",e}(Qg||{});function n1(e){let t=f.useContext(Mo);return t||Ie(!1),t}function r1(e){let t=f.useContext(Kg);return t||Ie(!1),t}function i1(e){let t=f.useContext(or);return t||Ie(!1),t}function Zg(e){let t=i1(),n=t.matches[t.matches.length-1];return n.route.id||Ie(!1),n.route.id}function s1(){var e;let t=f.useContext(Wg),n=r1(),i=Zg();return t!==void 0?t:(e=n.errors)==null?void 0:e[i]}function a1(){let{router:e}=n1(Xg.UseNavigateStable),t=Zg(Qg.UseNavigateStable),n=f.useRef(!1);return Gg(()=>{n.current=!0}),f.useCallback(function(s,a){a===void 0&&(a={}),n.current&&(typeof s=="number"?e.navigate(s):e.navigate(s,js({fromRouteId:t},a)))},[e,t])}const lm={};function o1(e,t,n){lm[e]||(lm[e]=!0)}function l1(e,t){e==null||e.v7_startTransition,e==null||e.v7_relativeSplatPath}function ca(e){let{to:t,replace:n,state:i,relative:s}=e;yi()||Ie(!1);let{future:a,static:o}=f.useContext(Cn),{matches:l}=f.useContext(or),{pathname:c}=Jt(),u=$o(),m=Bd(t,zd(l,a.v7_relativeSplatPath),c,s==="path"),d=JSON.stringify(m);return f.useEffect(()=>u(JSON.parse(d),{replace:n,state:i,relative:s}),[u,d,s,n,i]),null}function je(e){Ie(!1)}function c1(e){let{basename:t="/",children:n=null,location:i,navigationType:s=Fn.Pop,navigator:a,static:o=!1,future:l}=e;yi()&&Ie(!1);let c=t.replace(/^\/*/,"/"),u=f.useMemo(()=>({basename:c,navigator:a,static:o,future:js({v7_relativeSplatPath:!1},l)}),[c,l,a,o]);typeof i=="string"&&(i=bi(i));let{pathname:m="/",search:d="",hash:h="",state:x=null,key:g="default"}=i,p=f.useMemo(()=>{let w=ci(m,c);return w==null?null:{location:{pathname:w,search:d,hash:h,state:x,key:g},navigationType:s}},[c,m,d,h,x,g,s]);return p==null?null:f.createElement(Cn.Provider,{value:u},f.createElement(Fo.Provider,{children:n,value:p}))}function cm(e){let{children:t,location:n}=e;return X0(fu(t),n)}new Promise(()=>{});function fu(e,t){t===void 0&&(t=[]);let n=[];return f.Children.forEach(e,(i,s)=>{if(!f.isValidElement(i))return;let a=[...t,s];if(i.type===f.Fragment){n.push.apply(n,fu(i.props.children,a));return}i.type!==je&&Ie(!1),!i.props.index||!i.props.children||Ie(!1);let o={id:i.props.id||a.join("-"),caseSensitive:i.props.caseSensitive,element:i.props.element,Component:i.props.Component,index:i.props.index,path:i.props.path,loader:i.props.loader,action:i.props.action,errorElement:i.props.errorElement,ErrorBoundary:i.props.ErrorBoundary,hasErrorBoundary:i.props.ErrorBoundary!=null||i.props.errorElement!=null,shouldRevalidate:i.props.shouldRevalidate,handle:i.props.handle,lazy:i.props.lazy};i.props.children&&(o.children=fu(i.props.children,a)),n.push(o)}),n}/** + * React Router DOM v6.30.3 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */function po(){return po=Object.assign?Object.assign.bind():function(e){for(var t=1;t=0)&&(n[s]=e[s]);return n}function u1(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function d1(e,t){return e.button===0&&(!t||t==="_self")&&!u1(e)}const f1=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],m1=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"],p1="6";try{window.__reactRouterVersion=p1}catch{}const h1=f.createContext({isTransitioning:!1}),g1="startTransition",um=br[g1];function x1(e){let{basename:t,children:n,future:i,window:s}=e,a=f.useRef();a.current==null&&(a.current=j0({window:s,v5Compat:!0}));let o=a.current,[l,c]=f.useState({action:o.action,location:o.location}),{v7_startTransition:u}=i||{},m=f.useCallback(d=>{u&&um?um(()=>c(d)):c(d)},[c,u]);return f.useLayoutEffect(()=>o.listen(m),[o,m]),f.useEffect(()=>l1(i),[i]),f.createElement(c1,{basename:t,children:n,location:l.location,navigationType:l.action,navigator:o,future:i})}const v1=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",b1=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,y1=f.forwardRef(function(t,n){let{onClick:i,relative:s,reloadDocument:a,replace:o,state:l,target:c,to:u,preventScrollReset:m,viewTransition:d}=t,h=Yg(t,f1),{basename:x}=f.useContext(Cn),g,p=!1;if(typeof u=="string"&&b1.test(u)&&(g=u,v1))try{let y=new URL(window.location.href),j=u.startsWith("//")?new URL(y.protocol+u):new URL(u),k=ci(j.pathname,x);j.origin===y.origin&&k!=null?u=k+j.search+j.hash:p=!0}catch{}let w=W0(u,{relative:s}),b=_1(u,{replace:o,state:l,target:c,preventScrollReset:m,relative:s,viewTransition:d});function v(y){i&&i(y),y.defaultPrevented||b(y)}return f.createElement("a",po({},h,{href:g||w,onClick:p||a?i:v,ref:n,target:c}))}),Ra=f.forwardRef(function(t,n){let{"aria-current":i="page",caseSensitive:s=!1,className:a="",end:o=!1,style:l,to:c,viewTransition:u,children:m}=t,d=Yg(t,m1),h=qo(c,{relative:d.relative}),x=Jt(),g=f.useContext(Kg),{navigator:p,basename:w}=f.useContext(Cn),b=g!=null&&w1(h)&&u===!0,v=p.encodeLocation?p.encodeLocation(h).pathname:h.pathname,y=x.pathname,j=g&&g.navigation&&g.navigation.location?g.navigation.location.pathname:null;s||(y=y.toLowerCase(),j=j?j.toLowerCase():null,v=v.toLowerCase()),j&&w&&(j=ci(j,w)||j);const k=v!=="/"&&v.endsWith("/")?v.length-1:v.length;let N=y===v||!o&&y.startsWith(v)&&y.charAt(k)==="/",_=j!=null&&(j===v||!o&&j.startsWith(v)&&j.charAt(v.length)==="/"),S={isActive:N,isPending:_,isTransitioning:b},P=N?i:void 0,D;typeof a=="function"?D=a(S):D=[a,N?"active":null,_?"pending":null,b?"transitioning":null].filter(Boolean).join(" ");let F=typeof l=="function"?l(S):l;return f.createElement(y1,po({},d,{"aria-current":P,className:D,ref:n,style:F,to:c,viewTransition:u}),typeof m=="function"?m(S):m)});var mu;(function(e){e.UseScrollRestoration="useScrollRestoration",e.UseSubmit="useSubmit",e.UseSubmitFetcher="useSubmitFetcher",e.UseFetcher="useFetcher",e.useViewTransitionState="useViewTransitionState"})(mu||(mu={}));var dm;(function(e){e.UseFetcher="useFetcher",e.UseFetchers="useFetchers",e.UseScrollRestoration="useScrollRestoration"})(dm||(dm={}));function j1(e){let t=f.useContext(Mo);return t||Ie(!1),t}function _1(e,t){let{target:n,replace:i,state:s,preventScrollReset:a,relative:o,viewTransition:l}=t===void 0?{}:t,c=$o(),u=Jt(),m=qo(e,{relative:o});return f.useCallback(d=>{if(d1(d,n)){d.preventDefault();let h=i!==void 0?i:mo(u)===mo(m);c(e,{replace:h,state:s,preventScrollReset:a,relative:o,viewTransition:l})}},[u,c,m,i,s,n,e,a,o,l])}function w1(e,t){t===void 0&&(t={});let n=f.useContext(h1);n==null&&Ie(!1);let{basename:i}=j1(mu.useViewTransitionState),s=qo(e,{relative:t.relative});if(!n.isTransitioning)return!1;let a=ci(n.currentLocation.pathname,i)||n.currentLocation.pathname,o=ci(n.nextLocation.pathname,i)||n.nextLocation.pathname;return du(s.pathname,o)!=null||du(s.pathname,a)!=null}const fm="/assets/br-office-logo-DlHO8fHZ.png",k1={admin:["/","/cases","/deadlines","/meetings","/agenda-items","/decisions","/reports","/finance","/tasks","/calendar","/documents","/comments","/notifications","/users","/committees","/departments","/document-categories","/public-documents-admin","/templates","/permissions","/security","/audit"],chair:["/","/cases","/deadlines","/meetings","/agenda-items","/decisions","/reports","/finance","/tasks","/calendar","/documents","/comments","/notifications","/committees","/departments","/document-categories","/public-documents-admin","/templates"],secretary:["/","/meetings","/agenda-items","/decisions","/reports","/finance","/tasks","/calendar","/documents","/comments","/notifications"],member:["/","/cases","/deadlines","/tasks","/calendar","/documents","/comments","/notifications"],advisor:["/","/calendar","/documents","/comments","/notifications"]};function S1(e){return Array.isArray(e==null?void 0:e.allowed_page_paths)&&e.allowed_page_paths.length?e.allowed_page_paths:k1[e==null?void 0:e.role]||["/"]}function Jg(e,t){return e?e.role==="admin"?!0:S1(e).includes(t):!1}const Pl=[{key:"dashboard",label:"Dashboard",standalone:!0,items:[{label:"Dashboard",to:"/"}]},{key:"work",label:"Arbeit",items:[{label:"Vorgänge",to:"/cases"},{label:"Fristen",to:"/deadlines"},{label:"Sitzungen",to:"/meetings"},{label:"TOP-Bestand",to:"/agenda-items"},{label:"Beschlüsse",to:"/decisions"},{label:"Reports",to:"/reports"},{label:"Finanzen",to:"/finance"},{label:"Aufgaben",to:"/tasks"},{label:"Kalender",to:"/calendar"},{label:"Dokumente",to:"/documents"},{label:"Kommentare",to:"/comments"},{label:"Benachrichtigungen",to:"/notifications"}]},{key:"admin",label:"Administration",items:[{label:"Benutzer",to:"/users"},{label:"Ausschüsse",to:"/committees"},{label:"Abteilungen",to:"/departments"},{label:"Dokumentenkategorien",to:"/document-categories"},{label:"Öffentliche Dokumente",to:"/public-documents-admin"},{label:"Vorlagen / Formular-Editor",to:"/templates"},{label:"Rollen & Rechte",to:"/permissions"},{label:"Kummerkasten",to:"/kummerkasten-admin"},{label:"MwSt.-Sätze Finanzen",to:"/finance-tax-codes"},{label:"Sicherheit",to:"/security"},{label:"Audit",to:"/audit"}]}];function El(e,t){return e.items.some(n=>n.to==="/"?t==="/":t===n.to||t.startsWith(`${n.to}/`))}function N1({user:e,onLogout:t,children:n}){const i=Jt(),[s,a]=f.useState(!1),o=f.useMemo(()=>{const m={};for(const d of Pl)m[d.key]=d.standalone?!0:El(d,i.pathname);return m},[i.pathname]),[l,c]=f.useState(o);f.useEffect(()=>{c(m=>{const d={...m};for(const h of Pl)(h.standalone||El(h,i.pathname))&&(d[h.key]=!0);return d}),a(!1)},[i.pathname]);function u(m){c(d=>({...d,[m]:!d[m]}))}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + background: var(--app-bg, #f6f8fb); + } + + .app-sidebar { + background: var(--panel-bg, #ffffff); + border-right: 1px solid rgba(15, 23, 42, 0.08); + padding: 18px 14px; + display: flex; + flex-direction: column; + gap: 14px; + position: sticky; + top: 0; + height: 100vh; + overflow: auto; + z-index: 30; + } + + .app-brand { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 8px 12px; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + } + + .app-brand-main { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; + } + + .app-brand-logo { + width: 58px; + max-height: 42px; + flex: 0 0 auto; + object-fit: contain; + display: block; + } + + .app-brand-title { + font-weight: 800; + font-size: 1.05rem; + } + + .app-brand-subtitle { + font-size: 0.85rem; + opacity: 0.7; + } + + .sidebar-groups { + display: flex; + flex-direction: column; + gap: 10px; + } + + .sidebar-group { + border: 1px solid rgba(15, 23, 42, 0.07); + border-radius: 14px; + overflow: hidden; + background: rgba(255, 255, 255, 0.8); + } + + .sidebar-group.standalone { + padding: 6px; + } + + .sidebar-group-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 0; + background: transparent; + padding: 12px 14px; + cursor: pointer; + font-weight: 700; + font-size: 0.95rem; + text-align: left; + } + + .sidebar-group-toggle:hover { + background: rgba(15, 23, 42, 0.04); + } + + .sidebar-chevron { + transition: transform 0.2s ease; + font-size: 0.9rem; + opacity: 0.7; + } + + .sidebar-chevron.open { + transform: rotate(90deg); + } + + .sidebar-links { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0 8px 10px; + } + + .sidebar-group.standalone .sidebar-links { + padding: 0; + } + + .sidebar-link { + display: flex; + align-items: center; + min-height: 40px; + padding: 9px 12px; + border-radius: 10px; + text-decoration: none; + color: inherit; + font-weight: 500; + transition: background 0.15s ease, color 0.15s ease; + } + + .sidebar-link:hover { + background: rgba(15, 23, 42, 0.05); + } + + .sidebar-link.active { + background: rgba(37, 99, 235, 0.12); + color: #1d4ed8; + font-weight: 700; + } + + .sidebar-footer { + margin-top: auto; + padding-top: 12px; + border-top: 1px solid rgba(15, 23, 42, 0.08); + display: grid; + gap: 10px; + } + + .user-card { + display: grid; + gap: 2px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.04); + } + + .app-main { + min-width: 0; + display: flex; + flex-direction: column; + } + + .app-topbar { + display: none; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + background: var(--panel-bg, #fff); + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + position: sticky; + top: 0; + z-index: 25; + } + + .app-mobile-brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 800; + } + + .app-mobile-brand-logo { + width: 32px; + max-height: 28px; + object-fit: contain; + display: block; + } + + .app-content { + min-width: 0; + padding: 18px; + } + + .menu-btn, + .sidebar-close-btn { + border: 1px solid rgba(15, 23, 42, 0.12); + background: var(--panel-bg, #fff); + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + font-weight: 700; + } + + .sidebar-close-btn { + display: none; + } + + .sidebar-backdrop { + display: none; + } + + @media (max-width: 980px) { + .app-shell { + grid-template-columns: 1fr; + } + + .app-topbar { + display: flex; + } + + .app-sidebar { + position: fixed; + inset: 0 auto 0 0; + width: min(320px, 86vw); + transform: translateX(-100%); + transition: transform 0.24s ease; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22); + } + + .app-sidebar.open { + transform: translateX(0); + } + + .sidebar-close-btn { + display: inline-flex; + } + + .sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.45); + z-index: 20; + } + + .app-content { + padding: 14px; + } + } + `}),r.jsxs("div",{className:"app-shell",children:[s?r.jsx("div",{className:"sidebar-backdrop",onClick:()=>a(!1)}):null,r.jsxs("aside",{className:`app-sidebar ${s?"open":""}`,children:[r.jsxs("div",{className:"app-brand",children:[r.jsxs("div",{className:"app-brand-main",children:[r.jsx("img",{className:"app-brand-logo",src:fm,alt:"BR-Office Logo"}),r.jsxs("div",{children:[r.jsx("div",{className:"app-brand-title",children:"BR-Office"}),r.jsx("div",{className:"app-brand-subtitle",children:"Navigation"})]})]}),r.jsx("button",{className:"sidebar-close-btn",type:"button",onClick:()=>a(!1),"aria-label":"Menü schließen",children:"×"})]}),r.jsx("nav",{className:"sidebar-groups",children:Pl.map(m=>{const d=m.items.filter(p=>Jg(e,p.to));if(!d.length)return null;const h={...m,items:d},x=m.standalone?!0:!!l[m.key],g=El(h,i.pathname);return r.jsxs("div",{className:`sidebar-group ${m.standalone?"standalone":""} ${g?"active":""}`,children:[m.standalone?null:r.jsxs("button",{className:"sidebar-group-toggle",type:"button",onClick:()=>u(m.key),children:[r.jsx("span",{children:m.label}),r.jsx("span",{className:`sidebar-chevron ${x?"open":""}`,children:"▶"})]}),x?r.jsx("div",{className:"sidebar-links",children:d.map(p=>r.jsx(Ra,{to:p.to,end:p.to==="/",className:({isActive:w})=>`sidebar-link ${w?"active":""}`,children:p.label},p.to))}):null]},m.key)})}),r.jsxs("div",{className:"sidebar-footer",children:[r.jsxs("div",{className:"user-card",children:[r.jsx("strong",{children:(e==null?void 0:e.full_name)||(e==null?void 0:e.email)||"Benutzer"}),r.jsx("span",{children:(e==null?void 0:e.role)||""})]}),r.jsx("button",{className:"btn secondary",type:"button",onClick:t,children:"Abmelden"})]})]}),r.jsxs("main",{className:"app-main",children:[r.jsxs("div",{className:"app-topbar",children:[r.jsx("button",{className:"menu-btn",type:"button",onClick:()=>a(!0),"aria-label":"Menü öffnen",children:"☰ Menü"}),r.jsxs("div",{className:"app-mobile-brand",children:[r.jsx("img",{className:"app-mobile-brand-logo",src:fm,alt:"BR-Office Logo"}),r.jsx("strong",{children:"BR-Office"})]})]}),r.jsx("div",{className:"app-content",children:n})]})]})]})}const _s="http://localhost:8080/api";let sn=typeof window<"u"&&localStorage.getItem("br_access_token")||"",ua=null;typeof window<"u"&&(window.__br_access_token=sn||"");function Vo(e){sn=e||"",typeof window<"u"&&(sn?localStorage.setItem("br_access_token",sn):localStorage.removeItem("br_access_token"),window.__br_access_token=sn)}function ho(){sn="",typeof window<"u"&&(localStorage.removeItem("br_access_token"),localStorage.removeItem("br_user"),window.__br_access_token="")}function C1(){return!sn&&typeof window<"u"&&(sn=localStorage.getItem("br_access_token")||"",window.__br_access_token=sn),sn}function z1(e={}){return!e.body||e.body instanceof FormData?!1:typeof e.body=="string"}function go(e={},t=null){const n={...z1(e)?{"Content-Type":"application/json"}:{},...e.headers||{}},i=t??C1();return i&&(n.Authorization=`Bearer ${i}`),n}async function B1(){const e=await fetch(`${_s}/auth/refresh`,{method:"POST",credentials:"include"});if(!e.ok)return ho(),null;const t=await e.json().catch(()=>({})),n=(t==null?void 0:t.accessToken)||"";return n?(Vo(n),typeof window<"u"&&(t!=null&&t.user)&&localStorage.setItem("br_user",JSON.stringify(t.user)),n):(ho(),null)}async function ex(){return ua||(ua=B1().finally(()=>{ua=null})),ua}async function tx(e){return e.json().catch(()=>({}))}async function da(e,t={}){let n=await fetch(`${_s}${e}`,{...t,headers:go(t),credentials:"include"});if(n.status===401&&!e.includes("/auth/refresh")){const s=await ex();s&&(n=await fetch(`${_s}${e}`,{...t,headers:go(t,s),credentials:"include"}))}const i=await tx(n);if(!n.ok){n.status===401&&ho();const s=new Error(i.error||"API-Fehler");throw s.response=i,s.status=n.status,s}return i}async function Uo(e,t={}){let n=await fetch(`${_s}${e}`,{...t,headers:go(t),credentials:"include"});if(n.status===401&&!e.includes("/auth/refresh")){const i=await ex();i&&(n=await fetch(`${_s}${e}`,{...t,headers:go(t,i),credentials:"include"}))}if(!n.ok){const i=await tx(n);n.status===401&&ho();const s=new Error(i.error||"Datei konnte nicht geladen werden");throw s.response=i,s.status=n.status,s}return n}async function ji(e,t="download"){const i=await(await Uo(e)).blob(),s=window.URL.createObjectURL(i),a=document.createElement("a");a.href=s,a.download=t,document.body.appendChild(a),a.click(),a.remove(),window.setTimeout(()=>window.URL.revokeObjectURL(s),1e3)}async function _i(e){const n=await(await Uo(e)).blob(),i=window.URL.createObjectURL(n);window.open(i,"_blank","noopener,noreferrer"),window.setTimeout(()=>window.URL.revokeObjectURL(i),6e4)}async function T1(e){const n=await(await Uo(e)).blob(),i=window.URL.createObjectURL(n),s=document.createElement("iframe");s.style.position="fixed",s.style.right="0",s.style.bottom="0",s.style.width="0",s.style.height="0",s.style.border="0",s.src=i,s.onload=()=>{window.setTimeout(()=>{var a,o;try{(a=s.contentWindow)==null||a.focus(),(o=s.contentWindow)==null||o.print()}catch{window.open(i,"_blank","noopener,noreferrer")}},250),window.setTimeout(()=>{window.URL.revokeObjectURL(i),s.remove()},6e4)},document.body.appendChild(s)}const O={get:e=>da(e),post:(e,t)=>da(e,{method:"POST",body:t instanceof FormData?t:JSON.stringify(t)}),put:(e,t)=>da(e,{method:"PUT",body:t instanceof FormData?t:JSON.stringify(t)}),delete:e=>da(e,{method:"DELETE"})};function P1(){return{email:"",password:""}}function mm(){return{totp_code:""}}function fa(e){if(!e||typeof e!="string")return e;const t="=".repeat((4-e.length%4)%4),n=(e+t).replace(/-/g,"+").replace(/_/g,"/"),i=window.atob(n),s=new Uint8Array(i.length);for(let a=0;a({...i,id:fa(i.id)}))),Array.isArray(t.excludeCredentials)&&(t.excludeCredentials=t.excludeCredentials.map(i=>({...i,id:fa(i.id)}))),(n=t.user)!=null&&n.id&&(t.user={...t.user,id:fa(t.user.id)}),t}function Oi(e){if(!e)return"";const t=e instanceof Uint8Array?e:new Uint8Array(e);let n="";for(let i=0;itypeof window<"u"&&!!window.PublicKeyCredential&&!!navigator.credentials,[]);function j(P){if(!(P!=null&&P.accessToken)||!(P!=null&&P.user))throw new Error("Login-Antwort unvollständig");Vo(P.accessToken),localStorage.setItem("br_user",JSON.stringify(P.user)),g(""),w(""),e==null||e(P.user)}async function k(P){var D,F,T,M;P.preventDefault(),v(!0),w(""),g("");try{const z=await O.post("/auth/login",{email:t.email.trim(),password:t.password});if(z!=null&&z.mfaRequired){c(z.loginToken||""),m(z.userHint||null),h({totp:!!((D=z==null?void 0:z.availableSecondFactors)!=null&&D.totp),webauthn:!!((F=z==null?void 0:z.availableSecondFactors)!=null&&F.webauthn)}),(T=z==null?void 0:z.availableSecondFactors)!=null&&T.totp?(o("totp"),g("Bitte zweiten Faktor mit TOTP bestätigen.")):(M=z==null?void 0:z.availableSecondFactors)!=null&&M.webauthn?(o("webauthn"),g("Bitte Anmeldung mit Sicherheitsschlüssel oder Passkey bestätigen.")):(o("mfa-choice"),g("Zweiter Faktor erforderlich."));return}j(z)}catch(z){w(z.message||"Login fehlgeschlagen.")}finally{v(!1)}}async function N(P){P.preventDefault(),v(!0),w(""),g("");try{const D=await O.post("/auth/login/totp",{login_token:l,totp_code:i.totp_code.trim()});j(D)}catch(D){w(D.message||"TOTP-Anmeldung fehlgeschlagen.")}finally{v(!1)}}async function _(){v(!0),w(""),g("");try{if(!y)throw new Error("WebAuthn wird in diesem Browser nicht unterstützt.");const P=await O.post("/auth/login/webauthn/options",{login_token:l}),D=await navigator.credentials.get({publicKey:E1(P)});if(!D)throw new Error("WebAuthn-Anmeldung wurde abgebrochen.");const F=await O.post("/auth/login/webauthn/verify",{login_token:l,credential:D1(D)});j(F)}catch(P){w(P.message||"WebAuthn-Anmeldung fehlgeschlagen.")}finally{v(!1)}}function S(){o("password"),c(""),s(mm()),h({totp:!1,webauthn:!1}),m(null),g(""),w("")}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .login-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: + radial-gradient(circle at top left, rgba(37, 99, 235, 0.10), transparent 28%), + radial-gradient(circle at bottom right, rgba(220, 38, 38, 0.08), transparent 24%), + linear-gradient(180deg, #f4f7fb 0%, #eef3f8 100%); + } + + .login-card { + width: min(540px, 100%); + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 24px; + box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12); + padding: 24px; + backdrop-filter: blur(10px); + } + + .login-header { + margin-bottom: 18px; + } + + .login-header h1 { + margin: 0; + font-size: 1.8rem; + } + + .login-header p { + margin: 8px 0 0; + color: #64748b; + } + + .login-meta { + margin-bottom: 14px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.04); + color: #334155; + } + + .login-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 8px; + flex-wrap: wrap; + } + + .login-choice-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 12px; + } + `}),r.jsx("div",{className:"login-shell",children:r.jsxs("div",{className:"login-card",children:[r.jsxs("div",{className:"login-header",children:[r.jsx("h1",{children:"BR-Office"}),r.jsx("p",{children:a==="password"?"Bitte anmelden.":"Bitte zweiten Faktor bestätigen."})]}),u?r.jsxs("div",{className:"login-meta",children:[r.jsx("strong",{children:u.full_name||u.email}),r.jsx("div",{children:u.email}),r.jsx("div",{children:u.role})]}):null,p?r.jsx("div",{className:"alert error",children:p}):null,x?r.jsx("div",{className:"alert success",children:x}):null,a==="password"?r.jsxs("form",{className:"form-grid",onSubmit:k,children:[r.jsxs("label",{children:[r.jsx("span",{children:"E-Mail"}),r.jsx("input",{className:"input",type:"email",autoComplete:"username",value:t.email,onChange:P=>n(D=>({...D,email:P.target.value}))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Passwort"}),r.jsx("input",{className:"input",type:"password",autoComplete:"current-password",value:t.password,onChange:P=>n(D=>({...D,password:P.target.value}))})]}),r.jsx("div",{className:"login-actions",children:r.jsx("button",{className:"btn",type:"submit",disabled:b,children:b?"Anmelden...":"Anmelden"})})]}):null,a==="mfa-choice"?r.jsxs("div",{children:[r.jsx("p",{className:"field-hint",children:"Es ist ein zweiter Faktor erforderlich. Bitte eine Methode auswählen."}),r.jsxs("div",{className:"login-choice-actions",children:[d.totp?r.jsx("button",{className:"btn",type:"button",onClick:()=>{o("totp"),w(""),g("")},disabled:b,children:"TOTP verwenden"}):null,d.webauthn?r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>{o("webauthn"),w(""),g("")},disabled:b,children:"WebAuthn verwenden"}):null,r.jsx("button",{className:"btn secondary",type:"button",onClick:S,disabled:b,children:"Zurück"})]})]}):null,a==="totp"?r.jsxs("form",{className:"form-grid",onSubmit:N,children:[r.jsxs("label",{children:[r.jsx("span",{children:"TOTP-Code"}),r.jsx("input",{className:"input",inputMode:"numeric",autoComplete:"one-time-code",placeholder:"123456",value:i.totp_code,onChange:P=>s(D=>({...D,totp_code:P.target.value}))})]}),r.jsxs("div",{className:"login-actions",children:[d.webauthn?r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>{o("webauthn"),w(""),g("")},disabled:b,children:"WebAuthn stattdessen"}):null,r.jsx("button",{className:"btn secondary",type:"button",onClick:S,disabled:b,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",disabled:b,children:b?"Prüfen...":"Bestätigen"})]})]}):null,a==="webauthn"?r.jsxs("div",{children:[r.jsx("p",{className:"field-hint",children:"Mit Sicherheitsschlüssel, Windows Hello, Touch ID oder Passkey anmelden."}),r.jsxs("div",{className:"login-actions",children:[d.totp?r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>{o("totp"),w(""),g("")},disabled:b,children:"TOTP stattdessen"}):null,r.jsx("button",{className:"btn secondary",type:"button",onClick:S,disabled:b,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"button",onClick:_,disabled:b,children:b?"Anmeldung läuft...":"WebAuthn starten"})]})]}):null]})})]})}function de({title:e,subtitle:t,children:n,actions:i}){return r.jsxs("section",{className:"section-card glass",children:[r.jsxs("header",{className:"section-header",children:[r.jsxs("div",{children:[r.jsx("h2",{children:e}),t?r.jsx("p",{children:t}):null]}),r.jsx("div",{className:"section-actions",children:i})]}),n]})}function pm(e){if(!e)return"—";const t=new Date(e);return Number.isNaN(t.getTime())?String(e):t.toLocaleDateString("de-DE")}function R1(e){if(!e)return"—";const t=new Date(e);return Number.isNaN(t.getTime())?String(e):t.toLocaleString("de-DE",{day:"2-digit",month:"2-digit",year:"numeric",hour:"2-digit",minute:"2-digit"})}function O1(e){return Array.isArray(e)?e:Array.isArray(e==null?void 0:e.items)?e.items:[]}function hm(e){const t=String(e||"").toLowerCase();return t==="geplant"?"Geplant":t==="gestartet"?"Gestartet":t==="beendet"?"Beendet":t==="ungeplant"?"Ungeplant":t==="neu"?"Neu":t==="in_bearbeitung"?"In Bearbeitung":t==="rueckfrage"?"Rückfrage":t==="abgeschlossen"?"Abgeschlossen":e||"—"}function I1(){const[e,t]=f.useState(null),[n,i]=f.useState([]),[s,a]=f.useState([]),[o,l]=f.useState([]),[c,u]=f.useState("");f.useEffect(()=>{let h=!0;async function x(){u("");try{const[g,p,w,b]=await Promise.all([O.get("/dashboard"),O.get("/meetings?show_ended=false&page=1&page_size=5"),O.get("/cases"),O.get("/notifications")]);if(!h)return;const v=O1(p).slice().sort((k,N)=>String(k.meeting_date||"").localeCompare(String(N.meeting_date||""))).slice(0,5),y=(Array.isArray(w)?w:[]).filter(k=>String(k.status||"")!=="abgeschlossen").slice(0,5),j=(Array.isArray(b)?b:[]).slice(0,5);t(g||null),i(v),a(y),l(j)}catch(g){if(!h)return;u(g.message||"Dashboard konnte nicht geladen werden.")}}return x(),()=>{h=!1}},[]);const m=f.useMemo(()=>n[0]||null,[n]),d=[{title:"Meine Vorgänge",value:(e==null?void 0:e.openCases)??"—",hint:e!=null&&e.openCases?`${e.openCases} offen`:"Keine Daten",tone:"blue"},{title:"Nächste Sitzung",value:m!=null&&m.meeting_date?pm(m.meeting_date):"—",hint:(m==null?void 0:m.title)||"Keine Sitzung geplant",tone:"green"},{title:"Dringende Fristen",value:(e==null?void 0:e.urgentDeadlines)??"—",hint:e!=null&&e.urgentDeadlines?"innerhalb 3 Tagen":"Keine Daten",tone:"purple"},{title:"Dokumente",value:(e==null?void 0:e.documents)??"—",hint:e!=null&&e.unreadNotifications?`${e.unreadNotifications} neue Hinweise`:"Dokumentenbestand",tone:"amber"}];return r.jsxs("div",{className:"stack dashboard-stack",children:[r.jsxs(de,{title:"Dashboard",subtitle:"Startseite mit den wichtigsten Kennzahlen, Sitzungen, Vorgängen und Benachrichtigungen.",children:[c?r.jsx("div",{className:"alert error",children:c}):null,r.jsx("div",{className:"dashboard-kpi-grid",children:d.map(h=>r.jsxs("div",{className:`dashboard-kpi-card tone-${h.tone}`,children:[r.jsx("div",{className:"dashboard-kpi-icon","aria-hidden":"true"}),r.jsxs("div",{className:"dashboard-kpi-content",children:[r.jsx("span",{children:h.title}),r.jsx("strong",{children:h.value}),r.jsx("small",{children:h.hint})]})]},h.title))})]}),r.jsxs("div",{className:"dashboard-two-col",children:[r.jsx(de,{title:"Meine nächsten Sitzungen",subtitle:"Kommende Termine im Überblick",actions:r.jsx(Ra,{className:"btn secondary",to:"/meetings",children:"Alle Sitzungen anzeigen"}),children:r.jsx("div",{className:"dashboard-list",children:n.length?n.map(h=>r.jsxs("div",{className:"dashboard-list-item",children:[r.jsxs("div",{className:"dashboard-date-box",children:[r.jsx("strong",{children:pm(h.meeting_date).slice(0,5)}),r.jsx("span",{children:String(h.meeting_date||"").slice(0,4)})]}),r.jsxs("div",{className:"dashboard-list-main",children:[r.jsx("strong",{children:h.title||h.meeting_number||`Sitzung ${h.id}`}),r.jsxs("div",{className:"field-hint",children:[h.meeting_number?`${h.meeting_number} · `:"",h.location||"Ort offen"]})]}),r.jsx("span",{className:`status-chip status-${String(h.status||"").toLowerCase()}`,children:hm(h.status)})]},h.id)):r.jsx("div",{className:"field-hint",children:"Keine kommenden Sitzungen gefunden."})})}),r.jsx(de,{title:"Meine offenen Vorgänge",subtitle:"Nicht abgeschlossene Vorgänge",actions:r.jsx(Ra,{className:"btn secondary",to:"/cases",children:"Alle Vorgänge anzeigen"}),children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table dashboard-table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Vorgang"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Priorität"}),r.jsx("th",{children:"Ausschuss"})]})}),r.jsx("tbody",{children:s.length?s.map(h=>r.jsxs("tr",{children:[r.jsxs("td",{children:[r.jsx("strong",{children:h.case_number||`V-${h.id}`}),r.jsx("div",{className:"field-hint",children:h.title||"Ohne Titel"})]}),r.jsx("td",{children:hm(h.status)}),r.jsx("td",{children:h.priority||"—"}),r.jsx("td",{children:h.committee_name||h.department||"—"})]},h.id)):r.jsx("tr",{children:r.jsx("td",{colSpan:4,children:"Keine offenen Vorgänge gefunden."})})})]})})})]}),r.jsx(de,{title:"Aktuelle Benachrichtigungen",subtitle:"Neueste Hinweise aus dem System",actions:r.jsx(Ra,{className:"btn secondary",to:"/notifications",children:"Alle Benachrichtigungen anzeigen"}),children:r.jsx("div",{className:"dashboard-notification-list",children:o.length?o.map(h=>r.jsxs("div",{className:"dashboard-notification-row",children:[r.jsxs("div",{children:[r.jsx("strong",{children:h.title||h.notification_type||"Hinweis"}),r.jsxs("div",{className:"field-hint",children:[h.entity_type?`${h.entity_type} · `:"",R1(h.created_at)]})]}),r.jsx("span",{className:`status-chip ${h.is_read?"status-neutral":"status-geplant"}`,children:h.is_read?"Gelesen":"Neu"})]},h.id)):r.jsx("div",{className:"field-hint",children:"Keine Benachrichtigungen vorhanden."})})})]})}function nx({title:e,rows:t,columns:n,onPick:i,onClose:s}){const[a,o]=f.useState(""),l=f.useMemo(()=>{const c=a.trim().toLowerCase();return c?t.filter(u=>JSON.stringify(u).toLowerCase().includes(c)):t},[t,a]);return r.jsx("div",{style:{position:"fixed",inset:0,background:"rgba(15, 23, 42, 0.55)",display:"grid",placeItems:"center",zIndex:999},children:r.jsxs("div",{className:"glass",style:{width:"min(960px, 92vw)",maxHeight:"85vh",overflow:"auto",padding:18,borderRadius:20},children:[r.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:12,marginBottom:14},children:[r.jsxs("div",{children:[r.jsx("h3",{style:{margin:0},children:e}),r.jsx("p",{style:{margin:"6px 0 0 0",opacity:.75},children:"Eintrag auswählen und die ID automatisch übernehmen."})]}),r.jsx("button",{className:"btn secondary",type:"button",onClick:s,children:"Schließen"})]}),r.jsx("input",{className:"input search",placeholder:"Suchen...",value:a,onChange:c=>o(c.target.value),style:{marginBottom:12}}),r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[n.map(c=>r.jsx("th",{children:c.label},c.key)),r.jsx("th",{children:"Aktion"})]})}),r.jsx("tbody",{children:l.map(c=>r.jsxs("tr",{children:[n.map(u=>r.jsx("td",{children:String(c[u.key]??"")},u.key)),r.jsx("td",{children:r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>i(c),children:"Übernehmen"})})]},c.id))})]})})]})})}function Dl(){return{name:"",committee_type:"general",default_user_id:"",chair_user_id:"",secretary_user_id:""}}function gm(e){return{name:e.name??"",committee_type:e.committee_type??"general",default_user_id:e.default_user_id?String(e.default_user_id):"",chair_user_id:e.chair_user_id?String(e.chair_user_id):"",secretary_user_id:e.secretary_user_id?String(e.secretary_user_id):""}}function L1({open:e,selected:t,form:n,setForm:i,users:s,members:a,membersLoading:o,lookup:l,setLookup:c,error:u,onClose:m,onSubmit:d,onAssignMember:h,onRemoveMember:x}){if(!e)return null;function g(v){return s.find(y=>String(y.id)===String(v))}function p(v,y){i(j=>({...j,[v]:String(y.id)})),c(null)}const w=new Set(a.map(v=>String(v.user_id??v.id))),b=s.filter(v=>!w.has(String(v.id)));return r.jsxs(r.Fragment,{children:[r.jsx("div",{className:"modal-backdrop",onClick:v=>{l||v.target===v.currentTarget&&m()},children:r.jsxs("div",{className:"modal-panel",onClick:v=>v.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:t?"Ausschuss bearbeiten":"Neuen Ausschuss anlegen"}),r.jsx("p",{className:"modal-subtitle",children:"Leitung, Schriftführung und Standard-User werden direkt im Ausschuss gepflegt. Benutzer können mehreren Ausschüssen zugeordnet sein."})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:m,"aria-label":"Schließen",children:"×"})]}),u?r.jsx("div",{className:"alert error",children:u}):null,r.jsxs("form",{className:"form-grid",onSubmit:d,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Name"}),r.jsx("input",{className:"input",value:n.name,onChange:v=>i(y=>({...y,name:v.target.value}))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Typ"}),r.jsx("input",{className:"input",value:n.committee_type,onChange:v=>i(y=>({...y,committee_type:v.target.value}))})]}),[["chair_user_id","Leitung User-ID"],["secretary_user_id","Schriftführung User-ID"],["default_user_id","Standard-User-ID"]].map(([v,y])=>{const j=g(n[v]);return r.jsxs("label",{children:[r.jsx("span",{children:y}),r.jsxs("div",{style:{display:"flex",gap:8},children:[r.jsx("input",{className:"input",value:n[v],readOnly:!0}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>c(v),children:"Auswählen"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>i(k=>({...k,[v]:""})),children:"Leeren"})]}),r.jsx("small",{children:j?`${j.full_name} (${j.email})`:"Kein Benutzer ausgewählt"})]},v)}),r.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:10,gridColumn:"1 / -1"},children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:m,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",children:t?"Speichern":"Erstellen"})]})]}),r.jsx("div",{style:{marginTop:18},children:r.jsx(de,{title:"Benutzerzuordnung",subtitle:t?"Benutzer können diesem Ausschuss zusätzlich als Mitglieder zugeordnet werden.":"Benutzerzuordnung ist nach dem ersten Speichern des Ausschusses möglich.",actions:t?r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>c("assign_committee_member"),children:"Benutzer hinzufügen"}):null,children:t?r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Name"}),r.jsx("th",{children:"E-Mail"}),r.jsx("th",{children:"Rolle"}),r.jsx("th",{children:"Aktiv"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsx("tbody",{children:o?r.jsx("tr",{children:r.jsx("td",{colSpan:6,children:"Benutzer werden geladen …"})}):a.length?a.map(v=>r.jsxs("tr",{children:[r.jsx("td",{children:v.user_id??v.id}),r.jsx("td",{children:v.full_name}),r.jsx("td",{children:v.email}),r.jsx("td",{children:v.role}),r.jsx("td",{children:String(v.is_active!==!1)}),r.jsx("td",{className:"row-actions",children:r.jsx("button",{className:"mini-btn danger",type:"button",onClick:()=>x(v.user_id??v.id),children:"Entfernen"})})]},`${v.user_id??v.id}`)):r.jsx("tr",{children:r.jsx("td",{colSpan:6,children:"Keine Benutzer zugeordnet"})})})]})}):r.jsx("div",{className:"helper-text",children:"Ausschuss zuerst speichern, danach können Mitglieder zugeordnet werden."})})})]})}),l?r.jsx("div",{style:{position:"fixed",inset:0,zIndex:1100},children:r.jsx(nx,{title:l==="assign_committee_member"?"Benutzer dem Ausschuss zuordnen":"Benutzer auswählen",rows:l==="assign_committee_member"?b:s,columns:[{key:"id",label:"ID"},{key:"full_name",label:"Name"},{key:"email",label:"E-Mail"},{key:"role",label:"Rolle"}],onPick:v=>{if(l==="assign_committee_member"){h(v.id),c(null);return}p(l,v)},onClose:()=>c(null)})}):null]})}function M1(){const[e,t]=f.useState([]),[n,i]=f.useState([]),[s,a]=f.useState([]),[o,l]=f.useState(!1),[c,u]=f.useState(null),[m,d]=f.useState(Dl()),[h,x]=f.useState(""),[g,p]=f.useState(""),[w,b]=f.useState(""),[v,y]=f.useState(""),[j,k]=f.useState(null),[N,_]=f.useState(!1);async function S(){b("");try{const[R,K]=await Promise.all([O.get("/committees"),O.get("/users")]);t(Array.isArray(R)?R:[]),i(Array.isArray(K)?K.filter(X=>X.is_active!==!1):[])}catch(R){b(R.message||"Ausschüsse konnten nicht geladen werden.")}}async function P(R){if(!R){a([]);return}l(!0);try{const K=await O.get(`/committees/${R}/members`);a(Array.isArray(K)?K:[])}catch(K){y(K.message||"Mitglieder konnten nicht geladen werden."),a([])}finally{l(!1)}}f.useEffect(()=>{S()},[]);const D=f.useMemo(()=>{const R=h.trim().toLowerCase();return R?e.filter(K=>JSON.stringify(K).toLowerCase().includes(R)):e},[e,h]);function F(){u(null),d(Dl()),a([]),l(!1),p(""),b(""),y(""),k(null),_(!0)}async function T(R){u(R),d(gm(R)),a([]),l(!1),p(""),b(""),y(""),k(null),_(!0),await P(R.id)}function M(){u(null),d(Dl()),a([]),l(!1),y(""),k(null),_(!1)}async function z(R){R.preventDefault(),y(""),p("");const K={name:m.name.trim(),committee_type:m.committee_type.trim(),default_user_id:m.default_user_id?Number(m.default_user_id):null,chair_user_id:m.chair_user_id?Number(m.chair_user_id):null,secretary_user_id:m.secretary_user_id?Number(m.secretary_user_id):null};try{if(c!=null&&c.id)await O.put(`/committees/${c.id}`,K),await S(),await P(c.id),p("Ausschuss gespeichert");else{const X=await O.post("/committees",K);await S(),p("Ausschuss erstellt"),X!=null&&X.id?(u(X),d(gm(X)),await P(X.id)):M()}}catch(X){y(X.message||"Ausschuss konnte nicht gespeichert werden.")}}async function A(R){b(""),p("");try{await O.delete(`/committees/${R}`),(c==null?void 0:c.id)===R&&M(),await S(),p("Ausschuss gelöscht")}catch(K){b(K.message||"Ausschuss konnte nicht gelöscht werden.")}}async function $(R){if(c!=null&&c.id){y("");try{await O.post(`/committees/${c.id}/members`,{user_id:Number(R)}),await P(c.id),p("Benutzer dem Ausschuss zugeordnet")}catch(K){y(K.message||"Benutzer konnte nicht zugeordnet werden.")}}}async function Z(R){if(c!=null&&c.id){y("");try{await O.delete(`/committees/${c.id}/members/${R}`),await P(c.id),p("Benutzer aus Ausschuss entfernt")}catch(K){y(K.message||"Benutzer konnte nicht entfernt werden.")}}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .committees-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; + } + + .modal-panel { + width: min(960px, 100%); + max-height: 92vh; + overflow: auto; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + padding: 18px; + } + + .modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .modal-subtitle { + margin: 6px 0 0; + opacity: 0.75; + font-size: 0.95rem; + } + + .modal-close { + border: 0; + background: transparent; + font-size: 28px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + } + `}),r.jsxs(de,{title:"Ausschüsse",subtitle:"Leitung, Schriftführung und Standard-User werden einmal pro Ausschuss gepflegt. Mitglieder können mehreren Ausschüssen zugeordnet sein.",actions:r.jsxs("div",{className:"committees-toolbar",children:[r.jsx("input",{className:"input search",placeholder:"Suchen...",value:h,onChange:R=>x(R.target.value)}),r.jsx("button",{className:"btn",type:"button",onClick:F,children:"Neuer Ausschuss"})]}),children:[w?r.jsx("div",{className:"alert error",children:w}):null,g?r.jsx("div",{className:"alert success",children:g}):null,r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Name"}),r.jsx("th",{children:"Typ"}),r.jsx("th",{children:"Leitung"}),r.jsx("th",{children:"Schriftführung"}),r.jsx("th",{children:"Standard-User"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[D.map(R=>r.jsxs("tr",{onClick:()=>T(R),children:[r.jsx("td",{children:R.id}),r.jsx("td",{children:R.name}),r.jsx("td",{children:R.committee_type}),r.jsx("td",{children:R.chair_user_name||R.chair_user_id||""}),r.jsx("td",{children:R.secretary_user_name||R.secretary_user_id||""}),r.jsx("td",{children:R.default_user_name||R.default_user_id||""}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:K=>{K.stopPropagation(),T(R)},children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:K=>{K.stopPropagation(),A(R.id)},children:"Löschen"})]})]},R.id)),D.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:7,children:"Keine Ausschüsse gefunden"})})]})]})})]}),r.jsx(L1,{open:N,selected:c,form:m,setForm:d,users:n,members:s,membersLoading:o,lookup:j,setLookup:k,error:v,onClose:M,onSubmit:z,onAssignMember:$,onRemoveMember:Z})]})}function Al(){return{full_name:"",email:"",role:"member",committee_id:"",is_active:!0,security_classification:"standard",force_password_reset:!1}}function F1(e){return{full_name:e.full_name??"",email:e.email??"",role:e.role??"member",committee_id:e.committee_id?String(e.committee_id):"",is_active:!!e.is_active,security_classification:e.security_classification??"standard",force_password_reset:!!e.force_password_reset}}function $1({open:e,selected:t,form:n,setForm:i,committees:s,error:a,onClose:o,onSubmit:l}){return e?r.jsx("div",{className:"modal-backdrop",onClick:o,children:r.jsxs("div",{className:"modal-panel",onClick:c=>c.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:t?"Benutzer bearbeiten":"Neuen Benutzer anlegen"}),r.jsx("p",{className:"modal-subtitle",children:"Ausschuss auswählen, die Ausschuss-ID wird im Datensatz gespeichert."})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:o,"aria-label":"Schließen",children:"×"})]}),a?r.jsx("div",{className:"alert error",children:a}):null,r.jsxs("form",{className:"form-grid",onSubmit:l,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Name"}),r.jsx("input",{className:"input",value:n.full_name,onChange:c=>i({...n,full_name:c.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"E-Mail"}),r.jsx("input",{className:"input",type:"email",value:n.email,onChange:c=>i({...n,email:c.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Rolle"}),r.jsxs("select",{className:"input",value:n.role,onChange:c=>i({...n,role:c.target.value}),children:[r.jsx("option",{value:"admin",children:"admin"}),r.jsx("option",{value:"chair",children:"chair"}),r.jsx("option",{value:"secretary",children:"secretary"}),r.jsx("option",{value:"member",children:"member"}),r.jsx("option",{value:"advisor",children:"advisor"})]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Ausschuss"}),r.jsxs("select",{className:"input",value:n.committee_id,onChange:c=>i({...n,committee_id:c.target.value}),children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),s.map(c=>r.jsx("option",{value:c.id,children:c.name},c.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Sicherheitsklasse"}),r.jsx("input",{className:"input",value:n.security_classification,onChange:c=>i({...n,security_classification:c.target.value})})]}),r.jsxs("label",{style:{display:"flex",gap:10,alignItems:"center",marginTop:26},children:[r.jsx("input",{type:"checkbox",checked:!!n.is_active,onChange:c=>i({...n,is_active:c.target.checked})}),r.jsx("span",{children:"Aktiv"})]}),r.jsxs("label",{style:{display:"flex",gap:10,alignItems:"center",gridColumn:"1 / -1"},children:[r.jsx("input",{type:"checkbox",checked:!!n.force_password_reset,onChange:c=>i({...n,force_password_reset:c.target.checked})}),r.jsx("span",{children:"Passwort-Reset erzwingen"})]}),r.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:10,gridColumn:"1 / -1"},children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:o,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",children:t?"Speichern":"Erstellen"})]})]})]})}):null}function q1(){const[e,t]=f.useState([]),[n,i]=f.useState([]),[s,a]=f.useState(null),[o,l]=f.useState(Al()),[c,u]=f.useState(""),[m,d]=f.useState(""),[h,x]=f.useState(""),[g,p]=f.useState(""),[w,b]=f.useState(!1);async function v(){x("");try{const[D,F]=await Promise.all([O.get("/users"),O.get("/committees")]);t(Array.isArray(D)?D:[]),i(Array.isArray(F)?F:[])}catch(D){x(D.message)}}f.useEffect(()=>{v()},[]);const y=f.useMemo(()=>{const D=c.trim().toLowerCase();return D?e.filter(F=>JSON.stringify(F).toLowerCase().includes(D)):e},[e,c]);function j(D){var F;return((F=n.find(T=>String(T.id)===String(D)))==null?void 0:F.name)||""}function k(){a(null),l(Al()),p(""),d(""),x(""),b(!0)}function N(D){a(D),l(F1(D)),p(""),d(""),x(""),b(!0)}function _(){a(null),l(Al()),p(""),b(!1)}async function S(D){D.preventDefault(),p(""),d("");const F={full_name:o.full_name.trim(),email:o.email.trim(),role:o.role,committee_id:o.committee_id?Number(o.committee_id):null,is_active:!!o.is_active,security_classification:o.security_classification.trim(),force_password_reset:!!o.force_password_reset};try{const T=s!=null&&s.id?"Benutzer gespeichert":"Benutzer erstellt";s!=null&&s.id?await O.put(`/users/${s.id}`,F):await O.post("/users",F),await v(),_(),d(T)}catch(T){p(T.message)}}async function P(D){x(""),d("");try{await O.delete(`/users/${D}`),(s==null?void 0:s.id)===D&&_(),await v(),d("Benutzer deaktiviert")}catch(F){x(F.message)}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .users-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; + } + + .modal-panel { + width: min(900px, 100%); + max-height: 92vh; + overflow: auto; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + padding: 18px; + } + + .modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .modal-subtitle { + margin: 6px 0 0; + opacity: 0.75; + font-size: 0.95rem; + } + + .modal-close { + border: 0; + background: transparent; + font-size: 28px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + } + `}),r.jsxs(de,{title:"Benutzer",subtitle:"Ausschuss wird per Auswahl übernommen, die interne ID bleibt dabei eindeutig erhalten.",actions:r.jsxs("div",{className:"users-toolbar",children:[r.jsx("input",{className:"input search",placeholder:"Suchen...",value:c,onChange:D=>u(D.target.value)}),r.jsx("button",{className:"btn",type:"button",onClick:k,children:"Neuer Benutzer"})]}),children:[h?r.jsx("div",{className:"alert error",children:h}):null,m?r.jsx("div",{className:"alert success",children:m}):null,r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Name"}),r.jsx("th",{children:"E-Mail"}),r.jsx("th",{children:"Rolle"}),r.jsx("th",{children:"Ausschuss"}),r.jsx("th",{children:"Aktiv"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[y.map(D=>r.jsxs("tr",{onClick:()=>N(D),children:[r.jsx("td",{children:D.id}),r.jsx("td",{children:D.full_name}),r.jsx("td",{children:D.email}),r.jsx("td",{children:D.role}),r.jsx("td",{children:j(D.committee_id)}),r.jsx("td",{children:String(!!D.is_active)}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:F=>{F.stopPropagation(),N(D)},children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:F=>{F.stopPropagation(),P(D.id)},children:"Deaktivieren"})]})]},D.id)),y.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:7,children:"Keine Benutzer gefunden"})})]})]})})]}),r.jsx($1,{open:w,selected:s,form:o,setForm:l,committees:n,error:g,onClose:_,onSubmit:S})]})}const xm=[{value:"sehr_hoch",label:"Sehr hoch"},{value:"hoch",label:"Hoch"},{value:"mittel",label:"Mittel"},{value:"niedrig",label:"Niedrig"}],V1=[{value:"intern",label:"Intern"},{value:"vertraulich",label:"Vertraulich"},{value:"streng_vertraulich",label:"Streng vertraulich"}],U1=["neu","in_bearbeitung","rueckfrage","abgeschlossen"],H1=[{value:"",label:"Bitte auswählen"},{value:"it",label:"IT"},{value:"personal",label:"Personal"},{value:"fertigung",label:"Fertigung"},{value:"qualitaet",label:"Qualität"},{value:"logistik",label:"Logistik"},{value:"einkauf",label:"Einkauf"},{value:"verwaltung",label:"Verwaltung"},{value:"sonstiges",label:"Sonstiges"}];function Rl(e){if(e==null)return null;const t=String(e).trim();return t||null}function K1(){const e=localStorage.getItem("br_user");return e?JSON.parse(e):null}function W1(e){if(!e||e.is_closed===!0||e.closed===!0||e.closed_at)return!1;const t=String(e.status??"").trim().toLowerCase();return!["geschlossen","closed","archiviert","archived","beendet"].includes(t)}function vm(e){const t=Number(e||0);return t?t<1024?`${t} B`:t<1024*1024?`${Math.round(t/1024)} KB`:`${(t/(1024*1024)).toFixed(1)} MB`:"0 KB"}function G1(e){if(!e)return"";const t=new Date(e);return Number.isNaN(t.getTime())?"":t.toLocaleString("de-DE")}function Ol(e){return{case_number:"",title:"",case_type_id:"",status:"neu",priority:"mittel",confidentiality_level:"intern",summary:"",affected_employee_name:"",department:"",committee_id:"",owner_user_id:e?String(e):"",legal_hold:!1,attachmentFiles:[],team_role_ids:[]}}function X1(e){return{case_number:e.case_number??"",title:e.title??"",case_type_id:e.case_type_id?String(e.case_type_id):"",status:e.status??"neu",priority:e.priority??"mittel",confidentiality_level:e.confidentiality_level??"intern",summary:e.summary??"",affected_employee_name:e.affected_employee_name??"",department:e.department??"",committee_id:e.committee_id?String(e.committee_id):"",owner_user_id:e.owner_user_id?String(e.owner_user_id):"",legal_hold:!!e.legal_hold,attachmentFiles:[],team_role_ids:Array.isArray(e.team_role_ids)?e.team_role_ids.map(String):[]}}function Q1(e){return{id:e.id,title:e.title||e.file_name||e.original_filename||"Datei",file_name:e.file_name||e.original_filename||e.title||"Datei",size_bytes:e.size_bytes||0,mime_type:e.mime_type||"",uploaded_at:e.uploaded_at||e.created_at||null,uploaded_by_name:e.uploaded_by_name||"",uploaded_by_email:e.uploaded_by_email||""}}function Z1(e){const t=String((e==null?void 0:e.mime_type)||"").toLowerCase();return t.startsWith("image/")||t==="application/pdf"||t.startsWith("text/")}function Y1(){const e=f.useMemo(()=>K1(),[]),[t,n]=f.useState([]),[i,s]=f.useState({caseTypes:[],users:[],committees:[],meetings:[],teamRoles:[],departments:[]}),[a,o]=f.useState([]),[l,c]=f.useState(()=>Ol(e==null?void 0:e.id)),[u,m]=f.useState(null),[d,h]=f.useState(""),[x,g]=f.useState(""),[p,w]=f.useState(""),[b,v]=f.useState(!1),[y,j]=f.useState(!1),[k,N]=f.useState(""),[_,S]=f.useState(""),[P,D]=f.useState("15"),[F,T]=f.useState(!1),[M,z]=f.useState([]),[A,$]=f.useState(!1),[Z,R]=f.useState(null),[K,X]=f.useState(0),ae=Jt();f.useEffect(()=>{const C=new URLSearchParams(ae.search).get("open");if(!C||!t.length)return;const G=t.find(ne=>String(ne.id)===String(C));G&&choose(G)},[ae.search,t]);async function J(){w("");try{const[C,G,ne]=await Promise.all([O.get("/cases"),O.get("/cases/meta"),O.get("/agenda-items")]);n(Array.isArray(C)?C:[]),s(G||{caseTypes:[],users:[],committees:[],meetings:[],teamRoles:[],departments:[]}),o(Array.isArray(ne)?ne:[])}catch(C){w(C.message||"Daten konnten nicht geladen werden.")}}f.useEffect(()=>{J()},[]);const ue=f.useMemo(()=>{const C=d.trim().toLowerCase();return C?t.filter(G=>JSON.stringify(G).toLowerCase().includes(C)):t},[t,d]),pe=f.useMemo(()=>{const C=Array.isArray(i.departments)?i.departments.map(ye=>({value:ye.code,label:ye.label})):[],ne=!l.department||C.some(ye=>ye.value===l.department)?C:[{value:l.department,label:l.department},...C];return[{value:"",label:"Bitte auswählen"},...ne.length?ne:H1.slice(1)]},[i.departments,l.department]),ee=f.useMemo(()=>{const C=Number(l.committee_id||(u==null?void 0:u.committee_id)||0),G=(i.meetings||[]).filter(W1);return C?G.filter(ne=>Number(ne.committee_id)===Number(C)):G},[i.meetings,l.committee_id,u==null?void 0:u.committee_id]),le=f.useMemo(()=>u!=null&&u.id?a.filter(C=>!C.case_id||Number(C.case_id)===Number(u.id)):a,[a,u==null?void 0:u.id]);function ge(){z([]),$(!1),R(null),X(C=>C+1)}async function Y(C){$(!0);try{const G=await O.get(`/cases/${C}/attachments`),ne=Array.isArray(G)?G:Array.isArray(G==null?void 0:G.items)?G.items:[];z(ne.map(Q1))}catch(G){z([]),w(G.message||"Anlagen konnten nicht geladen werden.")}finally{$(!1)}}function fe(){m(null),c(Ol(e==null?void 0:e.id)),ge(),j(!1),N(""),S(""),D("15"),w(""),g(""),v(!0)}async function Se(C,G=!1){m(C),c(X1(C)),ge(),j(G),N(""),S(""),D("15"),w(""),g(""),v(!0),await Y(C.id)}function he(){v(!1),j(!1),N(""),S(""),D("15"),m(null),c(Ol(e==null?void 0:e.id)),ge()}function q(){return{title:l.title.trim(),case_type_id:Number(l.case_type_id),status:l.status,priority:l.priority,confidentiality_level:l.confidentiality_level,summary:Rl(l.summary),affected_employee_name:Rl(l.affected_employee_name),department:Rl(l.department),committee_id:l.committee_id?Number(l.committee_id):null,owner_user_id:l.owner_user_id?Number(l.owner_user_id):e==null?void 0:e.id,legal_hold:!!l.legal_hold,team_role_ids:Array.isArray(l.team_role_ids)?l.team_role_ids.map(Number):[]}}function re(C){const G=Array.from(C.target.files||[]);G.length&&(c(ne=>({...ne,attachmentFiles:[...ne.attachmentFiles,...G]})),X(ne=>ne+1))}function me(C){c(G=>({...G,attachmentFiles:G.attachmentFiles.filter((ne,ye)=>ye!==C)}))}async function V(C){if(!(!(u!=null&&u.id)||!window.confirm("Diese gespeicherte Datei wirklich entfernen?"))){R(C),w(""),g("");try{await O.delete(`/cases/${u.id}/attachments/${C}`),z(ne=>ne.filter(ye=>ye.id!==C)),n(ne=>ne.map(ye=>ye.id===u.id?{...ye,attachment_count:Math.max(0,Number(ye.attachment_count||0)-1)}:ye)),m(ne=>ne&&{...ne,attachment_count:Math.max(0,Number(ne.attachment_count||0)-1)}),g("Datei entfernt")}catch(ne){w(ne.message||"Datei konnte nicht entfernt werden.")}finally{R(null)}}}async function te(C){try{await _i(`/documents/${C.id}/view`)}catch(G){w(G.message||"Datei konnte nicht angezeigt werden.")}}async function Ne(C){try{await ji(`/documents/${C.id}/download`,C.file_name||C.title||`datei-${C.id}`)}catch(G){w(G.message||"Datei konnte nicht heruntergeladen werden.")}}async function Xe(C){if(!Array.isArray(l.attachmentFiles)||l.attachmentFiles.length===0)return;const G=new FormData;l.attachmentFiles.forEach(ne=>{G.append("files",ne)}),await O.post(`/cases/${C}/attachments`,G)}async function E(C){if(C.preventDefault(),w(""),g(""),!l.title.trim()){w("Bitte einen Titel eingeben.");return}if(!l.case_type_id){w("Bitte einen Typ auswählen.");return}T(!0);try{const G=q();let ne;u!=null&&u.id?ne=await O.put(`/cases/${u.id}`,G):ne=await O.post("/cases",G),await Xe(ne.id),await J(),g(u!=null&&u.id?"Vorgang gespeichert":`Vorgang erstellt: ${ne.case_number}`),he()}catch(G){w(G.message||"Speichern fehlgeschlagen.")}finally{T(!1)}}async function B(C){w(""),g("");try{await O.delete(`/cases/${C}`),await J(),(u==null?void 0:u.id)===C&&he(),g("Vorgang gelöscht")}catch(G){w(G.message||"Löschen fehlgeschlagen.")}}async function U(){if(u!=null&&u.id){w(""),g("");try{const C=await O.post(`/cases/${u.id}/add-to-agenda`,{meeting_id:Number(k),agenda_item_id:_?Number(_):null,duration_minutes:P?Number(P):null});g(`TOP ${C.agenda_number} für Sitzung ${C.meeting_number} zugewiesen`),j(!1),await J(),he()}catch(C){w(C.message||"TOP konnte nicht an die Sitzung angehängt werden.")}}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .cases-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + } + + .row-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; + } + + .modal-panel { + width: min(1100px, 100%); + max-height: 92vh; + overflow: auto; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + padding: 18px; + } + + .modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .modal-subtitle { + margin: 6px 0 0; + opacity: 0.75; + font-size: 0.95rem; + } + + .modal-close { + border: 0; + background: transparent; + font-size: 28px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + } + + .modal-top-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; + } + + .form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + } + + .form-grid > label.full { + grid-column: 1 / -1; + } + + .upload-list { + list-style: none; + margin: 10px 0 0; + padding: 0; + display: grid; + gap: 8px; + } + + .upload-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + background: rgba(148, 163, 184, 0.08); + } + + .upload-item-main { + min-width: 0; + } + + .upload-item-name { + font-weight: 600; + word-break: break-word; + } + + .upload-item-meta { + margin-top: 3px; + font-size: 0.85rem; + opacity: 0.75; + } + + .upload-remove-btn { + border: 0; + background: transparent; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 4px 6px; + border-radius: 8px; + } + + .upload-remove-btn:hover { + background: rgba(239, 68, 68, 0.12); + } + + .upload-remove-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + .helper-text { + margin-top: 6px; + font-size: 0.9rem; + opacity: 0.75; + } + + .attachments-section { + display: grid; + gap: 10px; + } + + .permission-list { + display: grid; + gap: 8px; + } + + .permission-check { + display: flex; + gap: 8px; + align-items: center; + padding: 8px 10px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + } + + .attachments-title { + font-weight: 600; + margin-top: 8px; + } + + @media (max-width: 860px) { + .form-grid { + grid-template-columns: 1fr; + } + + .modal-panel { + padding: 14px; + } + } + `}),r.jsx("div",{className:"page-grid",children:r.jsxs(de,{title:"Vorgänge",subtitle:"Vorgangsnummern werden automatisch als Jahr_Monat_fortlaufende Nummer erzeugt. Beim Erstellen entsteht automatisch ein TOP im Bestand.",actions:r.jsxs("div",{className:"cases-toolbar",children:[r.jsx("input",{className:"input search",placeholder:"Suchen...",value:d,onChange:C=>h(C.target.value)}),r.jsx("button",{className:"btn",type:"button",onClick:fe,children:"Neuer Vorgang"})]}),children:[p?r.jsx("div",{className:"alert error",children:p}):null,x?r.jsx("div",{className:"alert success",children:x}):null,r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Vorgangsnummer"}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Typ"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Prio"}),r.jsx("th",{children:"Verantwortlich"}),r.jsx("th",{children:"Abteilung"}),r.jsx("th",{children:"Ausschuss"}),r.jsx("th",{children:"Dateien"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[ue.map(C=>{var G;return r.jsxs("tr",{onClick:()=>Se(C),children:[r.jsx("td",{children:C.id}),r.jsx("td",{children:C.case_number}),r.jsx("td",{children:C.title}),r.jsx("td",{children:C.case_type}),r.jsx("td",{children:C.status}),r.jsx("td",{children:((G=xm.find(ne=>ne.value===C.priority))==null?void 0:G.label)||C.priority}),r.jsx("td",{children:C.owner_name||C.owner_user_id}),r.jsx("td",{children:C.department_label||C.department||""}),r.jsx("td",{children:C.committee_name||C.committee_id||""}),r.jsx("td",{children:C.attachment_count??0}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:ne=>{ne.stopPropagation(),Se(C)},children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:ne=>{ne.stopPropagation(),Se(C,!0)},children:"TOP an Sitzung"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:ne=>{ne.stopPropagation(),B(C.id)},children:"Löschen"})]})]},C.id)}),ue.length===0?r.jsx("tr",{children:r.jsx("td",{colSpan:"11",children:"Keine Vorgänge gefunden."})}):null]})]})})]})}),b?r.jsx("div",{className:"modal-backdrop",onClick:he,children:r.jsxs("div",{className:"modal-panel",onClick:C=>C.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:u?"Vorgang bearbeiten":"Neuen Vorgang anlegen"}),r.jsx("p",{className:"modal-subtitle",children:"Verantwortliche Person ist beim Neuanlegen automatisch auf den Erfasser vorbelegt."})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:he,"aria-label":"Schließen",children:"×"})]}),u?r.jsxs("div",{className:"modal-top-actions",children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>j(!1),children:"Stammdaten"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>j(!0),children:"TOP an Sitzung"})]}):null,p?r.jsx("div",{className:"alert error",children:p}):null,y?r.jsxs("div",{className:"form-grid",children:[r.jsxs("label",{className:"full",children:[r.jsx("span",{children:"Sitzung"}),r.jsxs("select",{className:"input",value:k,onChange:C=>N(C.target.value),children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),ee.map(C=>r.jsxs("option",{value:C.id,children:[C.meeting_number," – ",C.title," (",C.meeting_date,")"]},C.id))]}),r.jsx("div",{className:"helper-text",children:"Es werden nur offene Sitzungen angezeigt."})]}),r.jsxs("label",{className:"full",children:[r.jsx("span",{children:"Vorhandenen TOP-Bestand wählen"}),r.jsxs("select",{className:"input",value:_,onChange:C=>S(C.target.value),children:[r.jsx("option",{value:"",children:"Automatischen TOP aus diesem Vorgang verwenden oder erzeugen"}),le.map(C=>r.jsxs("option",{value:C.id,children:[C.case_number?`${C.case_number} – `:"",C.title]},C.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Dauer in Minuten"}),r.jsx("input",{className:"input",type:"number",min:"0",max:"600",value:P,onChange:C=>D(C.target.value)})]}),r.jsxs("div",{className:"full",style:{display:"flex",gap:10,flexWrap:"wrap"},children:[r.jsx("button",{className:"btn",type:"button",disabled:!k,onClick:U,children:"Automatisch an Sitzung anhängen"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>j(!1),children:"Zurück zu Stammdaten"})]})]}):r.jsxs("form",{className:"form-grid",onSubmit:E,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Vorgangsnummer"}),r.jsx("input",{className:"input",value:u?l.case_number:"wird automatisch vergeben",disabled:!0})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Titel"}),r.jsx("input",{className:"input",value:l.title,onChange:C=>c({...l,title:C.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Typ"}),r.jsxs("select",{className:"input",value:l.case_type_id,onChange:C=>c({...l,case_type_id:C.target.value}),children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),i.caseTypes.map(C=>r.jsx("option",{value:C.id,children:C.label},C.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Status"}),r.jsx("select",{className:"input",value:l.status,onChange:C=>c({...l,status:C.target.value}),children:U1.map(C=>r.jsx("option",{value:C,children:C},C))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Priorität"}),r.jsx("select",{className:"input",value:l.priority,onChange:C=>c({...l,priority:C.target.value}),children:xm.map(C=>r.jsx("option",{value:C.value,children:C.label},C.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Vertraulichkeit"}),r.jsx("select",{className:"input",value:l.confidentiality_level,onChange:C=>c({...l,confidentiality_level:C.target.value}),children:V1.map(C=>r.jsx("option",{value:C.value,children:C.label},C.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Verantwortliche Person"}),r.jsxs("select",{className:"input",value:l.owner_user_id,onChange:C=>c({...l,owner_user_id:C.target.value}),children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),i.users.map(C=>r.jsxs("option",{value:C.id,children:[C.full_name," (",C.email,")"]},C.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Ausschuss"}),r.jsxs("select",{className:"input",value:l.committee_id,onChange:C=>c({...l,committee_id:C.target.value}),children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),i.committees.map(C=>r.jsx("option",{value:C.id,children:C.name},C.id))]})]}),r.jsxs("div",{className:"full",children:[r.jsx("span",{children:"Teamrollen mit Vorgangs-Zugriff"}),r.jsx("div",{className:"permission-list",children:(i.teamRoles||[]).map(C=>{const G=String(C.id),ne=l.team_role_ids.includes(G);return r.jsxs("label",{className:"permission-check",children:[r.jsx("input",{type:"checkbox",checked:ne,onChange:()=>{const ye=new Set(l.team_role_ids);ye.has(G)?ye.delete(G):ye.add(G),c({...l,team_role_ids:Array.from(ye)})}}),r.jsx("span",{children:C.name})]},C.id)})}),r.jsx("div",{className:"helper-text",children:"Diese Teamrollen erhalten Lesezugriff auf diesen Vorgang. Chair und Ersteller sehen den Vorgang immer."})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Betroffene Person"}),r.jsx("input",{className:"input",value:l.affected_employee_name,onChange:C=>c({...l,affected_employee_name:C.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Abteilung"}),r.jsx("select",{className:"input",value:l.department,onChange:C=>c({...l,department:C.target.value}),children:pe.map(C=>r.jsx("option",{value:C.value,children:C.label},C.value||"empty"))})]}),r.jsxs("label",{className:"full",children:[r.jsx("span",{children:"Sachverhalt"}),r.jsx("textarea",{className:"input textarea",value:l.summary,onChange:C=>c({...l,summary:C.target.value})})]}),r.jsxs("label",{className:"full",children:[r.jsx("span",{children:"Dateien"}),r.jsxs("div",{className:"attachments-section",children:[r.jsx("input",{className:"input",type:"file",multiple:!0,onChange:re},K),r.jsx("div",{className:"helper-text",children:"Bereits gespeicherte Dateien werden unten angezeigt. Neue Dateien kannst du direkt hinzufügen oder mit X wieder entfernen."}),u?r.jsx("div",{className:"attachments-title",children:"Gespeicherte Dateien"}):null,u&&A?r.jsx("div",{className:"helper-text",children:"Dateien werden geladen..."}):null,u&&!A&&M.length===0?r.jsx("div",{className:"helper-text",children:"Keine gespeicherten Dateien vorhanden."}):null,M.length>0?r.jsx("ul",{className:"upload-list",children:M.map(C=>r.jsxs("li",{className:"upload-item",children:[r.jsxs("div",{className:"upload-item-main",children:[r.jsx("div",{className:"upload-item-name",children:C.file_name}),r.jsx("div",{className:"upload-item-meta",children:vm(C.size_bytes)}),C.uploaded_at?r.jsxs("div",{className:"upload-item-meta",children:["Hochgeladen: ",G1(C.uploaded_at)]}):null,C.uploaded_by_name?r.jsxs("div",{className:"upload-item-meta",children:["Von: ",C.uploaded_by_name]}):null]}),r.jsxs("div",{className:"row-actions",children:[Z1(C)?r.jsx("button",{type:"button",className:"mini-btn",onClick:()=>te(C),children:"Anzeigen"}):null,r.jsx("button",{type:"button",className:"mini-btn",onClick:()=>Ne(C),children:"Download"}),r.jsx("button",{type:"button",className:"upload-remove-btn",title:"Datei entfernen",disabled:Z===C.id,onClick:()=>V(C.id),children:"×"})]})]},C.id))}):null,r.jsx("div",{className:"attachments-title",children:"Neue Dateien zum Hochladen"}),l.attachmentFiles.length===0?r.jsx("div",{className:"helper-text",children:"Keine neuen Dateien ausgewählt."}):r.jsx("ul",{className:"upload-list",children:l.attachmentFiles.map((C,G)=>r.jsxs("li",{className:"upload-item",children:[r.jsxs("div",{className:"upload-item-main",children:[r.jsx("div",{className:"upload-item-name",children:C.name}),r.jsx("div",{className:"upload-item-meta",children:vm(C.size)})]}),r.jsx("button",{type:"button",className:"upload-remove-btn",title:"Aus Auswahl entfernen",onClick:()=>me(G),children:"×"})]},`${C.name}-${C.size}-${G}`))})]})]}),r.jsx("label",{className:"full",children:r.jsxs("span",{children:[r.jsx("input",{type:"checkbox",checked:!!l.legal_hold,onChange:C=>c({...l,legal_hold:C.target.checked}),style:{marginRight:8}}),"Legal Hold"]})}),r.jsxs("div",{className:"full",style:{display:"flex",gap:10,flexWrap:"wrap"},children:[r.jsx("button",{className:"btn",type:"submit",disabled:F,children:F?"Speichern...":u?"Speichern":"Erstellen"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:he,children:"Abbrechen"})]})]})]})}):null]})}const Il=typeof window>"u"?global:window,Ll="@griffel/";function J1(e,t){return Il[Symbol.for(Ll+e)]||(Il[Symbol.for(Ll+e)]=t),Il[Symbol.for(Ll+e)]}const pu=J1("DEFINITION_LOOKUP_TABLE",{}),es="data-make-styles-bucket",ej="data-priority",hu="f",gu=7,Td="___",tj=Td.length+gu,nj=0,rj=1,ij={all:1,borderColor:1,borderStyle:1,borderWidth:1,borderBlock:1,borderBlockEnd:1,borderBlockStart:1,borderInline:1,borderInlineEnd:1,borderInlineStart:1},xu="DO_NOT_USE_DIRECTLY: @griffel/reset-value";function ws(e){for(var t=0,n,i=0,s=e.length;s>=4;++i,s-=4)n=e.charCodeAt(i)&255|(e.charCodeAt(++i)&255)<<8|(e.charCodeAt(++i)&255)<<16|(e.charCodeAt(++i)&255)<<24,n=(n&65535)*1540483477+((n>>>16)*59797<<16),n^=n>>>24,t=(n&65535)*1540483477+((n>>>16)*59797<<16)^(t&65535)*1540483477+((t>>>16)*59797<<16);switch(s){case 3:t^=(e.charCodeAt(i+2)&255)<<16;case 2:t^=(e.charCodeAt(i+1)&255)<<8;case 1:t^=e.charCodeAt(i)&255,t=(t&65535)*1540483477+((t>>>16)*59797<<16)}return t^=t>>>13,t=(t&65535)*1540483477+((t>>>16)*59797<<16),((t^t>>>15)>>>0).toString(36)}function sj(e){const t=e.length;if(t===gu)return e;for(let n=t;n0&&(t+=h.slice(0,x)),n+=g,i[d]=g}}}if(n==="")return t.slice(0,-1);const s=bm[n];if(s!==void 0)return t+s;const a=[];for(let d=0;do.cssText):s}}}const lj=["r","d","l","v","w","f","i","h","a","s","k","t","m","c"],ym=lj.reduce((e,t,n)=>(e[t]=n,e),{});function cj(e,t,n){return(e==="m"?e+t:e)+n}function uj(e,t,n,i,s={}){var a,o;const l=e==="m",c=(a=s.m)!==null&&a!==void 0?a:"0",u=(o=s.p)!==null&&o!==void 0?o:0,m=cj(e,c,u);if(!i.stylesheets[m]){const d=t&&t.createElement("style"),h=oj(d,e,u,Object.assign({},i.styleElementAttributes,l&&{media:c}));i.stylesheets[m]=h,t!=null&&t.head&&d&&t.head.insertBefore(d,fj(t,n,e,i,s))}return i.stylesheets[m]}function dj(e,t,n){var i,s;const a=t+((i=n.m)!==null&&i!==void 0?i:""),o=e.getAttribute(es)+((s=e.media)!==null&&s!==void 0?s:"");return a===o}function fj(e,t,n,i,s={}){var a,o;const l=ym[n],c=(a=s.m)!==null&&a!==void 0?a:"",u=(o=s.p)!==null&&o!==void 0?o:0;let m=p=>l-ym[p.getAttribute(es)],d=e.head.querySelectorAll(`[${es}]`);if(n==="m"){const p=e.head.querySelectorAll(`[${es}="${n}"]`);p.length&&(d=p,m=w=>i.compareMediaQueries(c,w.media))}const h=p=>dj(p,n,s)?u-Number(p.getAttribute("data-priority")):m(p),x=d.length;let g=x-1;for(;g>=0;){const p=d.item(g);if(h(p)>0)return p.nextSibling;g--}return x>0?d.item(0):t?t.nextSibling:null}function jm(e,t){try{e.insertRule(t)}catch{}}let mj=0;const pj=(e,t)=>et?1:0;function hj(e=typeof document>"u"?void 0:document,t={}){const{classNameHashSalt:n,unstable_filterCSSRule:i,insertionPoint:s,styleElementAttributes:a,compareMediaQueries:o=pj}=t,l={classNameHashSalt:n,insertionCache:{},stylesheets:{},styleElementAttributes:Object.freeze(a),compareMediaQueries:o,id:`d${mj++}`,insertCSSRules(c){for(const u in c){const m=c[u];for(let d=0,h=m.length;d{const e={};return function(n,i){e[n.id]===void 0&&(n.insertCSSRules(i),e[n.id]=!0)}};function sx(e){return e.reduce(function(t,n){var i=n[0],s=n[1];return t[i]=s,t[s]=i,t},{})}function gj(e){return typeof e=="boolean"}function xj(e){return typeof e=="function"}function Ui(e){return typeof e=="number"}function vj(e){return e===null||typeof e>"u"}function bj(e){return e&&typeof e=="object"}function yj(e){return typeof e=="string"}function Oa(e,t){return e.indexOf(t)!==-1}function jj(e){return parseFloat(e)===0?e:e[0]==="-"?e.slice(1):"-"+e}function ma(e,t,n,i){return t+jj(n)+i}function _j(e){var t=e.indexOf(".");if(t===-1)e=100-parseFloat(e)+"%";else{var n=e.length-t-2;e=100-parseFloat(e),e=e.toFixed(n)+"%"}return e}function ax(e){return e.replace(/ +/g," ").split(" ").map(function(t){return t.trim()}).filter(Boolean).reduce(function(t,n){var i=t.list,s=t.state,a=(n.match(/\(/g)||[]).length,o=(n.match(/\)/g)||[]).length;return s.parensDepth>0?i[i.length-1]=i[i.length-1]+" "+n:i.push(n),s.parensDepth+=a-o,{list:i,state:s}},{list:[],state:{parensDepth:0}}).list}function _m(e){var t=ax(e);if(t.length<=3||t.length>4)return e;var n=t[0],i=t[1],s=t[2],a=t[3];return[n,a,s,i].join(" ")}function wj(e){return!gj(e)&&!vj(e)}function kj(e){for(var t=[],n=0,i=0,s=!1;i0?at(wi,--Nt):0,di--,Re===10&&(di=1,Ko--),Re}function It(){return Re=Nt2||fi(Re)>3?"":" "}function Wj(e){for(;It();)switch(fi(Re)){case 0:hr(vx(Nt-1),e);break;case 2:hr(La(Re),e);break;default:hr(ei(Re),e)}return e}function Gj(e,t){for(;--t&&It()&&!(Re<48||Re>102||Re>57&&Re<65||Re>70&&Re<97););return Go(e,Ia()+(t<6&&$n()==32&&It()==32))}function bu(e){for(;It();)switch(Re){case e:return Nt;case 34:case 39:e!==34&&e!==39&&bu(Re);break;case 40:e===41&&bu(e);break;case 92:It();break}return Nt}function Xj(e,t){for(;It()&&e+Re!==57;)if(e+Re===84&&$n()===47)break;return"/*"+Go(t,Nt-1)+"*"+ei(e===47?e:It())}function vx(e){for(;!fi($n());)It();return Go(e,Nt)}function bx(e){return xx(Ma("",null,null,null,[""],e=gx(e),0,[0],e))}function Ma(e,t,n,i,s,a,o,l,c){for(var u=0,m=0,d=o,h=0,x=0,g=0,p=1,w=1,b=1,v=0,y=0,j="",k=s,N=a,_=i,S=j;w;)switch(g=y,y=It()){case 40:g!=108&&at(S,d-1)==58?(v++,S+="("):S+=La(y);break;case 41:v--,S+=")";break;case 34:case 39:case 91:S+=La(y);break;case 9:case 10:case 13:case 32:if(v>0){S+=ei(y);break}S+=Kj(g);break;case 92:S+=Gj(Ia()-1,7);continue;case 47:switch($n()){case 42:case 47:hr(Qj(Xj(It(),Ia()),t,n,c),c),(fi(g||1)==5||fi($n()||1)==5)&&Ht(S)&&ui(S,-1,void 0)!==" "&&(S+=" ");break;default:S+="/"}break;case 123*p:l[u++]=Ht(S)*b;case 125*p:case 59:case 0:if(v>0&&y){S+=ei(y);break}switch(y){case 0:case 125:w=0;case 59+m:b==-1&&(S=yt(S,/\f/g,"")),x>0&&(Ht(S)-d||p===0)&&hr(x>32?Sm(S+";",i,n,d-1,c):Sm(yt(S," ","")+";",i,n,d-2,c),c);break;case 59:S+=";";default:if(hr(_=km(S,t,n,u,m,s,l,j,k=[],N=[],d,a),a),y===123)if(m===0)Ma(S,t,_,_,k,a,d,l,N);else{switch(h){case 99:if(at(S,3)===110)break;case 108:if(at(S,2)===97)break;default:m=0;case 100:case 109:case 115:}m?Ma(e,_,_,i&&hr(km(e,_,_,0,0,s,l,j,s,k=[],d,N),N),s,N,d,l,i?k:N):Ma(S,_,_,_,[""],N,0,l,N)}}u=m=x=0,p=b=1,j=S="",d=o;break;case 58:d=1+Ht(S),x=g;default:if(p<1){if(y==123)--p;else if(y==125&&p++==0&&Uj()==125)continue}switch(S+=ei(y),y*p){case 38:b=m>0?1:(S+="\f",-1);break;case 44:if(v>0)break;l[u++]=(Ht(S)-1)*b,b=1;break;case 64:$n()===45&&(S+=La(It())),h=$n(),m=d=Ht(j=S+=vx(Ia())),y++;break;case 45:g===45&&Ht(S)==2&&(p=0)}}return a}function km(e,t,n,i,s,a,o,l,c,u,m,d){for(var h=s-1,x=s===0?a:[""],g=px(x),p=0,w=0,b=0;p0?x[v]+" "+y:yt(y,/&\f/g,x[v])))&&(c[b++]=j);return Wo(e,t,n,s===0?Ho:l,c,u,m,d)}function Qj(e,t,n,i){return Wo(e,t,n,ux,ei(Vj()),ui(e,2,-2),0,i)}function Sm(e,t,n,i,s){return Wo(e,t,n,Ed,ui(e,0,i),ui(e,i+1,-1),i,s)}function mi(e,t){for(var n="",i=0;i{switch(e.type){case Ho:if(typeof e.props=="string")return;e.props=e.props.map(t=>t.indexOf(":global(")===-1?t:Hj(t).reduce((n,i,s,a)=>{if(i==="")return n;if(i===":"&&a[s+1]==="global"){const o=a[s+2].slice(1,-1)+" ";return n.unshift(o),a[s+1]="",a[s+2]="",n}return n.push(i),n},[]).join(""))}};function wx(e,t,n){switch(Fj(e,t)){case 5103:return qt+"print-"+e+e;case 3191:case 6645:case 3005:case 6391:case 5879:case 5623:case 6135:case 4599:case 4855:return qt+e+e;case 4215:if(at(e,9)===102||at(e,t+1)===116)return qt+e+e;break;case 4789:return ts+e+e;case 5349:case 4246:case 6968:return qt+e+ts+e+e;case 6187:if(!mx(e,/grab/))return yt(yt(yt(e,/(zoom-|grab)/,qt+"$1"),/(image-set)/,qt+"$1"),e,"")+e;case 5495:case 3959:return yt(e,/(image-set\([^]*)/,qt+"$1$`$1");case 4095:case 3583:case 4068:case 2532:return yt(e,/(.+)-inline(.+)/,qt+"$1$2")+e;case 8116:case 7059:case 5753:case 5535:case 5445:case 5701:case 4933:case 4677:case 5533:case 5789:case 5021:case 4765:if(Ht(e)-1-t>6)switch(at(e,t+1)){case 102:if(at(e,t+3)===108)return yt(e,/(.+:)(.+)-([^]+)/,"$1"+qt+"$2-$3$1"+ts+(at(e,t+3)==108?"$3":"$2-$3"))+e;case 115:return~$j(e,"stretch")?wx(yt(e,"stretch","fill-available"),t)+e:e}break}return e}function kx(e,t,n,i){if(e.length>-1&&!e.return)switch(e.type){case Ed:e.return=wx(e.value,e.length);return;case Ho:if(e.length)return qj(e.props,function(s){switch(mx(s,/(::plac\w+|:read-\w+)/)){case":read-only":case":read-write":return mi([Fl(e,{props:[yt(s,/:(read-\w+)/,":"+ts+"$1")]})],i);case"::placeholder":return mi([Fl(e,{props:[yt(s,/:(plac\w+)/,":"+qt+"input-$1")]}),Fl(e,{props:[yt(s,/:(plac\w+)/,":"+ts+"$1")]})],i)}return""})}}function Yj(e){switch(e.type){case"@container":case Dj:case Rj:case dx:return!0}return!1}const Jj=e=>{Yj(e)&&Array.isArray(e.children)&&e.children.sort((t,n)=>t.props[0]>n.props[0]?1:-1)};function e_(e,t){const n=[];return mi(bx(e),jx([Zj,Jj,kx,yx,_x(i=>n.push(i))])),n}const t_=/,( *[^ &])/g;function n_(e){return"&"+cx(e.replace(t_,",&$1"))}function Nm(e,t,n){let i=t;return n.length>0&&(i=n.reduceRight((s,a)=>`${n_(a)} { ${s} }`,t)),`${e}{${i}}`}function Cm(e,t){const{className:n,selectors:i,property:s,rtlClassName:a,rtlProperty:o,rtlValue:l,value:c}=e,{container:u,layer:m,media:d,supports:h}=t,x=`.${n}`,g=Array.isArray(c)?`${c.map(w=>`${Hr(s)}: ${w}`).join(";")};`:`${Hr(s)}: ${c};`;let p=Nm(x,g,i);if(o&&a){const w=`.${a}`,b=Array.isArray(l)?`${l.map(v=>`${Hr(o)}: ${v}`).join(";")};`:`${Hr(o)}: ${l};`;p+=Nm(w,b,i)}return d&&(p=`@media ${d} { ${p} }`),m&&(p=`@layer ${m} { ${p} }`),h&&(p=`@supports ${h} { ${p} }`),u&&(p=`@container ${u} { ${p} }`),e_(p)}function r_(e){let t="";for(const n in e){const i=e[n];if(typeof i=="string"||typeof i=="number"){t+=Hr(n)+":"+i+";";continue}if(Array.isArray(i))for(const s of i)t+=Hr(n)+":"+s+";"}return t}function zm(e){let t="";for(const n in e)t+=`${n}{${r_(e[n])}}`;return t}function Bm(e,t){const n=`@keyframes ${e} {${t}}`,i=[];return mi(bx(n),jx([yx,kx,_x(s=>i.push(s))])),i}const i_={animation:[-1,["animationDelay","animationDirection","animationDuration","animationFillMode","animationIterationCount","animationName","animationPlayState","animationTimeline","animationTimingFunction"]],animationRange:[-1,["animationRangeEnd","animationRangeStart"]],background:[-2,["backgroundAttachment","backgroundClip","backgroundColor","backgroundImage","backgroundOrigin","backgroundPosition","backgroundPositionX","backgroundPositionY","backgroundRepeat","backgroundSize"]],backgroundPosition:[-1,["backgroundPositionX","backgroundPositionY"]],border:[-2,["borderBottom","borderBottomColor","borderBottomStyle","borderBottomWidth","borderLeft","borderLeftColor","borderLeftStyle","borderLeftWidth","borderRight","borderRightColor","borderRightStyle","borderRightWidth","borderTop","borderTopColor","borderTopStyle","borderTopWidth"]],borderBottom:[-1,["borderBottomColor","borderBottomStyle","borderBottomWidth"]],borderImage:[-1,["borderImageOutset","borderImageRepeat","borderImageSlice","borderImageSource","borderImageWidth"]],borderLeft:[-1,["borderLeftColor","borderLeftStyle","borderLeftWidth"]],borderRadius:[-1,["borderBottomLeftRadius","borderBottomRightRadius","borderTopLeftRadius","borderTopRightRadius"]],borderRight:[-1,["borderRightColor","borderRightStyle","borderRightWidth"]],borderTop:[-1,["borderTopColor","borderTopStyle","borderTopWidth"]],caret:[-1,["caretColor","caretShape"]],columnRule:[-1,["columnRuleColor","columnRuleStyle","columnRuleWidth"]],columns:[-1,["columnCount","columnWidth"]],containIntrinsicSize:[-1,["containIntrinsicHeight","containIntrinsicWidth"]],container:[-1,["containerName","containerType"]],flex:[-1,["flexBasis","flexGrow","flexShrink"]],flexFlow:[-1,["flexDirection","flexWrap"]],font:[-1,["fontFamily","fontSize","fontStretch","fontStyle","fontVariant","fontWeight","lineHeight"]],gap:[-1,["columnGap","rowGap"]],grid:[-1,["columnGap","gridAutoColumns","gridAutoFlow","gridAutoRows","gridColumnGap","gridRowGap","gridTemplateAreas","gridTemplateColumns","gridTemplateRows","rowGap"]],gridArea:[-1,["gridColumnEnd","gridColumnStart","gridRowEnd","gridRowStart"]],gridColumn:[-1,["gridColumnEnd","gridColumnStart"]],gridRow:[-1,["gridRowEnd","gridRowStart"]],gridTemplate:[-1,["gridTemplateAreas","gridTemplateColumns","gridTemplateRows"]],inset:[-1,["bottom","left","right","top"]],insetBlock:[-1,["insetBlockEnd","insetBlockStart"]],insetInline:[-1,["insetInlineEnd","insetInlineStart"]],listStyle:[-1,["listStyleImage","listStylePosition","listStyleType"]],margin:[-1,["marginBottom","marginLeft","marginRight","marginTop"]],marginBlock:[-1,["marginBlockEnd","marginBlockStart"]],marginInline:[-1,["marginInlineEnd","marginInlineStart"]],mask:[-1,["maskClip","maskComposite","maskImage","maskMode","maskOrigin","maskPosition","maskRepeat","maskSize"]],maskBorder:[-1,["maskBorderMode","maskBorderOutset","maskBorderRepeat","maskBorderSlice","maskBorderSource","maskBorderWidth"]],offset:[-1,["offsetAnchor","offsetDistance","offsetPath","offsetPosition","offsetRotate"]],outline:[-1,["outlineColor","outlineStyle","outlineWidth"]],overflow:[-1,["overflowX","overflowY"]],overscrollBehavior:[-1,["overscrollBehaviorX","overscrollBehaviorY"]],padding:[-1,["paddingBottom","paddingLeft","paddingRight","paddingTop"]],paddingBlock:[-1,["paddingBlockEnd","paddingBlockStart"]],paddingInline:[-1,["paddingInlineEnd","paddingInlineStart"]],placeContent:[-1,["alignContent","justifyContent"]],placeItems:[-1,["alignItems","justifyItems"]],placeSelf:[-1,["alignSelf","justifySelf"]],scrollMargin:[-1,["scrollMarginBottom","scrollMarginLeft","scrollMarginRight","scrollMarginTop"]],scrollMarginBlock:[-1,["scrollMarginBlockEnd","scrollMarginBlockStart"]],scrollMarginInline:[-1,["scrollMarginInlineEnd","scrollMarginInlineStart"]],scrollPadding:[-1,["scrollPaddingBottom","scrollPaddingLeft","scrollPaddingRight","scrollPaddingTop"]],scrollPaddingBlock:[-1,["scrollPaddingBlockEnd","scrollPaddingBlockStart"]],scrollPaddingInline:[-1,["scrollPaddingInlineEnd","scrollPaddingInlineStart"]],scrollTimeline:[-1,["scrollTimelineAxis","scrollTimelineName"]],textDecoration:[-1,["textDecorationColor","textDecorationLine","textDecorationStyle","textDecorationThickness"]],textEmphasis:[-1,["textEmphasisColor","textEmphasisStyle"]],transition:[-1,["transitionBehavior","transitionDelay","transitionDuration","transitionProperty","transitionTimingFunction"]],viewTimeline:[-1,["viewTimelineAxis","viewTimelineName"]]};function Tm(e,t){return e.length===0?t:`${e} and ${t}`}function s_(e){return e.substr(0,6)==="@media"}function a_(e){return e.substr(0,6)==="@layer"}const o_=/^(:|\[|>|&)/;function l_(e){return o_.test(e)}function c_(e){return e.substr(0,9)==="@supports"}function u_(e){return e.substring(0,10)==="@container"}function d_(e){return e!=null&&typeof e=="object"&&Array.isArray(e)===!1}const Pm={"us-w":"w","us-v":"i",nk:"l",si:"v",cu:"f",ve:"h",ti:"a"};function Em(e,t){if(t.media)return"m";if(t.layer||t.supports)return"t";if(t.container)return"c";if(e.length>0){const n=e[0].trim();if(n.charCodeAt(0)===58)return Pm[n.slice(4,8)]||Pm[n.slice(3,5)]||"d"}return"d"}function pa(e,t){return e&&t+e}function Sx(e){return pa(e.container,"c")+pa(e.media,"m")+pa(e.layer,"l")+pa(e.supports,"s")}function $l(e,t,n){const i=e+Sx(n)+t,s=ws(i),a=s.charCodeAt(0);return a>=48&&a<=57?String.fromCharCode(a+17)+s.slice(1):s}function ha({property:e,selector:t,salt:n,value:i},s){return hu+ws(n+t+Sx(s)+e+i.trim())}function f_(e){return e===xu}function ql(e){return e.replace(/>\s+/g,">")}function Dm(e){return i_[e]}function Am(e){var t;return(t=e==null?void 0:e[0])!==null&&t!==void 0?t:0}function Vl(e,t,n,i){e[t]=i?[n,i]:n}function Rm(e,t){return t.length>0?[e,Object.fromEntries(t)]:e}function Ul(e,t,n,i,s,a){var o;const l=[];a!==0&&l.push(["p",a]),t==="m"&&s&&l.push(["m",s]),(o=e[t])!==null&&o!==void 0||(e[t]=[]),n&&e[t].push(Rm(n,l)),i&&e[t].push(Rm(i,l))}function dn(e,t="",n=[],i={container:"",layer:"",media:"",supports:""},s={},a={},o){for(const l in e){if(ij.hasOwnProperty(l)){e[l];continue}const c=e[l];if(c!=null){if(f_(c)){const u=ql(n.join("")),m=$l(u,l,i);Vl(s,m,0,void 0);continue}if(typeof c=="string"||typeof c=="number"){const u=ql(n.join("")),m=Dm(l);if(m){const j=m[1],k=Object.fromEntries(j.map(N=>[N,xu]));dn(k,t,n,i,s,a)}const d=$l(u,l,i),h=ha({value:c.toString(),salt:t,selector:u,property:l},i),x=o&&{key:l,value:o}||vu(l,c),g=x.key!==l||x.value!==c,p=g?ha({value:x.value.toString(),property:x.key,salt:t,selector:u},i):void 0,w=g?{rtlClassName:p,rtlProperty:x.key,rtlValue:x.value}:void 0,b=Em(n,i),[v,y]=Cm(Object.assign({className:h,selectors:n,property:l,value:c},w),i);Vl(s,d,h,p),Ul(a,b,v,y,i.media,Am(m))}else if(l==="animationName"){const u=Array.isArray(c)?c:[c],m=[],d=[];for(const h of u){const x=zm(h),g=zm(lx(h)),p=hu+ws(x);let w;const b=Bm(p,x);let v=[];x===g?w=p:(w=hu+ws(g),v=Bm(w,g));for(let y=0;y[_,xu]));dn(N,t,n,i,s,a)}const d=$l(u,l,i),h=ha({value:c.map(k=>(k??"").toString()).join(";"),salt:t,selector:u,property:l},i),x=c.map(k=>vu(l,k));if(!!x.some(k=>k.key!==x[0].key))continue;const p=x[0].key!==l||x.some((k,N)=>k.value!==c[N]),w=p?ha({value:x.map(k=>{var N;return((N=k==null?void 0:k.value)!==null&&N!==void 0?N:"").toString()}).join(";"),salt:t,property:x[0].key,selector:u},i):void 0,b=p?{rtlClassName:w,rtlProperty:x[0].key,rtlValue:x.map(k=>k.value)}:void 0,v=Em(n,i),[y,j]=Cm(Object.assign({className:h,selectors:n,property:l,value:c},b),i);Vl(s,d,h,w),Ul(a,v,y,j,i.media,Am(m))}else if(d_(c)){if(l_(l))dn(c,t,n.concat(cx(l)),i,s,a);else if(s_(l)){const u=Tm(i.media,l.slice(6).trim());dn(c,t,n,Object.assign({},i,{media:u}),s,a)}else if(a_(l)){const u=(i.layer?`${i.layer}.`:"")+l.slice(6).trim();dn(c,t,n,Object.assign({},i,{layer:u}),s,a)}else if(c_(l)){const u=Tm(i.supports,l.slice(9).trim());dn(c,t,n,Object.assign({},i,{supports:u}),s,a)}else if(u_(l)){const u=l.slice(10).trim();dn(c,t,n,Object.assign({},i,{container:u}),s,a)}}}}return[s,a]}function m_(e,t=""){const n={},i={};for(const s in e){const a=e[s],[o,l]=dn(a,t);n[s]=o,Object.keys(l).forEach(c=>{i[c]=(i[c]||[]).concat(l[c])})}return[n,i]}function p_(e,t=Pd){const n=t();let i=null,s=null,a=null,o=null;function l(c){const{dir:u,renderer:m}=c;i===null&&([i,s]=m_(e,m.classNameHashSalt));const d=u==="ltr";return d?a===null&&(a=xo(i,u)):o===null&&(o=xo(i,u)),n(m,s),d?a:o}return l}function h_(e,t,n=Pd){const i=n();let s=null,a=null;function o(l){const{dir:c,renderer:u}=l,m=c==="ltr";return m?s===null&&(s=xo(e,c)):a===null&&(a=xo(e,c)),i(u,t),m?s:a}return o}function g_(e,t,n,i=Pd){const s=i();function a(o){const{dir:l,renderer:c}=o,u=l==="ltr"?e:t||e;return s(c,Array.isArray(n)?{r:n}:n),u}return a}function x_(){return typeof window<"u"&&!!(window.document&&window.document.createElement)}const Om=br.useInsertionEffect?br.useInsertionEffect:void 0,Dd=()=>{const e={};return function(n,i){if(Om&&x_()){Om(()=>{n.insertCSSRules(i)},[n,i]);return}e[n.id]===void 0&&(n.insertCSSRules(i),e[n.id]=!0)}},v_=f.createContext(hj());function Ad(){return f.useContext(v_)}const b_=f.createContext("ltr");function Rd(){return f.useContext(b_)}function y_(e){const t=p_(e,Dd);return function(){const i=Rd(),s=Ad();return t({dir:i,renderer:s})}}function Pe(e,t){const n=h_(e,t,Dd);return function(){const s=Rd(),a=Ad();return n({dir:s,renderer:a})}}function en(e,t,n){const i=g_(e,t,n,Dd);return function(){const a=Rd(),o=Ad();return i({dir:a,renderer:o})}}const Nx=Symbol.for("fui.slotRenderFunction"),Xo=Symbol.for("fui.slotElementType"),Cx=Symbol.for("fui.slotClassNameProp");function vt(e,t){const{defaultProps:n,elementType:i}=t,s=j_(e),a={...n,...s,[Xo]:i,[Cx]:(s==null?void 0:s.className)||(n==null?void 0:n.className)};return s&&typeof s.children=="function"&&(a[Nx]=s.children,a.children=n==null?void 0:n.children),a}function et(e,t){if(!(e===null||e===void 0&&!t.renderByDefault))return vt(e,t)}function j_(e){return typeof e=="string"||typeof e=="number"||__(e)||f.isValidElement(e)?{children:e}:e}const __=e=>typeof e=="object"&&e!==null&&Symbol.iterator in e;function w_(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&!f.isValidElement(e)}function Im(e){return!!(e!=null&&e.hasOwnProperty(Xo))}const ze=(...e)=>{const t={};for(const n of e){const i=Array.isArray(n)?n:Object.keys(n);for(const s of i)t[s]=1}return t},k_=ze(["onAuxClick","onAnimationEnd","onAnimationStart","onCopy","onCut","onPaste","onCompositionEnd","onCompositionStart","onCompositionUpdate","onFocus","onFocusCapture","onBlur","onBlurCapture","onChange","onInput","onSubmit","onLoad","onError","onKeyDown","onKeyDownCapture","onKeyPress","onKeyUp","onAbort","onCanPlay","onCanPlayThrough","onDurationChange","onEmptied","onEncrypted","onEnded","onLoadedData","onLoadedMetadata","onLoadStart","onPause","onPlay","onPlaying","onProgress","onRateChange","onSeeked","onSeeking","onStalled","onSuspend","onTimeUpdate","onVolumeChange","onWaiting","onClick","onClickCapture","onContextMenu","onDoubleClick","onDrag","onDragEnd","onDragEnter","onDragExit","onDragLeave","onDragOver","onDragStart","onDrop","onMouseDown","onMouseDownCapture","onMouseEnter","onMouseLeave","onMouseMove","onMouseOut","onMouseOver","onMouseUp","onMouseUpCapture","onSelect","onTouchCancel","onTouchEnd","onTouchMove","onTouchStart","onScroll","onWheel","onPointerCancel","onPointerDown","onPointerEnter","onPointerLeave","onPointerMove","onPointerOut","onPointerOver","onPointerUp","onGotPointerCapture","onLostPointerCapture"]),S_=ze(["accessKey","children","className","contentEditable","dir","draggable","hidden","htmlFor","id","lang","popover","focusgroup","focusgroupstart","ref","role","style","tabIndex","title","translate","spellCheck","name"]),N_=ze(["itemID","itemProp","itemRef","itemScope","itemType"]),He=ze(S_,k_,N_),C_=ze(He,["form"]),zx=ze(He,["height","loop","muted","preload","src","width"]),z_=ze(zx,["poster"]),B_=ze(He,["start"]),T_=ze(He,["value"]),P_=ze(He,["download","href","hrefLang","media","referrerPolicy","rel","target","type"]),E_=ze(He,["dateTime"]),Qo=ze(He,["autoFocus","disabled","form","formAction","formEncType","formMethod","formNoValidate","formTarget","popoverTarget","popoverTargetAction","type","value"]),D_=ze(Qo,["accept","alt","autoCorrect","autoCapitalize","autoComplete","checked","dirname","form","height","inputMode","list","max","maxLength","min","minLength","multiple","pattern","placeholder","readOnly","required","src","step","size","type","value","width"]),A_=ze(Qo,["autoCapitalize","cols","dirname","form","maxLength","placeholder","readOnly","required","rows","wrap"]),R_=ze(Qo,["form","multiple","required"]),O_=ze(He,["selected","value"]),I_=ze(He,["cellPadding","cellSpacing"]),L_=He,M_=ze(He,["colSpan","rowSpan","scope"]),F_=ze(He,["colSpan","headers","rowSpan","scope"]),$_=ze(He,["span"]),q_=ze(He,["span"]),V_=ze(He,["disabled","form"]),U_=ze(He,["acceptCharset","action","encType","encType","method","noValidate","target"]),H_=ze(He,["allow","allowFullScreen","allowPaymentRequest","allowTransparency","csp","height","importance","referrerPolicy","sandbox","src","srcDoc","width"]),K_=ze(He,["alt","crossOrigin","height","src","srcSet","useMap","width"]),W_=ze(He,["open","onCancel","onClose"]);function G_(e,t,n){const i=Array.isArray(t),s={},a=Object.keys(e);for(const o of a)(!i&&t[o]||i&&t.indexOf(o)>=0||o.indexOf("data-")===0||o.indexOf("aria-")===0)&&(!n||(n==null?void 0:n.indexOf(o))===-1)&&(s[o]=e[o]);return s}const X_={label:C_,audio:zx,video:z_,ol:B_,li:T_,a:P_,button:Qo,input:D_,textarea:A_,select:R_,option:O_,table:I_,tr:L_,th:M_,td:F_,colGroup:$_,col:q_,fieldset:V_,form:U_,iframe:H_,img:K_,time:E_,dialog:W_};function Bx(e,t,n){const i=e&&X_[e]||He;return i.as=1,G_(t,i,n)}const Od=({primarySlotTagName:e,props:t,excludedPropNames:n})=>({root:{style:t.style,className:t.className},primary:Bx(e,t,[...n||[],"style","className"])}),Ds=(e,t,n)=>{var i;return Bx((i=t.as)!==null&&i!==void 0?i:e,t,n)};function Q_(e,t){const n=f.useRef(void 0),i=f.useCallback((a,o)=>(n.current!==void 0&&t(n.current),n.current=e(a,o),n.current),[t,e]),s=f.useCallback(()=>{n.current!==void 0&&(t(n.current),n.current=void 0)},[t]);return f.useEffect(()=>s,[s]),[i,s]}const Tx=f.createContext(void 0),Z_="";Tx.Provider;function Y_(){var e;return(e=f.useContext(Tx))!==null&&e!==void 0?e:Z_}const Px=f.createContext(void 0),J_={targetDocument:typeof document=="object"?document:void 0,dir:"ltr"};Px.Provider;function un(){var e;return(e=f.useContext(Px))!==null&&e!==void 0?e:J_}const Ex=f.createContext(void 0);Ex.Provider;function Dx(){var e;return(e=f.useContext(Ex))!==null&&e!==void 0?e:{}}const Ax=f.createContext(void 0),ew=()=>{};Ax.Provider;const zn=e=>{var t,n;return(n=(t=f.useContext(Ax))===null||t===void 0?void 0:t[e])!==null&&n!==void 0?n:ew},Rx=f.createContext(void 0);Rx.Provider;function tw(){return f.useContext(Rx)}function nw(e){return typeof e=="function"}const ks=e=>{"use no memo";const[t,n]=f.useState(()=>e.defaultState===void 0?e.initialState:rw(e.defaultState)?e.defaultState():e.defaultState),i=f.useRef(e.state);f.useEffect(()=>{i.current=e.state},[e.state]);const s=f.useCallback(a=>{nw(a)&&a(i.current)},[]);return iw(e.state)?[e.state,s]:[t,n]};function rw(e){return typeof e=="function"}const iw=e=>{"use no memo";const[t]=f.useState(()=>e!==void 0);return t};function Ox(){return typeof window<"u"&&!!(window.document&&window.document.createElement)}const sw={current:0},aw=f.createContext(void 0);function ow(){var e;return(e=f.useContext(aw))!==null&&e!==void 0?e:sw}const nr=Ox()?f.useLayoutEffect:f.useEffect,qe=e=>{const t=f.useRef(()=>{throw new Error("Cannot call an event handler while rendering")});return nr(()=>{t.current=e},[e]),f.useCallback((...n)=>{const i=t.current;return i(...n)},[t])};function lw(){const e=f.useRef(!0);return f.useEffect(()=>{e.current&&(e.current=!1)},[]),e.current}const Ix=f.createContext(void 0);Ix.Provider;function cw(){return f.useContext(Ix)||""}function Zo(e="fui-",t){"use no memo";const n=ow(),i=cw(),s=br.useId;if(s){const a=s(),o=f.useMemo(()=>a.replace(/:/g,""),[a]);return t||`${i}${e}${o}`}return f.useMemo(()=>t||`${i}${e}${++n.current}`,[i,e,t,n])}function Yn(...e){"use no memo";const t=f.useCallback(n=>{t.current=n;for(const i of e)typeof i=="function"?i(n):i&&(i.current=n)},[...e]);return t}const Lx=(e,t)=>!!(e!=null&&e.contains(t)),uw=e=>{const{targetDocument:t}=un(),n=t==null?void 0:t.defaultView,{refs:i,callback:s,element:a,disabled:o,disabledFocusOnIframe:l,contains:c=Lx}=e,u=f.useRef(void 0);fw({element:a,disabled:l||o,callback:s,refs:i,contains:c});const m=f.useRef(!1),d=qe(x=>{if(m.current){m.current=!1;return}const g=x.composedPath()[0];i.every(w=>!c(w.current||null,g))&&!o&&s(x)}),h=qe(x=>{m.current=i.some(g=>c(g.current||null,x.target))});f.useEffect(()=>{if(o)return;let x=dw(n);const g=p=>{if(p===x){x=void 0;return}d(p)};return a==null||a.addEventListener("click",g,!0),a==null||a.addEventListener("touchstart",g,!0),a==null||a.addEventListener("contextmenu",g,!0),a==null||a.addEventListener("mousedown",h,!0),u.current=n==null?void 0:n.setTimeout(()=>{x=void 0},1),()=>{a==null||a.removeEventListener("click",g,!0),a==null||a.removeEventListener("touchstart",g,!0),a==null||a.removeEventListener("contextmenu",g,!0),a==null||a.removeEventListener("mousedown",h,!0),n==null||n.clearTimeout(u.current),x=void 0}},[d,a,o,h,n])},dw=e=>{if(e){var t,n;if(typeof e.window=="object"&&e.window===e)return e.event;var i;return(i=(n=e.ownerDocument)===null||n===void 0||(t=n.defaultView)===null||t===void 0?void 0:t.event)!==null&&i!==void 0?i:void 0}},Hl="fuiframefocus",fw=e=>{const{disabled:t,element:n,callback:i,contains:s=Lx,pollDuration:a=100,refs:o}=e,l=f.useRef(void 0),c=qe(u=>{o.every(d=>!s(d.current||null,u.target))&&!t&&i(u)});f.useEffect(()=>{if(!t)return n==null||n.addEventListener(Hl,c,!0),()=>{n==null||n.removeEventListener(Hl,c,!0)}},[n,t,c]),f.useEffect(()=>{var u;if(!t)return l.current=n==null||(u=n.defaultView)===null||u===void 0?void 0:u.setInterval(()=>{const m=n==null?void 0:n.activeElement;if((m==null?void 0:m.tagName)==="IFRAME"||(m==null?void 0:m.tagName)==="WEBVIEW"){const d=new CustomEvent(Hl,{bubbles:!0});m.dispatchEvent(d)}},a),()=>{var m;n==null||(m=n.defaultView)===null||m===void 0||m.clearInterval(l.current)}},[n,t,a])},mw=e=>-1,pw=e=>{};function Mx(){const{targetDocument:e}=un(),t=e==null?void 0:e.defaultView,n=t?t.setTimeout:mw,i=t?t.clearTimeout:pw;return Q_(n,i)}function Wt(e,t){return(...n)=>{e==null||e(...n),t==null||t(...n)}}function pi(e,t){var n;const i=e;var s;return!!(!(i==null||(n=i.ownerDocument)===null||n===void 0)&&n.defaultView&&i instanceof i.ownerDocument.defaultView[(s=void 0)!==null&&s!==void 0?s:"HTMLElement"])}function Lm(e,t){e&&Object.assign(e,{_virtual:{parent:t}})}function hw(e,t){return{...t,[Xo]:e}}function Fx(e,t){return function(i,s,a,o,l){return Im(s)?t(hw(i,s),null,a,o,l):Im(i)?t(i,s,a,o,l):e(i,s,a,o,l)}}function $x(e){const{as:t,[Cx]:n,[Xo]:i,[Nx]:s,...a}=e,o=a,l=typeof i=="string"?t??i:i;return typeof l!="string"&&t&&(o.as=t),{elementType:l,props:o,renderFunction:s}}const vr=xb,gw=(e,t,n)=>{const{elementType:i,renderFunction:s,props:a}=$x(e),o={...a,...t};return s?vr.jsx(f.Fragment,{children:s(i,o)},n):vr.jsx(i,o,n)},xw=(e,t,n)=>{const{elementType:i,renderFunction:s,props:a}=$x(e),o={...a,...t};return s?vr.jsx(f.Fragment,{children:s(i,{...o,children:vr.jsxs(f.Fragment,{children:o.children},void 0)})},n):vr.jsxs(i,o,n)},ke=Fx(vr.jsx,gw),wn=Fx(vr.jsxs,xw),qx=f.createContext(void 0),vw={};qx.Provider;const bw=()=>{const e=f.useContext(qx);return e??vw};var yw=typeof WeakRef<"u",Mm=class{constructor(e){yw&&typeof e=="object"?this._weakRef=new WeakRef(e):this._instance=e}deref(){var e,t;let n;return this._weakRef?(n=(e=this._weakRef)==null?void 0:e.deref(),n||delete this._weakRef):(n=this._instance,(t=n==null?void 0:n.isDisposed)!=null&&t.call(n)&&delete this._instance),n}},Ss="keyborg:focusin",jw="keyborg:focusout";function _w(e){const t=e.HTMLElement,n=t.prototype.focus;let i=!1;return t.prototype.focus=function(){i=!0},e.document.createElement("button").focus(),t.prototype.focus=n,i}var Kl=!1;function ww(e){const t=e;Kl||(Kl=_w(t));const n=t.HTMLElement.prototype.focus;if(n.__keyborgNativeFocus)return;t.HTMLElement.prototype.focus=c;const i=new Set,s=m=>{const d=m.target;if(!d)return;const h=new CustomEvent(jw,{cancelable:!0,bubbles:!0,composed:!0,detail:{originalEvent:m}});d.dispatchEvent(h)},a=m=>{const d=m.target;if(!d)return;let h=m.composedPath()[0];const x=new Set;for(;h;)h.nodeType===Node.DOCUMENT_FRAGMENT_NODE?(x.add(h),h=h.host):h=h.parentNode;for(const g of i){const p=g.deref();(!p||!x.has(p))&&(i.delete(g),p&&(p.removeEventListener("focusin",a,!0),p.removeEventListener("focusout",s,!0)))}o(d,m.relatedTarget||void 0)},o=(m,d,h)=>{var x;const g=m.shadowRoot;if(g){for(const b of i)if(b.deref()===g)return;g.addEventListener("focusin",a,!0),g.addEventListener("focusout",s,!0),i.add(new Mm(g));return}const p={relatedTarget:d,originalEvent:h},w=new CustomEvent(Ss,{cancelable:!0,bubbles:!0,composed:!0,detail:p});w.details=p,(Kl||l.lastFocusedProgrammatically)&&(p.isFocusedProgrammatically=m===((x=l.lastFocusedProgrammatically)==null?void 0:x.deref()),l.lastFocusedProgrammatically=void 0),m.dispatchEvent(w)},l=t.__keyborgData={focusInHandler:a,focusOutHandler:s,shadowTargets:i};t.document.addEventListener("focusin",t.__keyborgData.focusInHandler,!0),t.document.addEventListener("focusout",t.__keyborgData.focusOutHandler,!0);function c(){const m=t.__keyborgData;return m&&(m.lastFocusedProgrammatically=new Mm(this)),n.apply(this,arguments)}let u=t.document.activeElement;for(;u&&u.shadowRoot;)o(u),u=u.shadowRoot.activeElement;c.__keyborgNativeFocus=n}function kw(e){const t=e,n=t.HTMLElement.prototype,i=n.focus.__keyborgNativeFocus,s=t.__keyborgData;if(s){t.document.removeEventListener("focusin",s.focusInHandler,!0),t.document.removeEventListener("focusout",s.focusOutHandler,!0);for(const a of s.shadowTargets){const o=a.deref();o&&(o.removeEventListener("focusin",s.focusInHandler,!0),o.removeEventListener("focusout",s.focusOutHandler,!0))}s.shadowTargets.clear(),delete t.__keyborgData}i&&(n.focus=i)}var Sw=500,Vx=0,Nw=class{constructor(e,t){this._isNavigatingWithKeyboard_DO_NOT_USE=!1,this._onFocusIn=i=>{if(this._isMouseOrTouchUsedTimer||this.isNavigatingWithKeyboard)return;const s=i.detail;s.relatedTarget&&(s.isFocusedProgrammatically||s.isFocusedProgrammatically===void 0||(this.isNavigatingWithKeyboard=!0))},this._onMouseDown=i=>{i.buttons===0||i.clientX===0&&i.clientY===0&&i.screenX===0&&i.screenY===0||this._onMouseOrTouch()},this._onMouseOrTouch=()=>{const i=this._win;i&&(this._isMouseOrTouchUsedTimer&&i.clearTimeout(this._isMouseOrTouchUsedTimer),this._isMouseOrTouchUsedTimer=i.setTimeout(()=>{delete this._isMouseOrTouchUsedTimer},1e3)),this.isNavigatingWithKeyboard=!1},this._onKeyDown=i=>{this.isNavigatingWithKeyboard?this._shouldDismissKeyboardNavigation(i)&&this._scheduleDismiss():this._shouldTriggerKeyboardNavigation(i)&&(this.isNavigatingWithKeyboard=!0)},this.id="c"+ ++Vx,this._win=e;const n=e.document;if(t){const i=t.triggerKeys,s=t.dismissKeys;i!=null&&i.length&&(this._triggerKeys=new Set(i)),s!=null&&s.length&&(this._dismissKeys=new Set(s))}n.addEventListener(Ss,this._onFocusIn,!0),n.addEventListener("mousedown",this._onMouseDown,!0),e.addEventListener("keydown",this._onKeyDown,!0),n.addEventListener("touchstart",this._onMouseOrTouch,!0),n.addEventListener("touchend",this._onMouseOrTouch,!0),n.addEventListener("touchcancel",this._onMouseOrTouch,!0),ww(e)}get isNavigatingWithKeyboard(){return this._isNavigatingWithKeyboard_DO_NOT_USE}set isNavigatingWithKeyboard(e){this._isNavigatingWithKeyboard_DO_NOT_USE!==e&&(this._isNavigatingWithKeyboard_DO_NOT_USE=e,this.update())}dispose(){const e=this._win;if(e){this._isMouseOrTouchUsedTimer&&(e.clearTimeout(this._isMouseOrTouchUsedTimer),this._isMouseOrTouchUsedTimer=void 0),this._dismissTimer&&(e.clearTimeout(this._dismissTimer),this._dismissTimer=void 0),kw(e);const t=e.document;t.removeEventListener(Ss,this._onFocusIn,!0),t.removeEventListener("mousedown",this._onMouseDown,!0),e.removeEventListener("keydown",this._onKeyDown,!0),t.removeEventListener("touchstart",this._onMouseOrTouch,!0),t.removeEventListener("touchend",this._onMouseOrTouch,!0),t.removeEventListener("touchcancel",this._onMouseOrTouch,!0),delete this._win}}isDisposed(){return!!this._win}update(){var e,t;const n=(t=(e=this._win)==null?void 0:e.__keyborg)==null?void 0:t.refs;if(n)for(const i of Object.keys(n))Id.update(n[i],this.isNavigatingWithKeyboard)}_shouldTriggerKeyboardNavigation(e){var t;if(e.key==="Tab")return!0;const n=(t=this._win)==null?void 0:t.document.activeElement,i=!this._triggerKeys||this._triggerKeys.has(e.keyCode),s=n&&(n.tagName==="INPUT"||n.tagName==="TEXTAREA"||n.isContentEditable);return i&&!s}_shouldDismissKeyboardNavigation(e){var t;return(t=this._dismissKeys)==null?void 0:t.has(e.keyCode)}_scheduleDismiss(){const e=this._win;if(e){this._dismissTimer&&(e.clearTimeout(this._dismissTimer),this._dismissTimer=void 0);const t=e.document.activeElement;this._dismissTimer=e.setTimeout(()=>{this._dismissTimer=void 0;const n=e.document.activeElement;t&&n&&t===n&&(this.isNavigatingWithKeyboard=!1)},Sw)}}},Id=class Ux{constructor(t,n){this._cb=[],this._id="k"+ ++Vx,this._win=t;const i=t.__keyborg;i?(this._core=i.core,i.refs[this._id]=this):(this._core=new Nw(t,n),t.__keyborg={core:this._core,refs:{[this._id]:this}})}static create(t,n){return new Ux(t,n)}static dispose(t){t.dispose()}static update(t,n){t._cb.forEach(i=>i(n))}dispose(){var t;const n=(t=this._win)==null?void 0:t.__keyborg;n!=null&&n.refs[this._id]&&(delete n.refs[this._id],Object.keys(n.refs).length===0&&(n.core.dispose(),delete this._win.__keyborg)),this._cb=[],delete this._core,delete this._win}isNavigatingWithKeyboard(){var t;return!!((t=this._core)!=null&&t.isNavigatingWithKeyboard)}subscribe(t){this._cb.push(t)}unsubscribe(t){const n=this._cb.indexOf(t);n>=0&&this._cb.splice(n,1)}setVal(t){this._core&&(this._core.isNavigatingWithKeyboard=t)}};function Hx(e,t){return Id.create(e,t)}function Kx(e){Id.dispose(e)}/*! + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */const Fm="data-fui-focus-visible";function Cw(e,t){if(Wx(e))return()=>{};const n={current:void 0},i=Hx(t);function s(c){i.isNavigatingWithKeyboard()&&pi(c)&&(n.current=c,c.setAttribute(Fm,""))}function a(){n.current&&(n.current.removeAttribute(Fm),n.current=void 0)}i.subscribe(c=>{c?s(t.document.activeElement):a()});const o=c=>{a();const u=c.composedPath()[0];s(u)},l=c=>{(!c.relatedTarget||pi(c.relatedTarget)&&!e.contains(c.relatedTarget))&&a()};return e.addEventListener(Ss,o),e.addEventListener("focusout",l),e.focusVisible=!0,e.contains(t.document.activeElement)&&s(t.document.activeElement),()=>{a(),e.removeEventListener(Ss,o),e.removeEventListener("focusout",l),e.focusVisible=void 0,Kx(i)}}function Wx(e){return e?e.focusVisible?!0:Wx(e==null?void 0:e.parentElement):!1}function zw(e={}){const t=un(),n=f.useRef(null);var i;const s=(i=e.targetDocument)!==null&&i!==void 0?i:t.targetDocument;return f.useEffect(()=>{if(s!=null&&s.defaultView&&n.current)return Cw(n.current,s.defaultView)},[n,s]),n}function Gx(){const{targetDocument:e}=un(),t=f.useRef(null);return f.useEffect(()=>{const n=e==null?void 0:e.defaultView;if(n){const i=Hx(n);return t.current=i,()=>{Kx(i),t.current=null}}},[e]),t}function Xx(e){const t=Gx(),n=qe(e);f.useEffect(()=>{const i=t.current;if(i){const s=a=>{n(a)};return i.subscribe(s),s(i.isNavigatingWithKeyboard()),()=>{i.unsubscribe(s)}}},[t,n])}function Bw(){const e=Gx();return f.useCallback(t=>{var n;(n=e.current)===null||n===void 0||n.setVal(t)},[e])}const $m={colorNeutralForeground3:"var(--colorNeutralForeground3)",fontSizeBase200:"var(--fontSizeBase200)"},Tw=e=>n=>{const i=f.useRef(n.value),s=f.useRef(null);return s.current||(s.current={value:i,listeners:[]}),nr(()=>{i.current=n.value,xc.unstable_runWithPriority(xc.unstable_NormalPriority,()=>{s.current.listeners.forEach(a=>{a(n.value)})})},[n.value]),f.createElement(e,{value:s.current},n.children)},Qx=e=>{const t=f.createContext({value:{current:e},listeners:[],isDefault:!0});return t.Provider=Tw(t.Provider),delete t.Consumer,t},Pw=(e,t)=>{const n=f.useContext(e),{value:i,listeners:s}=n,a=t(i.current),[,o]=f.useReducer(u=>u+1,0),l=f.useRef(t),c=f.useRef(a);return nr(()=>{l.current=t,c.current=a}),nr(()=>{const u=m=>{try{const d=l.current(m);Object.is(c.current,d)||o()}catch{}};return s.push(u),u(i.current),()=>{const m=s.indexOf(u);m!==-1&&s.splice(m,1)}},[s,i]),a};function Zx(e){return!f.useContext(e).isDefault}const ns="Enter",qn=" ",Ew="Tab",qm="ArrowDown",Wl="ArrowUp",Dw="End",Aw="Home",Rw="PageDown",Ow="PageUp",Iw="Escape";function Lw(e,t){const{disabled:n,disabledFocusable:i=!1,["aria-disabled"]:s,onClick:a,onKeyDown:o,onKeyUp:l,...c}=t??{},u=typeof s=="string"?s==="true":s,m=n||i||u,d=qe(g=>{m?(g.preventDefault(),g.stopPropagation()):a==null||a(g)}),h=qe(g=>{if(o==null||o(g),g.isDefaultPrevented())return;const p=g.key;if(m&&(p===ns||p===qn)){g.preventDefault(),g.stopPropagation();return}if(p===qn){g.preventDefault();return}else p===ns&&(g.preventDefault(),g.currentTarget.click())}),x=qe(g=>{if(l==null||l(g),g.isDefaultPrevented())return;const p=g.key;if(m&&(p===ns||p===qn)){g.preventDefault(),g.stopPropagation();return}p===qn&&(g.preventDefault(),g.currentTarget.click())});if(e==="button"||e===void 0)return{...c,disabled:n&&!i,"aria-disabled":i?!0:u,onClick:i?void 0:d,onKeyUp:i?void 0:l,onKeyDown:i?void 0:o};{const g=!!c.href;let p=g?void 0:"button";!p&&m&&(p="link");const w={role:p,tabIndex:i||!g&&!n?0:void 0,...c,onClick:d,onKeyUp:x,onKeyDown:h,"aria-disabled":m};return e==="a"&&m&&(w.href=void 0),w}}const ft=()=>{},Mw={controller:{active:ft,blur:ft,find:ft,first:ft,focus:ft,focusLastActive:ft,scrollActiveIntoView:ft,last:ft,next:ft,prev:ft,showAttributes:ft,hideAttributes:ft,showFocusVisibleAttributes:ft,hideFocusVisibleAttributes:ft}},Ld=f.createContext(void 0),Yx=Ld.Provider,Jx=()=>{var e;return(e=f.useContext(Ld))!==null&&e!==void 0?e:Mw},Fw=()=>!!f.useContext(Ld);function $w(e){const{matchOption:t}=e,{targetDocument:n}=un(),i=f.useRef(null),s=f.useRef(null),a=f.useCallback(c=>pi(c)&&t(c)?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP,[t]),o=f.useCallback(c=>{c&&n?(s.current=c,i.current=n.createTreeWalker(c,NodeFilter.SHOW_ELEMENT,a)):(s.current=null,i.current=null)},[n,a]);return{optionWalker:f.useMemo(()=>({first:()=>!i.current||!s.current?null:(i.current.currentNode=s.current,i.current.firstChild()),last:()=>!i.current||!s.current?null:(i.current.currentNode=s.current,i.current.lastChild()),next:()=>i.current?i.current.nextNode():null,prev:()=>i.current?i.current.previousNode():null,find:(c,u)=>{if(!i.current||!s.current)return null;const m=u?n==null?void 0:n.getElementById(u):null;i.current.currentNode=m??s.current;let d=i.current.currentNode;for(;d&&!c(d.id);)d=i.current.nextNode();return d},setCurrent:c=>{i.current&&(i.current.currentNode=c)}}),[n]),listboxCallbackRef:o}}const Vm="data-activedescendant",Tr="data-activedescendant-focusvisible",Um=e=>{if(!e)return;const t=ev(e.parentElement);if(!t)return;const{offsetHeight:n}=e,i=tv(e,t),{scrollMarginTop:s,scrollMarginBottom:a}=qw(e),{offsetHeight:o,scrollTop:l}=t,c=i-sl+o,m=2;c?t.scrollTo(0,i-s-m):u&&t.scrollTo(0,i+n+a-o+m)},ev=e=>e?e.scrollHeight>e.offsetHeight?e:ev(e.parentElement):null,tv=(e,t)=>!e||e===t?0:e.contains(t)?t.offsetTop*-1:e.offsetTop+tv(e.offsetParent,t),qw=e=>{var t;const n=(t=e.ownerDocument)===null||t===void 0?void 0:t.defaultView;if(!n)return{scrollMarginTop:0,scrollMarginBottom:0};const i=n.getComputedStyle(e);var s;const a=(s=ga(i.scrollMarginTop))!==null&&s!==void 0?s:ga(i.scrollMarginBlockStart);var o;const l=(o=ga(i.scrollMarginBottom))!==null&&o!==void 0?o:ga(i.scrollMarginBlockEnd);return{scrollMarginTop:a,scrollMarginBottom:l}},ga=e=>e?parseInt(e,10):0,Vw=e=>new CustomEvent("activedescendantchange",{bubbles:!0,cancelable:!1,composed:!0,detail:e});function nv(e){const{imperativeRef:t,matchOption:n}=e,i=f.useRef(!1),s=f.useRef(!0),a=f.useRef(null),o=f.useRef(null),l=f.useRef(null),c=f.useRef(!0),u=f.useCallback(()=>{var j;(j=l.current)===null||j===void 0||j.removeAttribute("aria-activedescendant")},[]),m=f.useCallback(j=>{if(j&&(a.current=j),c.current&&a.current){var k;(k=l.current)===null||k===void 0||k.setAttribute("aria-activedescendant",a.current)}},[]);Xx(j=>{i.current=j;const k=p();k&&(j&&s.current?k.setAttribute(Tr,""):k.removeAttribute(Tr))});const d=qe(n),h=f.useRef(null),{optionWalker:x,listboxCallbackRef:g}=$w({matchOption:d}),p=f.useCallback(()=>{var j;return(j=h.current)===null||j===void 0?void 0:j.querySelector(`#${a.current}`)},[h]),w=f.useCallback(j=>{s.current=j;const k=p();k&&(j&&i.current?k.setAttribute(Tr,""):k.removeAttribute(Tr))},[p]),b=f.useCallback(()=>{const j=p();j&&(j.removeAttribute(Vm),j.removeAttribute(Tr)),u(),o.current=a.current,a.current=null;var k;return(k=j==null?void 0:j.id)!==null&&k!==void 0?k:null},[p,u]),v=f.useCallback(j=>{if(!j)return;const k=b();Um(j),m(j.id),j.setAttribute(Vm,""),i.current&&s.current&&j.setAttribute(Tr,"");const N=Vw({id:j.id,previousId:k});j.dispatchEvent(N)},[b,m]),y=f.useMemo(()=>({first:({passive:j}={})=>{const k=x.first();return j||v(k),k==null?void 0:k.id},last:({passive:j}={})=>{const k=x.last();return j||v(k),k==null?void 0:k.id},next:({passive:j}={})=>{const k=p();if(!k)return;x.setCurrent(k);const N=x.next();return j||v(N),N==null?void 0:N.id},prev:({passive:j}={})=>{const k=p();if(!k)return;x.setCurrent(k);const N=x.prev();return j||v(N),N==null?void 0:N.id},blur:()=>{b()},active:()=>{var j;return(j=p())===null||j===void 0?void 0:j.id},focus:j=>{if(!h.current)return;const k=h.current.querySelector(`#${j}`);k&&v(k)},focusLastActive:()=>{if(!h.current||!o.current)return;const j=h.current.querySelector(`#${o.current}`);if(j)return v(j),!0},find(j,{passive:k,startFrom:N}={}){const _=x.find(j,N);return k||v(_),_==null?void 0:_.id},scrollActiveIntoView:()=>{if(!h.current)return;const j=p();j&&Um(j)},showAttributes(){c.current=!0,m()},hideAttributes(){c.current=!1,u()},showFocusVisibleAttributes(){w(!0)},hideFocusVisibleAttributes(){w(!1)}}),[x,h,m,u,v,b,p,w]);return f.useImperativeHandle(t,()=>y),{listboxRef:Yn(h,g),activeParentRef:l,controller:y}}const Uw=Pe({root:{mc9l5x:"f1w7gpdv",Bg96gwp:"fez10in"},rtl:{Bz10aip:"f13rod7r"}},{d:[".f1w7gpdv{display:inline;}",".fez10in{line-height:0;}",".f13rod7r{transform:scaleX(-1);}"]}),Hw=(e,t)=>{const{filled:n,title:i,primaryFill:s="currentColor",...a}=e,o={...a,fill:s},l=Uw(),c=bw();return o.className=_e(l.root,(t==null?void 0:t.flipInRtl)&&(c==null?void 0:c.textDirection)==="rtl"&&l.rtl,o.className),i&&(o["aria-label"]=i),!o["aria-label"]&&!o["aria-labelledby"]?o["aria-hidden"]=!0:o.role="img",o},Kw=Pe({root:{B8gzw0y:"f1dd5bof"}},{m:[["@media (forced-colors: active){.f1dd5bof{forced-color-adjust:auto;}}",{m:"(forced-colors: active)"}]]}),Ww="fui-Icon",Ft=(e,t,n,i)=>{const s=t==="1em"?"20":t,a=f.forwardRef((o,l)=>{const c=Kw(),u=Hw(o,{flipInRtl:i==null?void 0:i.flipInRtl}),m={...u,className:_e(Ww,u.className,c.root),ref:l,width:t,height:t,viewBox:`0 0 ${s} ${s}`,xmlns:"http://www.w3.org/2000/svg"};return typeof n=="string"?f.createElement("svg",{...m,dangerouslySetInnerHTML:{__html:n}}):f.createElement("svg",m,...n.map(d=>f.createElement("path",{d,fill:m.fill})))});return a.displayName=e,a},Gw=Ft("CheckmarkFilled","1em",["M7.03 13.9 3.56 10a.75.75 0 0 0-1.12 1l4 4.5c.29.32.79.34 1.09.03l10.5-10.5a.75.75 0 0 0-1.06-1.06l-9.94 9.94Z"]),Xw=Ft("ChevronDownRegular","1em",["M15.85 7.65c.2.2.2.5 0 .7l-5.46 5.49a.55.55 0 0 1-.78 0L4.15 8.35a.5.5 0 1 1 .7-.7L10 12.8l5.15-5.16c.2-.2.5-.2.7 0Z"]),Qw=Ft("DismissRegular","1em",["m4.09 4.22.06-.07a.5.5 0 0 1 .63-.06l.07.06L10 9.29l5.15-5.14a.5.5 0 0 1 .63-.06l.07.06c.18.17.2.44.06.63l-.06.07L10.71 10l5.14 5.15c.18.17.2.44.06.63l-.06.07a.5.5 0 0 1-.63.06l-.07-.06L10 10.71l-5.15 5.14a.5.5 0 0 1-.63.06l-.07-.06a.5.5 0 0 1-.06-.63l.06-.07L9.29 10 4.15 4.85a.5.5 0 0 1-.06-.63l.06-.07-.06.07Z"]),Zw=Ft("Open24Regular","24",["M6.25 4.5c-.97 0-1.75.78-1.75 1.75v11.5c0 .97.78 1.75 1.75 1.75h11.5c.97 0 1.75-.78 1.75-1.75v-4a.75.75 0 0 1 1.5 0v4c0 1.8-1.46 3.25-3.25 3.25H6.25A3.25 3.25 0 0 1 3 17.75V6.25C3 4.45 4.46 3 6.25 3h4a.75.75 0 0 1 0 1.5h-4ZM13 3.75c0-.41.34-.75.75-.75h6.5c.41 0 .75.34.75.75v6.5a.75.75 0 0 1-1.5 0V5.56l-5.22 5.22a.75.75 0 0 1-1.06-1.06l5.22-5.22h-4.69a.75.75 0 0 1-.75-.75Z"]),Yw=Ft("Warning12Filled","12",["M4.28 1.97a2 2 0 0 1 3.43 0l3 5A2 2 0 0 1 9 10H3A2 2 0 0 1 1.3 6.98l3-5ZM6 6.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM6 3.5a.5.5 0 0 0-.5.5v1.5a.5.5 0 0 0 1 0V4a.5.5 0 0 0-.5-.5Z"]),Jw=Ft("Add24Regular","24",["M12 3.25c.41 0 .75.34.75.75v7.25H20a.75.75 0 0 1 0 1.5h-7.25V20a.75.75 0 0 1-1.5 0v-7.25H4a.75.75 0 0 1 0-1.5h7.25V4c0-.41.34-.75.75-.75Z"]),ek=Ft("Edit24Regular","24",["M20.95 3.05a3.58 3.58 0 0 0-5.06 0L3.94 15c-.4.4-.7.92-.82 1.48l-1.1 4.6a.75.75 0 0 0 .9.9l4.6-1.1A3.1 3.1 0 0 0 9 20.07L20.95 8.11a3.58 3.58 0 0 0 0-5.06Zm-4 1.06a2.08 2.08 0 1 1 2.94 2.94l-.89.89L16.06 5l.9-.9ZM15 6.06 17.94 9l-10 10a1.6 1.6 0 0 1-.76.43l-3.42.8.82-3.4c.06-.3.21-.56.42-.77l10-10Z"]),tk=Ft("ArrowDownload24Regular","24",["M18.25 20.5a.75.75 0 1 1 0 1.5h-13a.75.75 0 1 1 0-1.5h13Zm-6.6-18.49h.1c.38 0 .7.28.74.64l.01.1v13.7l3.72-3.73a.75.75 0 0 1 .98-.07l.08.07c.27.27.3.68.07.98l-.07.08-5 5a.75.75 0 0 1-.97.07l-.09-.07-5-5a.75.75 0 0 1 .98-1.13l.08.07L11 16.43V2.76c0-.38.28-.7.65-.75h.1-.1Z"]),nk=Ft("Checkmark12Filled","12",["M9.76 3.2c.3.29.32.76.04 1.06l-4.25 4.5a.75.75 0 0 1-1.08.02L2.22 6.53a.75.75 0 0 1 1.06-1.06l1.7 1.7L8.7 3.24a.75.75 0 0 1 1.06-.04Z"]),rk=Ft("CheckmarkCircle12Filled","12",["M1 6a5 5 0 1 1 10 0A5 5 0 0 1 1 6Zm7.35-.9a.5.5 0 1 0-.7-.7L5.5 6.54 4.35 5.4a.5.5 0 1 0-.7.7l1.5 1.5c.2.2.5.2.7 0l2.5-2.5Z"]),rv=Ft("Delete24Regular","24",["M10 5h4a2 2 0 1 0-4 0ZM8.5 5a3.5 3.5 0 1 1 7 0h5.75a.75.75 0 0 1 0 1.5h-1.32l-1.17 12.11A3.75 3.75 0 0 1 15.03 22H8.97a3.75 3.75 0 0 1-3.73-3.39L4.07 6.5H2.75a.75.75 0 0 1 0-1.5H8.5Zm2 4.75a.75.75 0 0 0-1.5 0v7.5a.75.75 0 0 0 1.5 0v-7.5ZM14.25 9c.41 0 .75.34.75.75v7.5a.75.75 0 0 1-1.5 0v-7.5c0-.41.34-.75.75-.75Zm-7.52 9.47a2.25 2.25 0 0 0 2.24 2.03h6.06c1.15 0 2.12-.88 2.24-2.03L18.42 6.5H5.58l1.15 11.97Z"]),ik=Ft("DiamondDismiss12Filled","12",["M4.59 1.09a2 2 0 0 1 2.82 0l3.5 3.5a2 2 0 0 1 0 2.82l-3.5 3.5a2 2 0 0 1-2.82 0l-3.5-3.5a2 2 0 0 1 0-2.83l3.5-3.5Zm3.26 3.06a.5.5 0 0 0-.7 0L6 5.29 4.85 4.15a.5.5 0 0 0-.7.7L5.29 6 4.15 7.15a.5.5 0 0 0 .7.7L6 6.71l1.15 1.14a.5.5 0 1 0 .7-.7L6.71 6l1.14-1.15a.5.5 0 0 0 0-.7Z"]),Hm="data-popper-is-intersecting",Km="data-popper-escaped",Wm="data-popper-reference-hidden",sk="data-popper-placement",yu="fui-positioningend",ak=({options:e})=>e,iv=f.createContext(void 0);iv.Provider;const ok=()=>{var e;return(e=f.useContext(iv))!==null&&e!==void 0?e:ak},lk=["top","right","bottom","left"],Sr=Math.min,At=Math.max,vo=Math.round,cn=e=>({x:e,y:e}),ck={left:"right",right:"left",bottom:"top",top:"bottom"};function ju(e,t,n){return At(e,Sr(t,n))}function kn(e,t){return typeof e=="function"?e(t):e}function Sn(e){return e.split("-")[0]}function ki(e){return e.split("-")[1]}function Md(e){return e==="x"?"y":"x"}function Fd(e){return e==="y"?"height":"width"}function an(e){const t=e[0];return t==="t"||t==="b"?"y":"x"}function $d(e){return Md(an(e))}function uk(e,t,n){n===void 0&&(n=!1);const i=ki(e),s=$d(e),a=Fd(s);let o=s==="x"?i===(n?"end":"start")?"right":"left":i==="start"?"bottom":"top";return t.reference[a]>t.floating[a]&&(o=bo(o)),[o,bo(o)]}function dk(e){const t=bo(e);return[_u(e),t,_u(t)]}function _u(e){return e.includes("start")?e.replace("start","end"):e.replace("end","start")}const Gm=["left","right"],Xm=["right","left"],fk=["top","bottom"],mk=["bottom","top"];function pk(e,t,n){switch(e){case"top":case"bottom":return n?t?Xm:Gm:t?Gm:Xm;case"left":case"right":return t?fk:mk;default:return[]}}function hk(e,t,n,i){const s=ki(e);let a=pk(Sn(e),n==="start",i);return s&&(a=a.map(o=>o+"-"+s),t&&(a=a.concat(a.map(_u)))),a}function bo(e){const t=Sn(e);return ck[t]+e.slice(t.length)}function gk(e){return{top:0,right:0,bottom:0,left:0,...e}}function sv(e){return typeof e!="number"?gk(e):{top:e,right:e,bottom:e,left:e}}function yo(e){const{x:t,y:n,width:i,height:s}=e;return{width:i,height:s,top:n,left:t,right:t+i,bottom:n+s,x:t,y:n}}function Qm(e,t,n){let{reference:i,floating:s}=e;const a=an(t),o=$d(t),l=Fd(o),c=Sn(t),u=a==="y",m=i.x+i.width/2-s.width/2,d=i.y+i.height/2-s.height/2,h=i[l]/2-s[l]/2;let x;switch(c){case"top":x={x:m,y:i.y-s.height};break;case"bottom":x={x:m,y:i.y+i.height};break;case"right":x={x:i.x+i.width,y:d};break;case"left":x={x:i.x-s.width,y:d};break;default:x={x:i.x,y:i.y}}switch(ki(t)){case"start":x[o]-=h*(n&&u?-1:1);break;case"end":x[o]+=h*(n&&u?-1:1);break}return x}async function av(e,t){var n;t===void 0&&(t={});const{x:i,y:s,platform:a,rects:o,elements:l,strategy:c}=e,{boundary:u="clippingAncestors",rootBoundary:m="viewport",elementContext:d="floating",altBoundary:h=!1,padding:x=0}=kn(t,e),g=sv(x),w=l[h?d==="floating"?"reference":"floating":d],b=yo(await a.getClippingRect({element:(n=await(a.isElement==null?void 0:a.isElement(w)))==null||n?w:w.contextElement||await(a.getDocumentElement==null?void 0:a.getDocumentElement(l.floating)),boundary:u,rootBoundary:m,strategy:c})),v=d==="floating"?{x:i,y:s,width:o.floating.width,height:o.floating.height}:o.reference,y=await(a.getOffsetParent==null?void 0:a.getOffsetParent(l.floating)),j=await(a.isElement==null?void 0:a.isElement(y))?await(a.getScale==null?void 0:a.getScale(y))||{x:1,y:1}:{x:1,y:1},k=yo(a.convertOffsetParentRelativeRectToViewportRelativeRect?await a.convertOffsetParentRelativeRectToViewportRelativeRect({elements:l,rect:v,offsetParent:y,strategy:c}):v);return{top:(b.top-k.top+g.top)/j.y,bottom:(k.bottom-b.bottom+g.bottom)/j.y,left:(b.left-k.left+g.left)/j.x,right:(k.right-b.right+g.right)/j.x}}const xk=50,vk=async(e,t,n)=>{const{placement:i="bottom",strategy:s="absolute",middleware:a=[],platform:o}=n,l=o.detectOverflow?o:{...o,detectOverflow:av},c=await(o.isRTL==null?void 0:o.isRTL(t));let u=await o.getElementRects({reference:e,floating:t,strategy:s}),{x:m,y:d}=Qm(u,i,c),h=i,x=0;const g={};for(let p=0;p({name:"arrow",options:e,async fn(t){const{x:n,y:i,placement:s,rects:a,platform:o,elements:l,middlewareData:c}=t,{element:u,padding:m=0}=kn(e,t)||{};if(u==null)return{};const d=sv(m),h={x:n,y:i},x=$d(s),g=Fd(x),p=await o.getDimensions(u),w=x==="y",b=w?"top":"left",v=w?"bottom":"right",y=w?"clientHeight":"clientWidth",j=a.reference[g]+a.reference[x]-h[x]-a.floating[g],k=h[x]-a.reference[x],N=await(o.getOffsetParent==null?void 0:o.getOffsetParent(u));let _=N?N[y]:0;(!_||!await(o.isElement==null?void 0:o.isElement(N)))&&(_=l.floating[y]||a.floating[g]);const S=j/2-k/2,P=_/2-p[g]/2-1,D=Sr(d[b],P),F=Sr(d[v],P),T=D,M=_-p[g]-F,z=_/2-p[g]/2+S,A=ju(T,z,M),$=!c.arrow&&ki(s)!=null&&z!==A&&a.reference[g]/2-(zz<=0)){var F,T;const z=(((F=a.flip)==null?void 0:F.index)||0)+1,A=_[z];if(A&&(!(d==="alignment"?v!==an(A):!1)||D.every(R=>an(R.placement)===v?R.overflows[0]>0:!0)))return{data:{index:z,overflows:D},reset:{placement:A}};let $=(T=D.filter(Z=>Z.overflows[0]<=0).sort((Z,R)=>Z.overflows[1]-R.overflows[1])[0])==null?void 0:T.placement;if(!$)switch(x){case"bestFit":{var M;const Z=(M=D.filter(R=>{if(N){const K=an(R.placement);return K===v||K==="y"}return!0}).map(R=>[R.placement,R.overflows.filter(K=>K>0).reduce((K,X)=>K+X,0)]).sort((R,K)=>R[1]-K[1])[0])==null?void 0:M[0];Z&&($=Z);break}case"initialPlacement":$=l;break}if(s!==$)return{reset:{placement:$}}}return{}}}};function Zm(e,t){return{top:e.top-t.height,right:e.right-t.width,bottom:e.bottom-t.height,left:e.left-t.width}}function Ym(e){return lk.some(t=>e[t]>=0)}const jk=function(e){return e===void 0&&(e={}),{name:"hide",options:e,async fn(t){const{rects:n,platform:i}=t,{strategy:s="referenceHidden",...a}=kn(e,t);switch(s){case"referenceHidden":{const o=await i.detectOverflow(t,{...a,elementContext:"reference"}),l=Zm(o,n.reference);return{data:{referenceHiddenOffsets:l,referenceHidden:Ym(l)}}}case"escaped":{const o=await i.detectOverflow(t,{...a,altBoundary:!0}),l=Zm(o,n.floating);return{data:{escapedOffsets:l,escaped:Ym(l)}}}default:return{}}}}},ov=new Set(["left","top"]);async function _k(e,t){const{placement:n,platform:i,elements:s}=e,a=await(i.isRTL==null?void 0:i.isRTL(s.floating)),o=Sn(n),l=ki(n),c=an(n)==="y",u=ov.has(o)?-1:1,m=a&&c?-1:1,d=kn(t,e);let{mainAxis:h,crossAxis:x,alignmentAxis:g}=typeof d=="number"?{mainAxis:d,crossAxis:0,alignmentAxis:null}:{mainAxis:d.mainAxis||0,crossAxis:d.crossAxis||0,alignmentAxis:d.alignmentAxis};return l&&typeof g=="number"&&(x=l==="end"?g*-1:g),c?{x:x*m,y:h*u}:{x:h*u,y:x*m}}const wk=function(e){return e===void 0&&(e=0),{name:"offset",options:e,async fn(t){var n,i;const{x:s,y:a,placement:o,middlewareData:l}=t,c=await _k(t,e);return o===((n=l.offset)==null?void 0:n.placement)&&(i=l.arrow)!=null&&i.alignmentOffset?{}:{x:s+c.x,y:a+c.y,data:{...c,placement:o}}}}},kk=function(e){return e===void 0&&(e={}),{name:"shift",options:e,async fn(t){const{x:n,y:i,placement:s,platform:a}=t,{mainAxis:o=!0,crossAxis:l=!1,limiter:c={fn:b=>{let{x:v,y}=b;return{x:v,y}}},...u}=kn(e,t),m={x:n,y:i},d=await a.detectOverflow(t,u),h=an(Sn(s)),x=Md(h);let g=m[x],p=m[h];if(o){const b=x==="y"?"top":"left",v=x==="y"?"bottom":"right",y=g+d[b],j=g-d[v];g=ju(y,g,j)}if(l){const b=h==="y"?"top":"left",v=h==="y"?"bottom":"right",y=p+d[b],j=p-d[v];p=ju(y,p,j)}const w=c.fn({...t,[x]:g,[h]:p});return{...w,data:{x:w.x-n,y:w.y-i,enabled:{[x]:o,[h]:l}}}}}},Sk=function(e){return e===void 0&&(e={}),{options:e,fn(t){const{x:n,y:i,placement:s,rects:a,middlewareData:o}=t,{offset:l=0,mainAxis:c=!0,crossAxis:u=!0}=kn(e,t),m={x:n,y:i},d=an(s),h=Md(d);let x=m[h],g=m[d];const p=kn(l,t),w=typeof p=="number"?{mainAxis:p,crossAxis:0}:{mainAxis:0,crossAxis:0,...p};if(c){const y=h==="y"?"height":"width",j=a.reference[h]-a.floating[y]+w.mainAxis,k=a.reference[h]+a.reference[y]-w.mainAxis;xk&&(x=k)}if(u){var b,v;const y=h==="y"?"width":"height",j=ov.has(Sn(s)),k=a.reference[d]-a.floating[y]+(j&&((b=o.offset)==null?void 0:b[d])||0)+(j?0:w.crossAxis),N=a.reference[d]+a.reference[y]+(j?0:((v=o.offset)==null?void 0:v[d])||0)-(j?w.crossAxis:0);gN&&(g=N)}return{[h]:x,[d]:g}}}},Nk=function(e){return e===void 0&&(e={}),{name:"size",options:e,async fn(t){var n,i;const{placement:s,rects:a,platform:o,elements:l}=t,{apply:c=()=>{},...u}=kn(e,t),m=await o.detectOverflow(t,u),d=Sn(s),h=ki(s),x=an(s)==="y",{width:g,height:p}=a.floating;let w,b;d==="top"||d==="bottom"?(w=d,b=h===(await(o.isRTL==null?void 0:o.isRTL(l.floating))?"start":"end")?"left":"right"):(b=d,w=h==="end"?"top":"bottom");const v=p-m.top-m.bottom,y=g-m.left-m.right,j=Sr(p-m[w],v),k=Sr(g-m[b],y),N=!t.middlewareData.shift;let _=j,S=k;if((n=t.middlewareData.shift)!=null&&n.enabled.x&&(S=y),(i=t.middlewareData.shift)!=null&&i.enabled.y&&(_=v),N&&!h){const D=At(m.left,0),F=At(m.right,0),T=At(m.top,0),M=At(m.bottom,0);x?S=g-2*(D!==0||F!==0?D+F:At(m.left,m.right)):_=p-2*(T!==0||M!==0?T+M:At(m.top,m.bottom))}await c({...t,availableWidth:S,availableHeight:_});const P=await o.getDimensions(l.floating);return g!==P.width||p!==P.height?{reset:{rects:!0}}:{}}}};function Yo(){return typeof window<"u"}function Si(e){return lv(e)?(e.nodeName||"").toLowerCase():"#document"}function kt(e){var t;return(e==null||(t=e.ownerDocument)==null?void 0:t.defaultView)||window}function Bn(e){var t;return(t=(lv(e)?e.ownerDocument:e.document)||window.document)==null?void 0:t.documentElement}function lv(e){return Yo()?e instanceof Node||e instanceof kt(e).Node:!1}function Zt(e){return Yo()?e instanceof Element||e instanceof kt(e).Element:!1}function Tn(e){return Yo()?e instanceof HTMLElement||e instanceof kt(e).HTMLElement:!1}function Jm(e){return!Yo()||typeof ShadowRoot>"u"?!1:e instanceof ShadowRoot||e instanceof kt(e).ShadowRoot}function As(e){const{overflow:t,overflowX:n,overflowY:i,display:s}=Yt(e);return/auto|scroll|overlay|hidden|clip/.test(t+i+n)&&s!=="inline"&&s!=="contents"}function Ck(e){return/^(table|td|th)$/.test(Si(e))}function Jo(e){try{if(e.matches(":popover-open"))return!0}catch{}try{return e.matches(":modal")}catch{return!1}}const zk=/transform|translate|scale|rotate|perspective|filter/,Bk=/paint|layout|strict|content/,lr=e=>!!e&&e!=="none";let Gl;function qd(e){const t=Zt(e)?Yt(e):e;return lr(t.transform)||lr(t.translate)||lr(t.scale)||lr(t.rotate)||lr(t.perspective)||!Vd()&&(lr(t.backdropFilter)||lr(t.filter))||zk.test(t.willChange||"")||Bk.test(t.contain||"")}function Tk(e){let t=rr(e);for(;Tn(t)&&!hi(t);){if(qd(t))return t;if(Jo(t))return null;t=rr(t)}return null}function Vd(){return Gl==null&&(Gl=typeof CSS<"u"&&CSS.supports&&CSS.supports("-webkit-backdrop-filter","none")),Gl}function hi(e){return/^(html|body|#document)$/.test(Si(e))}function Yt(e){return kt(e).getComputedStyle(e)}function el(e){return Zt(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function rr(e){if(Si(e)==="html")return e;const t=e.assignedSlot||e.parentNode||Jm(e)&&e.host||Bn(e);return Jm(t)?t.host:t}function cv(e){const t=rr(e);return hi(t)?e.ownerDocument?e.ownerDocument.body:e.body:Tn(t)&&As(t)?t:cv(t)}function uv(e,t,n){var i;t===void 0&&(t=[]);const s=cv(e),a=s===((i=e.ownerDocument)==null?void 0:i.body),o=kt(s);return a?(wu(o),t.concat(o,o.visualViewport||[],As(s)?s:[],[])):t.concat(s,uv(s,[]))}function wu(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}function dv(e){const t=Yt(e);let n=parseFloat(t.width)||0,i=parseFloat(t.height)||0;const s=Tn(e),a=s?e.offsetWidth:n,o=s?e.offsetHeight:i,l=vo(n)!==a||vo(i)!==o;return l&&(n=a,i=o),{width:n,height:i,$:l}}function fv(e){return Zt(e)?e:e.contextElement}function ti(e){const t=fv(e);if(!Tn(t))return cn(1);const n=t.getBoundingClientRect(),{width:i,height:s,$:a}=dv(t);let o=(a?vo(n.width):n.width)/i,l=(a?vo(n.height):n.height)/s;return(!o||!Number.isFinite(o))&&(o=1),(!l||!Number.isFinite(l))&&(l=1),{x:o,y:l}}const Pk=cn(0);function mv(e){const t=kt(e);return!Vd()||!t.visualViewport?Pk:{x:t.visualViewport.offsetLeft,y:t.visualViewport.offsetTop}}function Ek(e,t,n){return t===void 0&&(t=!1),!n||t&&n!==kt(e)?!1:t}function Ns(e,t,n,i){t===void 0&&(t=!1),n===void 0&&(n=!1);const s=e.getBoundingClientRect(),a=fv(e);let o=cn(1);t&&(i?Zt(i)&&(o=ti(i)):o=ti(e));const l=Ek(a,n,i)?mv(a):cn(0);let c=(s.left+l.x)/o.x,u=(s.top+l.y)/o.y,m=s.width/o.x,d=s.height/o.y;if(a){const h=kt(a),x=i&&Zt(i)?kt(i):i;let g=h,p=wu(g);for(;p&&i&&x!==g;){const w=ti(p),b=p.getBoundingClientRect(),v=Yt(p),y=b.left+(p.clientLeft+parseFloat(v.paddingLeft))*w.x,j=b.top+(p.clientTop+parseFloat(v.paddingTop))*w.y;c*=w.x,u*=w.y,m*=w.x,d*=w.y,c+=y,u+=j,g=kt(p),p=wu(g)}}return yo({width:m,height:d,x:c,y:u})}function tl(e,t){const n=el(e).scrollLeft;return t?t.left+n:Ns(Bn(e)).left+n}function pv(e,t){const n=e.getBoundingClientRect(),i=n.left+t.scrollLeft-tl(e,n),s=n.top+t.scrollTop;return{x:i,y:s}}function Dk(e){let{elements:t,rect:n,offsetParent:i,strategy:s}=e;const a=s==="fixed",o=Bn(i),l=t?Jo(t.floating):!1;if(i===o||l&&a)return n;let c={scrollLeft:0,scrollTop:0},u=cn(1);const m=cn(0),d=Tn(i);if((d||!d&&!a)&&((Si(i)!=="body"||As(o))&&(c=el(i)),d)){const x=Ns(i);u=ti(i),m.x=x.x+i.clientLeft,m.y=x.y+i.clientTop}const h=o&&!d&&!a?pv(o,c):cn(0);return{width:n.width*u.x,height:n.height*u.y,x:n.x*u.x-c.scrollLeft*u.x+m.x+h.x,y:n.y*u.y-c.scrollTop*u.y+m.y+h.y}}function Ak(e){return Array.from(e.getClientRects())}function Rk(e){const t=Bn(e),n=el(e),i=e.ownerDocument.body,s=At(t.scrollWidth,t.clientWidth,i.scrollWidth,i.clientWidth),a=At(t.scrollHeight,t.clientHeight,i.scrollHeight,i.clientHeight);let o=-n.scrollLeft+tl(e);const l=-n.scrollTop;return Yt(i).direction==="rtl"&&(o+=At(t.clientWidth,i.clientWidth)-s),{width:s,height:a,x:o,y:l}}const ep=25;function Ok(e,t){const n=kt(e),i=Bn(e),s=n.visualViewport;let a=i.clientWidth,o=i.clientHeight,l=0,c=0;if(s){a=s.width,o=s.height;const m=Vd();(!m||m&&t==="fixed")&&(l=s.offsetLeft,c=s.offsetTop)}const u=tl(i);if(u<=0){const m=i.ownerDocument,d=m.body,h=getComputedStyle(d),x=m.compatMode==="CSS1Compat"&&parseFloat(h.marginLeft)+parseFloat(h.marginRight)||0,g=Math.abs(i.clientWidth-d.clientWidth-x);g<=ep&&(a-=g)}else u<=ep&&(a+=u);return{width:a,height:o,x:l,y:c}}function Ik(e,t){const n=Ns(e,!0,t==="fixed"),i=n.top+e.clientTop,s=n.left+e.clientLeft,a=Tn(e)?ti(e):cn(1),o=e.clientWidth*a.x,l=e.clientHeight*a.y,c=s*a.x,u=i*a.y;return{width:o,height:l,x:c,y:u}}function tp(e,t,n){let i;if(t==="viewport")i=Ok(e,n);else if(t==="document")i=Rk(Bn(e));else if(Zt(t))i=Ik(t,n);else{const s=mv(e);i={x:t.x-s.x,y:t.y-s.y,width:t.width,height:t.height}}return yo(i)}function hv(e,t){const n=rr(e);return n===t||!Zt(n)||hi(n)?!1:Yt(n).position==="fixed"||hv(n,t)}function Lk(e,t){const n=t.get(e);if(n)return n;let i=uv(e,[]).filter(l=>Zt(l)&&Si(l)!=="body"),s=null;const a=Yt(e).position==="fixed";let o=a?rr(e):e;for(;Zt(o)&&!hi(o);){const l=Yt(o),c=qd(o);!c&&l.position==="fixed"&&(s=null),(a?!c&&!s:!c&&l.position==="static"&&!!s&&(s.position==="absolute"||s.position==="fixed")||As(o)&&!c&&hv(e,o))?i=i.filter(m=>m!==o):s=l,o=rr(o)}return t.set(e,i),i}function Mk(e){let{element:t,boundary:n,rootBoundary:i,strategy:s}=e;const o=[...n==="clippingAncestors"?Jo(t)?[]:Lk(t,this._c):[].concat(n),i],l=tp(t,o[0],s);let c=l.top,u=l.right,m=l.bottom,d=l.left;for(let h=1;h{const i=new Map,s={platform:Uk,...n},a={...s.platform,_c:i};return vk(e,t,{...s,platform:a})};function xv(e){const t=e.split("-");return{side:t[0],alignment:t[1]}}const Yk=e=>e.nodeName==="HTML"?e:e.parentNode||e.host,Jk=e=>{var t;if(e.nodeType!==1)return{};const n=(t=e.ownerDocument)===null||t===void 0?void 0:t.defaultView;return n?n.getComputedStyle(e,null):{}},nl=e=>{const t=e&&Yk(e);if(!t)return document.body;switch(t.nodeName){case"HTML":case"BODY":return t.ownerDocument.body;case"#document":return t.body}const{overflow:n,overflowX:i,overflowY:s}=Jk(t);return/(auto|scroll|overlay)/.test(n+s+i)?t:nl(t)},eS=e=>{var t;const n=nl(e);return n?n!==((t=n.ownerDocument)===null||t===void 0?void 0:t.body):!1};function Ud(e,t){if(t==="window")return e==null?void 0:e.ownerDocument.documentElement;if(t==="clippingParents")return"clippingAncestors";if(t==="scrollParent"){let n=nl(e);return n.nodeName==="BODY"&&(n=e==null?void 0:e.ownerDocument.documentElement),n}return t}function vv(e,t){if(typeof e=="number")return e;const{start:n,end:i,...s}=e,a=s,o=t?"end":"start",l=t?"start":"end";return e[o]&&(a.left=e[o]),e[l]&&(a.right=e[l]),a}const tS=e=>({above:"top",below:"bottom",before:e?"right":"left",after:e?"left":"right"}),nS=()=>({start:"start",end:"end",top:"start",bottom:"end",center:void 0}),rS=(e,t)=>{const n=e==="above"||e==="below",i=t==="top"||t==="bottom";return n&&i||!n&&!i},bv=(e,t,n)=>{const i=rS(t,e)?"center":e,s=t&&tS(n)[t],a=i&&nS()[i];return s&&a?`${s}-${a}`:s},iS=()=>({top:"above",bottom:"below",right:"after",left:"before"}),sS=e=>e==="above"||e==="below"?{start:"start",end:"end"}:{start:"top",end:"bottom"},aS=e=>{const{side:t,alignment:n}=xv(e),i=iS()[t],s=n&&sS(i)[n];return{position:i,alignment:s}},oS={above:{position:"above",align:"center"},"above-start":{position:"above",align:"start"},"above-end":{position:"above",align:"end"},below:{position:"below",align:"center"},"below-start":{position:"below",align:"start"},"below-end":{position:"below",align:"end"},before:{position:"before",align:"center"},"before-top":{position:"before",align:"top"},"before-bottom":{position:"before",align:"bottom"},after:{position:"after",align:"center"},"after-top":{position:"after",align:"top"},"after-bottom":{position:"after",align:"bottom"}};function yv(e){return e==null?{}:typeof e=="string"?oS[e]:e}function Ql(e,t,n){const i=f.useRef(!0),[s]=f.useState(()=>({value:e,callback:t,facade:{get current(){return s.value},set current(a){const o=s.value;o!==a&&(s.value=a,s.callback(a,o))}}}));return nr(()=>{i.current=!1},[]),s.callback=t,s.facade}function lS(e){let t;return()=>(t||(t=new Promise(n=>{Promise.resolve().then(()=>{t=void 0,n(e())})})),t)}function cS(e){const{arrow:t,middlewareData:n}=e;if(!n.arrow||!t)return;const{x:i,y:s}=n.arrow;Object.assign(t.style,{left:i!=null?`${i}px`:"",top:s!=null?`${s}px`:""})}function uS(e){var t,n,i;const{container:s,placement:a,middlewareData:o,strategy:l,lowPPI:c,coordinates:u,useTransform:m=!0}=e;if(!s)return;s.setAttribute(sk,a),s.removeAttribute(Hm),o.intersectionObserver.intersecting&&s.setAttribute(Hm,""),s.removeAttribute(Km),!((t=o.hide)===null||t===void 0)&&t.escaped&&s.setAttribute(Km,""),s.removeAttribute(Wm),!((n=o.hide)===null||n===void 0)&&n.referenceHidden&&s.setAttribute(Wm,"");const d=((i=s.ownerDocument.defaultView)===null||i===void 0?void 0:i.devicePixelRatio)||1,h=Math.round(u.x*d)/d,x=Math.round(u.y*d)/d;if(Object.assign(s.style,{position:l}),m){Object.assign(s.style,{transform:c?`translate(${h}px, ${x}px)`:`translate3d(${h}px, ${x}px, 0)`});return}Object.assign(s.style,{left:`${h}px`,top:`${x}px`})}const dS=e=>{switch(e){case"always":case!0:return{applyMaxWidth:!0,applyMaxHeight:!0};case"width-always":case"width":return{applyMaxWidth:!0,applyMaxHeight:!1};case"height-always":case"height":return{applyMaxWidth:!1,applyMaxHeight:!0};default:return!1}};function sp(e){const t=[];let n=e;for(;n;){const i=nl(n);if(e.ownerDocument.body===i){t.push(i);break}if(i.nodeName==="BODY"&&i!==e.ownerDocument.body)break;t.push(i),n=i}return t}function fS(e,t){return new e.ResizeObserver(t)}function mS(e){let t=!1;const{container:n,target:i,arrow:s,strategy:a,middleware:o,placement:l,useTransform:c=!0,disableUpdateOnResize:u=!1}=e,m=n.ownerDocument.defaultView;if(!i||!n||!m)return{updatePosition:()=>{},dispose:()=>{}};const d=u?null:fS(m,b=>{b.every(y=>y.contentRect.width>0&&y.contentRect.height>0)&&p()});let h=!0;const x=new Set;Object.assign(n.style,{position:"fixed",left:0,top:0,margin:0});const g=()=>{t||(h&&(sp(n).forEach(b=>x.add(b)),pi(i)&&sp(i).forEach(b=>x.add(b)),x.forEach(b=>{b.addEventListener("scroll",p,{passive:!0})}),d==null||d.observe(n),pi(i)&&(d==null||d.observe(i)),h=!1),Object.assign(n.style,{position:a}),Zk(i,n,{placement:l,middleware:o,strategy:a}).then(({x:b,y:v,middlewareData:y,placement:j})=>{t||(cS({arrow:s,middlewareData:y}),uS({container:n,middlewareData:y,placement:j,coordinates:{x:b,y:v},lowPPI:((m==null?void 0:m.devicePixelRatio)||1)<=1,strategy:a,useTransform:c}),n.dispatchEvent(new CustomEvent(yu,{detail:{placement:j}})))}).catch(b=>{}))},p=lS(()=>g()),w=()=>{t=!0,m&&(m.removeEventListener("scroll",p),m.removeEventListener("resize",p)),x.forEach(b=>{b.removeEventListener("scroll",p)}),x.clear(),d==null||d.disconnect()};return m&&(m.addEventListener("scroll",p,{passive:!0}),m.addEventListener("resize",p)),p(),{updatePosition:p,dispose:w}}function pS(){return{name:"coverTarget",fn:e=>{const{placement:t,rects:n,x:i,y:s}=e,a=xv(t).side,o={x:i,y:s};switch(a){case"bottom":o.y-=n.reference.height;break;case"top":o.y+=n.reference.height;break;case"left":o.x+=n.reference.width;break;case"right":o.x-=n.reference.width;break}return o}}}function hS(e){const{hasScrollableElement:t,flipBoundary:n,container:i,fallbackPositions:s=[],isRtl:a}=e,o=s.reduce((l,c)=>{const{position:u,align:m}=yv(c),d=bv(m,u,a);return d&&l.push(d),l},[]);return Gk({...t&&{boundary:"clippingAncestors"},...n&&{altBoundary:!0,boundary:Ud(i,n)},fallbackStrategy:"bestFit",...o.length&&{fallbackPlacements:o}})}function gS(){return{name:"intersectionObserver",fn:async e=>{const t=e.rects.floating,n=await Hk(e,{altBoundary:!0}),i=n.top0,s=n.bottom0;return{data:{intersecting:i||s}}}}}const xS=e=>({name:"resetMaxSize",fn({middlewareData:t,elements:n}){var i;if(!((i=t.resetMaxSize)===null||i===void 0)&&i.maxSizeAlreadyReset)return{};const{applyMaxWidth:s,applyMaxHeight:a}=e;return s&&(n.floating.style.removeProperty("box-sizing"),n.floating.style.removeProperty("max-width"),n.floating.style.removeProperty("width")),a&&(n.floating.style.removeProperty("box-sizing"),n.floating.style.removeProperty("max-height"),n.floating.style.removeProperty("height")),{data:{maxSizeAlreadyReset:!0},reset:{rects:!0}}}});function vS(e,t){const{container:n,overflowBoundary:i,overflowBoundaryPadding:s,isRtl:a}=t;return Xk({...s&&{padding:vv(s,a)},...i&&{altBoundary:!0,boundary:Ud(n,i)},apply({availableHeight:o,availableWidth:l,elements:c,rects:u}){const m=(x,g,p)=>{if(x&&(c.floating.style.setProperty("box-sizing","border-box"),c.floating.style.setProperty(`max-${g}`,`${p}px`),u.floating[g]>p)){c.floating.style.setProperty(g,`${p}px`);const w=g==="width"?"x":"y";c.floating.style.getPropertyValue(`overflow-${w}`)||c.floating.style.setProperty(`overflow-${w}`,"auto")}},{applyMaxWidth:d,applyMaxHeight:h}=e;m(d,"width",l),m(h,"height",o)}})}function bS(e){return!e||typeof e=="number"||typeof e=="object"?e:({rects:{floating:t,reference:n},placement:i})=>{const{position:s,alignment:a}=aS(i);return e({positionedRect:t,targetRect:n,position:s,alignment:a})}}function yS(e){const t=bS(e);return Kk(t)}function jS(e){const{hasScrollableElement:t,shiftToCoverTarget:n,disableTether:i,overflowBoundary:s,container:a,overflowBoundaryPadding:o,isRtl:l}=e;return Wk({...t&&{boundary:"clippingAncestors"},...n&&{crossAxis:!0,limiter:ip({crossAxis:!0,mainAxis:!1})},...i&&{crossAxis:i==="all",limiter:ip({crossAxis:i!=="all",mainAxis:!1})},...o&&{padding:vv(o,l)},...s&&{altBoundary:!0,boundary:Ud(a,s)}})}const ap="--fui-match-target-size";function _S(){return{name:"matchTargetSize",fn:async e=>{const{rects:{reference:t,floating:n},elements:{floating:i},middlewareData:{matchTargetSize:{matchTargetSizeAttempt:s=!1}={}}}=e;if(t.width===n.width||s)return{};const{width:a}=t;return i.style.setProperty(ap,`${a}px`),i.style.width||(i.style.width=`var(${ap})`),{data:{matchTargetSizeAttempt:!0},reset:{rects:!0}}}}}function wS(e,t){const{align:n,arrowPadding:i,autoSize:s,coverTarget:a,disableUpdateOnResize:o,flipBoundary:l,offset:c,overflowBoundary:u,pinned:m,position:d,unstable_disableTether:h,strategy:x,overflowBoundaryPadding:g,fallbackPositions:p,useTransform:w,matchTargetSize:b,shiftToCoverTarget:v}=t;return f.useCallback((y,j)=>e({container:y,arrow:j,options:{autoSize:s,disableUpdateOnResize:o,matchTargetSize:b,offset:c,strategy:x,coverTarget:a,flipBoundary:l,overflowBoundary:u,useTransform:w,overflowBoundaryPadding:g,pinned:m,arrowPadding:i,align:n,fallbackPositions:p,shiftToCoverTarget:v,position:d,unstable_disableTether:h}}),[s,o,b,c,x,a,l,u,w,g,m,i,n,p,v,d,h,e])}function kS(e){const{dir:t,targetDocument:n}=un(),i=t==="rtl",s=wS(ok(),e),{positionFixed:a}=e;return f.useCallback((o,l)=>{const c=eS(o),u=s(o,l),{autoSize:m,disableUpdateOnResize:d,matchTargetSize:h,offset:x,coverTarget:g,flipBoundary:p,overflowBoundary:w,useTransform:b,overflowBoundaryPadding:v,pinned:y,position:j,arrowPadding:k,strategy:N,align:_,fallbackPositions:S,shiftToCoverTarget:P,unstable_disableTether:D}=u,F=dS(m),T=[F&&xS(F),h&&_S(),x&&yS(x),g&&pS(),!y&&hS({container:o,flipBoundary:p,hasScrollableElement:c,isRtl:i,fallbackPositions:S}),jS({container:o,hasScrollableElement:c,overflowBoundary:w,disableTether:D,overflowBoundaryPadding:v,isRtl:i,shiftToCoverTarget:P}),F&&vS(F,{container:o,overflowBoundary:w,overflowBoundaryPadding:v,isRtl:i}),gS(),l&&Qk({element:l,padding:k}),rp({strategy:"referenceHidden"}),rp({strategy:"escaped"}),!1].filter(Boolean);return{placement:bv(_,j,i),middleware:T,strategy:N??a?"fixed":"absolute",disableUpdateOnResize:d,useTransform:b}},[s,i,n,a])}function SS(e){"use no memo";const t=f.useRef(null),n=f.useRef(null),i=f.useRef(null),s=f.useRef(null),a=f.useRef(null),{enabled:o=!0}=e,l=kS(e),c=f.useCallback(()=>{t.current&&t.current.dispose(),t.current=null;var g;const p=(g=i.current)!==null&&g!==void 0?g:n.current;o&&Ox()&&p&&s.current&&(t.current=mS({container:s.current,target:p,arrow:a.current,...l(s.current,a.current)}))},[o,l]),u=qe(g=>{i.current=g,c()});f.useImperativeHandle(e.positioningRef,()=>({updatePosition:()=>{var g;return(g=t.current)===null||g===void 0?void 0:g.updatePosition()},setTarget:g=>{e.target,u(g)}}),[e.target,u]),nr(()=>{var g;u((g=e.target)!==null&&g!==void 0?g:null)},[e.target,u]),nr(()=>{c()},[c]);const m=Ql(null,g=>{n.current!==g&&(n.current=g,c())}),d=qe(g=>{var p;return(p=e.onPositioningEnd)===null||p===void 0?void 0:p.call(e,g)}),h=Ql(null,g=>{if(s.current!==g){var p;(p=s.current)===null||p===void 0||p.removeEventListener(yu,d),g==null||g.addEventListener(yu,d),s.current=g,c()}}),x=Ql(null,g=>{a.current!==g&&(a.current=g,c())});return{targetRef:m,containerRef:h,arrowRef:x}}function NS(e){return pi(e)?{element:e}:typeof e=="object"?e===null?{element:null}:e:{}}const CS=br.useInsertionEffect?br.useInsertionEffect:nr,jv=Symbol.for("fui-portal-style-ref-count"),zS="[data-portal-node]{position:absolute;top:0;left:0;right:0;z-index:1000000}",_v="fui-portal-styles";function wv(e){var t;return(t=e[jv])!==null&&t!==void 0?t:0}function ku(e,t){e[jv]=t}function BS(e){var t;const n=wv(e);if(n>0){ku(e,n+1);return}const i=e.createElement("style");i.id=_v,e.head.prepend(i),(t=i.sheet)===null||t===void 0||t.insertRule(zS),ku(e,1)}function TS(e){const t=wv(e);if(t===0)return;const n=t-1;if(n===0){var i;(i=e.head.querySelector(`#${_v}`))===null||i===void 0||i.remove()}ku(e,n)}function PS(e){const{targetDocument:t}=un();CS(()=>{if(!(e||!t))return BS(t),()=>TS(t)},[e,t])}const kv=br.useInsertionEffect,ES=e=>{"use no memo";const{className:t,dir:n,focusVisibleRef:i,targetNode:s}=e,a=f.useMemo(()=>{if(s===void 0||e.disabled)return null;const o=s.ownerDocument.createElement("div");return s.appendChild(o),o},[s,e.disabled]);return f.useMemo(()=>{a&&(a.className=t,a.setAttribute("dir",n),a.setAttribute("data-portal-node","true"),i.current=a)},[t,n,a,i]),f.useEffect(()=>()=>{a==null||a.remove()},[a]),a},DS=()=>{let e;function t(i,s){return e||(s&&(e=i.ownerDocument.createElement("div"),i.appendChild(e)),e)}function n(){e&&(e.remove(),e=void 0)}return{get:t,dispose:n}},AS=e=>{"use no memo";const{className:t,dir:n,focusVisibleRef:i,targetNode:s}=e,[a]=f.useState(DS),o=f.useMemo(()=>s===void 0||e.disabled?null:new Proxy({},{get(l,c){if(c==="nodeType")return 1;if(c==="remove"){const d=a.get(s,!1);return d&&d.childNodes.length===0&&a.dispose(),()=>{}}const u=a.get(s,!0),m=u?u[c]:void 0;return typeof m=="function"?m.bind(u):m},set(l,c,u){const m=c==="_virtual"||c==="focusVisible",d=m?a.get(s,!1):a.get(s,!0);return m&&!d?!0:d?(Object.assign(d,{[c]:u}),!0):!1}}),[a,s,e.disabled]);return kv(()=>{if(o)return o.setAttribute("class",t),o.setAttribute("dir",n),o.setAttribute("data-portal-node","true"),i.current=o,()=>{o.removeAttribute("class"),o.removeAttribute("dir")}},[t,n,o,i]),f.useEffect(()=>()=>{o==null||o.remove()},[o]),o},RS=kv?AS:ES,OS=e=>{"use no memo";const{targetDocument:t,dir:n}=un(),i=tw(),s=zw(),a=Y_(),o={dir:n,disabled:e.disabled,focusVisibleRef:s,className:[a,e.className].filter(Boolean).join(" "),targetNode:i??(t==null?void 0:t.body)};return PS(e.disabled),RS(o)},IS=e=>{const{element:t,className:n}=NS(e.mountNode),i=f.useRef(null),s=OS({disabled:!!t,className:n}),a=t??s,o={children:e.children,mountNode:a,virtualParentRootRef:i};return f.useEffect(()=>{if(!a)return;const l=i.current,c=a.contains(l);if(l&&!c)return Lm(a,l),()=>{Lm(a,void 0)}},[i,a]),o},LS=e=>f.createElement("span",{hidden:!0,ref:e.virtualParentRootRef},e.mountNode&&fo.createPortal(f.createElement(f.Fragment,null,e.children,f.createElement("span",{hidden:!0})),e.mountNode)),Sv=e=>{const t=IS(e);return LS(t)};Sv.displayName="Portal";const MS=e=>{const{iconOnly:t,iconPosition:n}=e;return wn(e.root,{children:[n!=="after"&&e.icon&&ke(e.icon,{}),!t&&e.root.children,n==="after"&&e.icon&&ke(e.icon,{})]})},Nv=f.createContext(void 0),FS={};Nv.Provider;const $S=()=>{var e;return(e=f.useContext(Nv))!==null&&e!==void 0?e:FS},qS=(e,t)=>{const{size:n}=$S(),{appearance:i="secondary",shape:s="rounded",size:a=n??"medium",...o}=e,l=VS(o,t);return{appearance:i,shape:s,size:a,...l}},VS=(e,t)=>{const{icon:n,iconPosition:i="before",...s}=e,a=et(n,{elementType:"span"});var o,l;return{disabled:(o=e.disabled)!==null&&o!==void 0?o:!1,disabledFocusable:(l=e.disabledFocusable)!==null&&l!==void 0?l:!1,iconPosition:i,iconOnly:!!(a!=null&&a.children&&!e.children),components:{root:"button",icon:"span"},root:vt(Lw(s.as,s),{elementType:"button",defaultProps:{ref:t,type:e.as!=="a"?"button":void 0}}),icon:a}},op={root:"fui-Button",icon:"fui-Button__icon"},US=en("r1f29ykk",null,{r:[".r1f29ykk{align-items:center;box-sizing:border-box;display:inline-flex;justify-content:center;text-decoration-line:none;vertical-align:middle;margin:0;overflow:hidden;background-color:var(--colorNeutralBackground1);color:var(--colorNeutralForeground1);border:var(--strokeWidthThin) solid var(--colorNeutralStroke1);font-family:var(--fontFamilyBase);outline-style:none;padding:5px var(--spacingHorizontalM);min-width:96px;border-radius:var(--borderRadiusMedium);font-size:var(--fontSizeBase300);font-weight:var(--fontWeightSemibold);line-height:var(--lineHeightBase300);transition-duration:var(--durationFaster);transition-property:background,border,color;transition-timing-function:var(--curveEasyEase);}",".r1f29ykk:hover{background-color:var(--colorNeutralBackground1Hover);border-color:var(--colorNeutralStroke1Hover);color:var(--colorNeutralForeground1Hover);cursor:pointer;}",".r1f29ykk:hover:active,.r1f29ykk:active:focus-visible{background-color:var(--colorNeutralBackground1Pressed);border-color:var(--colorNeutralStroke1Pressed);color:var(--colorNeutralForeground1Pressed);outline-style:none;}",".r1f29ykk[data-fui-focus-visible]{border-color:var(--colorStrokeFocus2);border-radius:var(--borderRadiusMedium);border-width:1px;outline:var(--strokeWidthThick) solid var(--colorTransparentStroke);box-shadow:0 0 0 var(--strokeWidthThin) var(--colorStrokeFocus2) inset;z-index:1;}"],s:["@media screen and (prefers-reduced-motion: reduce){.r1f29ykk{transition-duration:0.01ms;}}","@media (forced-colors: active){.r1f29ykk:focus{border-color:ButtonText;}.r1f29ykk:hover{background-color:HighlightText;border-color:Highlight;color:Highlight;forced-color-adjust:none;}.r1f29ykk:hover:active,.r1f29ykk:active:focus-visible{background-color:HighlightText;border-color:Highlight;color:Highlight;forced-color-adjust:none;}}","@supports (-moz-appearance:button){.r1f29ykk[data-fui-focus-visible]{box-shadow:0 0 0 calc(var(--strokeWidthThin) + 0.25px) var(--colorStrokeFocus2) inset;}}"]}),HS=en("rywnvv2",null,[".rywnvv2{align-items:center;display:inline-flex;justify-content:center;font-size:20px;height:20px;width:20px;--fui-Button__icon--spacing:var(--spacingHorizontalSNudge);}"]),KS=Pe({outline:{De3pzq:"f1c21dwh",Jwef8y:"fjxutwb",Bpjbzib:"fkoldzo"},primary:{De3pzq:"ffp7eso",g2u3we:"f1p3nwhy",h3c5rm:["f11589ue","f1pdflbu"],B9xav0g:"f1q5o8ev",zhjwy3:["f1pdflbu","f11589ue"],sj55zd:"f1phragk",Jwef8y:"f15wkkf3",Bgoe8wy:"f1s2uweq",Bwzppfd:["fr80ssc","fecsdlb"],oetu4i:"f1ukrpxl",gg5e9n:["fecsdlb","fr80ssc"],Bi91k9c:"f1rq72xc",Bpjbzib:"f1ksv2xa",im15vp:"fhvnf4x",Hjvxdg:["fb6swo4","f232fm2"],Gpfmf1:"f1klyf7k",ustxxc:["f232fm2","fb6swo4"],Brsut9c:"f1d6mv4x",By8wz76:"f1nz3ub2",Bcq6wej:"fag2qd2",Jcjdmf:["fmvhcg7","f14bpyus"],sc4o1m:"f1o3dhpw",Bosien3:["f14bpyus","fmvhcg7"],B7iucu3:"fqc85l4",B8gzw0y:"f1h3a8gf",Bbkh6qg:"fkiggi6",F230oe:"f8gmj8i",Bdw8ktp:["f1ap8nzx","fjag8bx"],Bj1xduy:"f1igan7k",Bhh2cfd:["fjag8bx","f1ap8nzx"],Bahaeuw:"f1v3eptx",Bv2bamp:"f1ysmecq",vxuvv6:"faulsx",Bli9q98:["f79t15f","f8qmx7k"],Bx2tt8t:"fbtzoaq",yad0b3:["f8qmx7k","f79t15f"],j2fop7:"fd4bjan"},secondary:{},subtle:{De3pzq:"fhovq9v",g2u3we:"f1p3nwhy",h3c5rm:["f11589ue","f1pdflbu"],B9xav0g:"f1q5o8ev",zhjwy3:["f1pdflbu","f11589ue"],sj55zd:"fkfq4zb",Jwef8y:"f1t94bn6",Bgoe8wy:"f1s2uweq",Bwzppfd:["fr80ssc","fecsdlb"],oetu4i:"f1ukrpxl",gg5e9n:["fecsdlb","fr80ssc"],Bi91k9c:"fnwyq0v",Bk3fhr4:"ft1hn21",Bmfj8id:"fuxngvv",Bbdnnc7:"fy5bs14",Bpjbzib:"f1q1yqic",im15vp:"fhvnf4x",Hjvxdg:["fb6swo4","f232fm2"],Gpfmf1:"f1klyf7k",ustxxc:["f232fm2","fb6swo4"],Brsut9c:"fwga7ee",Bqou3pl:"f1nhwcv0",Bsnehw8:"f1gm6xmp",wsxvnf:"f1xxsver",Bahaeuw:"f1v3eptx",Buhizc3:"fivsta0",j2fop7:"fd4bjan",Bqabnb4:"f3m6zum"},transparent:{De3pzq:"f1c21dwh",g2u3we:"f1p3nwhy",h3c5rm:["f11589ue","f1pdflbu"],B9xav0g:"f1q5o8ev",zhjwy3:["f1pdflbu","f11589ue"],sj55zd:"fkfq4zb",Jwef8y:"fjxutwb",Bgoe8wy:"f1s2uweq",Bwzppfd:["fr80ssc","fecsdlb"],oetu4i:"f1ukrpxl",gg5e9n:["fecsdlb","fr80ssc"],Bi91k9c:"f139oj5f",Bk3fhr4:"ft1hn21",Bmfj8id:"fuxngvv",Bpjbzib:"fkoldzo",im15vp:"fhvnf4x",Hjvxdg:["fb6swo4","f232fm2"],Gpfmf1:"f1klyf7k",ustxxc:["f232fm2","fb6swo4"],Brsut9c:"f1l983o9",Bqou3pl:"f1nhwcv0",Bsnehw8:"f1gm6xmp",Bbkh6qg:"fxoo9op",Bahaeuw:"f1v3eptx",Bv2bamp:"f1i0gk12",j2fop7:"fd4bjan"},circular:{Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"f44lkw9"},rounded:{},square:{Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"f1fabniw"},small:{Bf4jedk:"fh7ncta",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"fneth5b",Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"ft85np5",Be2twd7:"fy9rknc",Bhrd7zp:"figsok6",Bg96gwp:"fwrc4pm"},smallWithIcon:{Byoj8tv:"f1brlhvm",z8tnut:"f1sl3k7w"},medium:{},large:{Bf4jedk:"f14es27b",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"f4db1ww",Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"ft85np5",Be2twd7:"fod5ikn",Bhrd7zp:"fl43uef",Bg96gwp:"faaz57k"},largeWithIcon:{Byoj8tv:"fy7v416",z8tnut:"f1a1bwwz"}},{d:[".f1c21dwh{background-color:var(--colorTransparentBackground);}",".ffp7eso{background-color:var(--colorBrandBackground);}",".f1p3nwhy{border-top-color:transparent;}",".f11589ue{border-right-color:transparent;}",".f1pdflbu{border-left-color:transparent;}",".f1q5o8ev{border-bottom-color:transparent;}",".f1phragk{color:var(--colorNeutralForegroundOnBrand);}",".fhovq9v{background-color:var(--colorSubtleBackground);}",".fkfq4zb{color:var(--colorNeutralForeground2);}",[".f44lkw9{border-radius:var(--borderRadiusCircular);}",{p:-1}],[".f1fabniw{border-radius:var(--borderRadiusNone);}",{p:-1}],".fh7ncta{min-width:64px;}",[".fneth5b{padding:3px var(--spacingHorizontalS);}",{p:-1}],[".ft85np5{border-radius:var(--borderRadiusMedium);}",{p:-1}],".fy9rknc{font-size:var(--fontSizeBase200);}",".figsok6{font-weight:var(--fontWeightRegular);}",".fwrc4pm{line-height:var(--lineHeightBase200);}",".f1brlhvm{padding-bottom:1px;}",".f1sl3k7w{padding-top:1px;}",".f14es27b{min-width:96px;}",[".f4db1ww{padding:8px var(--spacingHorizontalL);}",{p:-1}],[".ft85np5{border-radius:var(--borderRadiusMedium);}",{p:-1}],".fod5ikn{font-size:var(--fontSizeBase400);}",".fl43uef{font-weight:var(--fontWeightSemibold);}",".faaz57k{line-height:var(--lineHeightBase400);}",".fy7v416{padding-bottom:7px;}",".f1a1bwwz{padding-top:7px;}"],h:[".fjxutwb:hover{background-color:var(--colorTransparentBackgroundHover);}",".fkoldzo:hover:active,.fkoldzo:active:focus-visible{background-color:var(--colorTransparentBackgroundPressed);}",".f15wkkf3:hover{background-color:var(--colorBrandBackgroundHover);}",".f1s2uweq:hover{border-top-color:transparent;}",".fr80ssc:hover{border-right-color:transparent;}",".fecsdlb:hover{border-left-color:transparent;}",".f1ukrpxl:hover{border-bottom-color:transparent;}",".f1rq72xc:hover{color:var(--colorNeutralForegroundOnBrand);}",".f1ksv2xa:hover:active,.f1ksv2xa:active:focus-visible{background-color:var(--colorBrandBackgroundPressed);}",".fhvnf4x:hover:active,.fhvnf4x:active:focus-visible{border-top-color:transparent;}",".fb6swo4:hover:active,.fb6swo4:active:focus-visible{border-right-color:transparent;}",".f232fm2:hover:active,.f232fm2:active:focus-visible{border-left-color:transparent;}",".f1klyf7k:hover:active,.f1klyf7k:active:focus-visible{border-bottom-color:transparent;}",".f1d6mv4x:hover:active,.f1d6mv4x:active:focus-visible{color:var(--colorNeutralForegroundOnBrand);}",".f1t94bn6:hover{background-color:var(--colorSubtleBackgroundHover);}",".fnwyq0v:hover{color:var(--colorNeutralForeground2Hover);}",".ft1hn21:hover .fui-Icon-filled{display:inline;}",".fuxngvv:hover .fui-Icon-regular{display:none;}",".fy5bs14:hover .fui-Button__icon{color:var(--colorNeutralForeground2BrandHover);}",".f1q1yqic:hover:active,.f1q1yqic:active:focus-visible{background-color:var(--colorSubtleBackgroundPressed);}",".fwga7ee:hover:active,.fwga7ee:active:focus-visible{color:var(--colorNeutralForeground2Pressed);}",".f1nhwcv0:hover:active .fui-Icon-filled,.f1nhwcv0:active:focus-visible .fui-Icon-filled{display:inline;}",".f1gm6xmp:hover:active .fui-Icon-regular,.f1gm6xmp:active:focus-visible .fui-Icon-regular{display:none;}",".f1xxsver:hover:active .fui-Button__icon,.f1xxsver:active:focus-visible .fui-Button__icon{color:var(--colorNeutralForeground2BrandPressed);}",".f139oj5f:hover{color:var(--colorNeutralForeground2BrandHover);}",".f1l983o9:hover:active,.f1l983o9:active:focus-visible{color:var(--colorNeutralForeground2BrandPressed);}"],m:[["@media (forced-colors: active){.f1nz3ub2{background-color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fag2qd2{border-top-color:HighlightText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f14bpyus{border-left-color:HighlightText;}.fmvhcg7{border-right-color:HighlightText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1o3dhpw{border-bottom-color:HighlightText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fqc85l4{color:HighlightText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1h3a8gf{forced-color-adjust:none;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fkiggi6:hover{background-color:HighlightText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f8gmj8i:hover{border-top-color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1ap8nzx:hover{border-right-color:Highlight;}.fjag8bx:hover{border-left-color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1igan7k:hover{border-bottom-color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1v3eptx:hover{color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1ysmecq:hover:active,.f1ysmecq:active:focus-visible{background-color:HighlightText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.faulsx:hover:active,.faulsx:active:focus-visible{border-top-color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f79t15f:hover:active,.f79t15f:active:focus-visible{border-right-color:Highlight;}.f8qmx7k:hover:active,.f8qmx7k:active:focus-visible{border-left-color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fbtzoaq:hover:active,.fbtzoaq:active:focus-visible{border-bottom-color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fd4bjan:hover:active,.fd4bjan:active:focus-visible{color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fivsta0:hover .fui-Button__icon{color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f3m6zum:hover:active .fui-Button__icon,.f3m6zum:active:focus-visible .fui-Button__icon{color:Highlight;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fxoo9op:hover{background-color:var(--colorTransparentBackground);}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1i0gk12:hover:active,.f1i0gk12:active:focus-visible{background-color:var(--colorTransparentBackground);}}",{m:"(forced-colors: active)"}]]}),WS=Pe({base:{De3pzq:"f1bg9a2p",g2u3we:"f1jj8ep1",h3c5rm:["f15xbau","fy0fskl"],B9xav0g:"f4ikngz",zhjwy3:["fy0fskl","f15xbau"],sj55zd:"f1s2aq7o",Bceei9c:"fdrzuqr",Bfinmwp:"f15x8b5r",Jwef8y:"f1falr9n",Bgoe8wy:"f12mpcsy",Bwzppfd:["f1gwvigk","f18rmfxp"],oetu4i:"f1jnshp0",gg5e9n:["f18rmfxp","f1gwvigk"],Bi91k9c:"fvgxktp",eoavqd:"fphbwmw",Bk3fhr4:"f19vpps7",Bmfj8id:"fv5swzo",Bbdnnc7:"f1al02dq",Bpjbzib:"f1jct5ie",im15vp:"f13txml0",Hjvxdg:["f1ncddno","f1axfvow"],Gpfmf1:"f1z04ada",ustxxc:["f1axfvow","f1ncddno"],Brsut9c:"f1uhomfy",Bses4qk:"fy9mucy",Bqou3pl:"f1g9va8i",Bsnehw8:"fwgvudy",wsxvnf:"fom6jww"},highContrast:{By8wz76:"f14ptb23",Bcq6wej:"f9dbb4x",Jcjdmf:["f3qs60o","f5u9ap2"],sc4o1m:"fwd1oij",Bosien3:["f5u9ap2","f3qs60o"],B7iucu3:"f1cyfu5x",Grqk0h:"f127ot8j",h3ptyc:"f19etb0b",Buw724y:["f4f984j","fw441p0"],Buk7464:"f3d22hf",Hwei09:["fw441p0","f4f984j"],Bbkh6qg:"fj8k9ua",F230oe:"fifrq0d",Bdw8ktp:["f196mwp7","fnekfq"],Bj1xduy:"f1l6uprw",Bhh2cfd:["fnekfq","f196mwp7"],Bahaeuw:"fa9u7a5",Buhizc3:"f1m71e0y",Bv2bamp:"fw24f3",vxuvv6:"f1nznrny",Bli9q98:["fq8nxuu","f1ao3jkc"],Bx2tt8t:"ftoixeo",yad0b3:["f1ao3jkc","fq8nxuu"],j2fop7:"fpmuzpx",Bqabnb4:"f168odog"},outline:{De3pzq:"f1c21dwh",Jwef8y:"f9ql6rf",Bpjbzib:"f9r0db0"},primary:{g2u3we:"f1p3nwhy",h3c5rm:["f11589ue","f1pdflbu"],B9xav0g:"f1q5o8ev",zhjwy3:["f1pdflbu","f11589ue"],Bgoe8wy:"f1s2uweq",Bwzppfd:["fr80ssc","fecsdlb"],oetu4i:"f1ukrpxl",gg5e9n:["fecsdlb","fr80ssc"],im15vp:"fhvnf4x",Hjvxdg:["fb6swo4","f232fm2"],Gpfmf1:"f1klyf7k",ustxxc:["f232fm2","fb6swo4"]},secondary:{},subtle:{De3pzq:"f1c21dwh",g2u3we:"f1p3nwhy",h3c5rm:["f11589ue","f1pdflbu"],B9xav0g:"f1q5o8ev",zhjwy3:["f1pdflbu","f11589ue"],Jwef8y:"f9ql6rf",Bgoe8wy:"f1s2uweq",Bwzppfd:["fr80ssc","fecsdlb"],oetu4i:"f1ukrpxl",gg5e9n:["fecsdlb","fr80ssc"],Bpjbzib:"f9r0db0",im15vp:"fhvnf4x",Hjvxdg:["fb6swo4","f232fm2"],Gpfmf1:"f1klyf7k",ustxxc:["f232fm2","fb6swo4"]},transparent:{De3pzq:"f1c21dwh",g2u3we:"f1p3nwhy",h3c5rm:["f11589ue","f1pdflbu"],B9xav0g:"f1q5o8ev",zhjwy3:["f1pdflbu","f11589ue"],Jwef8y:"f9ql6rf",Bgoe8wy:"f1s2uweq",Bwzppfd:["fr80ssc","fecsdlb"],oetu4i:"f1ukrpxl",gg5e9n:["fecsdlb","fr80ssc"],Bpjbzib:"f9r0db0",im15vp:"fhvnf4x",Hjvxdg:["fb6swo4","f232fm2"],Gpfmf1:"f1klyf7k",ustxxc:["f232fm2","fb6swo4"]}},{d:[".f1bg9a2p{background-color:var(--colorNeutralBackgroundDisabled);}",".f1jj8ep1{border-top-color:var(--colorNeutralStrokeDisabled);}",".f15xbau{border-right-color:var(--colorNeutralStrokeDisabled);}",".fy0fskl{border-left-color:var(--colorNeutralStrokeDisabled);}",".f4ikngz{border-bottom-color:var(--colorNeutralStrokeDisabled);}",".f1s2aq7o{color:var(--colorNeutralForegroundDisabled);}",".fdrzuqr{cursor:not-allowed;}",".f15x8b5r .fui-Button__icon{color:var(--colorNeutralForegroundDisabled);}",".f1c21dwh{background-color:var(--colorTransparentBackground);}",".f1p3nwhy{border-top-color:transparent;}",".f11589ue{border-right-color:transparent;}",".f1pdflbu{border-left-color:transparent;}",".f1q5o8ev{border-bottom-color:transparent;}"],h:[".f1falr9n:hover{background-color:var(--colorNeutralBackgroundDisabled);}",".f12mpcsy:hover{border-top-color:var(--colorNeutralStrokeDisabled);}",".f1gwvigk:hover{border-right-color:var(--colorNeutralStrokeDisabled);}",".f18rmfxp:hover{border-left-color:var(--colorNeutralStrokeDisabled);}",".f1jnshp0:hover{border-bottom-color:var(--colorNeutralStrokeDisabled);}",".fvgxktp:hover{color:var(--colorNeutralForegroundDisabled);}",".fphbwmw:hover{cursor:not-allowed;}",".f19vpps7:hover .fui-Icon-filled{display:none;}",".fv5swzo:hover .fui-Icon-regular{display:inline;}",".f1al02dq:hover .fui-Button__icon{color:var(--colorNeutralForegroundDisabled);}",".f1jct5ie:hover:active,.f1jct5ie:active:focus-visible{background-color:var(--colorNeutralBackgroundDisabled);}",".f13txml0:hover:active,.f13txml0:active:focus-visible{border-top-color:var(--colorNeutralStrokeDisabled);}",".f1ncddno:hover:active,.f1ncddno:active:focus-visible{border-right-color:var(--colorNeutralStrokeDisabled);}",".f1axfvow:hover:active,.f1axfvow:active:focus-visible{border-left-color:var(--colorNeutralStrokeDisabled);}",".f1z04ada:hover:active,.f1z04ada:active:focus-visible{border-bottom-color:var(--colorNeutralStrokeDisabled);}",".f1uhomfy:hover:active,.f1uhomfy:active:focus-visible{color:var(--colorNeutralForegroundDisabled);}",".fy9mucy:hover:active,.fy9mucy:active:focus-visible{cursor:not-allowed;}",".f1g9va8i:hover:active .fui-Icon-filled,.f1g9va8i:active:focus-visible .fui-Icon-filled{display:none;}",".fwgvudy:hover:active .fui-Icon-regular,.fwgvudy:active:focus-visible .fui-Icon-regular{display:inline;}",".fom6jww:hover:active .fui-Button__icon,.fom6jww:active:focus-visible .fui-Button__icon{color:var(--colorNeutralForegroundDisabled);}",".f9ql6rf:hover{background-color:var(--colorTransparentBackground);}",".f9r0db0:hover:active,.f9r0db0:active:focus-visible{background-color:var(--colorTransparentBackground);}",".f1s2uweq:hover{border-top-color:transparent;}",".fr80ssc:hover{border-right-color:transparent;}",".fecsdlb:hover{border-left-color:transparent;}",".f1ukrpxl:hover{border-bottom-color:transparent;}",".fhvnf4x:hover:active,.fhvnf4x:active:focus-visible{border-top-color:transparent;}",".fb6swo4:hover:active,.fb6swo4:active:focus-visible{border-right-color:transparent;}",".f232fm2:hover:active,.f232fm2:active:focus-visible{border-left-color:transparent;}",".f1klyf7k:hover:active,.f1klyf7k:active:focus-visible{border-bottom-color:transparent;}"],m:[["@media (forced-colors: active){.f14ptb23{background-color:ButtonFace;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f9dbb4x{border-top-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f3qs60o{border-right-color:GrayText;}.f5u9ap2{border-left-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fwd1oij{border-bottom-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1cyfu5x{color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f127ot8j .fui-Button__icon{color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f19etb0b:focus{border-top-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f4f984j:focus{border-right-color:GrayText;}.fw441p0:focus{border-left-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f3d22hf:focus{border-bottom-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fj8k9ua:hover{background-color:ButtonFace;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fifrq0d:hover{border-top-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f196mwp7:hover{border-right-color:GrayText;}.fnekfq:hover{border-left-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1l6uprw:hover{border-bottom-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fa9u7a5:hover{color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1m71e0y:hover .fui-Button__icon{color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fw24f3:hover:active,.fw24f3:active:focus-visible{background-color:ButtonFace;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1nznrny:hover:active,.f1nznrny:active:focus-visible{border-top-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f1ao3jkc:hover:active,.f1ao3jkc:active:focus-visible{border-left-color:GrayText;}.fq8nxuu:hover:active,.fq8nxuu:active:focus-visible{border-right-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.ftoixeo:hover:active,.ftoixeo:active:focus-visible{border-bottom-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fpmuzpx:hover:active,.fpmuzpx:active:focus-visible{color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f168odog:hover:active .fui-Button__icon,.f168odog:active:focus-visible .fui-Button__icon{color:GrayText;}}",{m:"(forced-colors: active)"}]]}),GS=Pe({circular:{Bw81rd7:0,kdpuga:0,dm238s:0,B6xbmo0:0,B3whbx2:"f1062rbf"},rounded:{},square:{Bw81rd7:0,kdpuga:0,dm238s:0,B6xbmo0:0,B3whbx2:"fj0ryk1"},primary:{B8q5s1w:"f17t0x8g",Bci5o5g:["f194v5ow","fk7jm04"],n8qw10:"f1qgg65p",Bdrgwmp:["fk7jm04","f194v5ow"],j6ew2k:["fhgccpy","fjo7pq6"],he4mth:"f32wu9k",Byr4aka:"fu5nqqq",lks7q5:["f13prjl2","f1nl83rv"],Bnan3qt:"f1czftr5",k1dn9:["f1nl83rv","f13prjl2"],Bqsb82s:["fixhny3","f18mfu3r"],jg1oma:"feygou5"},small:{Bw81rd7:0,kdpuga:0,dm238s:0,B6xbmo0:0,B3whbx2:"fazmxh"},medium:{},large:{Bw81rd7:0,kdpuga:0,dm238s:0,B6xbmo0:0,B3whbx2:"f1b6alqh"}},{d:[[".f1062rbf[data-fui-focus-visible]{border-radius:var(--borderRadiusCircular);}",{p:-1}],[".fj0ryk1[data-fui-focus-visible]{border-radius:var(--borderRadiusNone);}",{p:-1}],".f17t0x8g[data-fui-focus-visible]{border-top-color:var(--colorStrokeFocus2);}",".f194v5ow[data-fui-focus-visible]{border-right-color:var(--colorStrokeFocus2);}",".fk7jm04[data-fui-focus-visible]{border-left-color:var(--colorStrokeFocus2);}",".f1qgg65p[data-fui-focus-visible]{border-bottom-color:var(--colorStrokeFocus2);}",".fhgccpy[data-fui-focus-visible]{box-shadow:var(--shadow2),0 0 0 var(--strokeWidthThin) var(--colorStrokeFocus2) inset,0 0 0 var(--strokeWidthThick) var(--colorNeutralForegroundOnBrand) inset;}",".fjo7pq6[data-fui-focus-visible]{box-shadow:var(--shadow2),0 0 0 var(--strokeWidthThin) var(--colorStrokeFocus2) inset,0 0 0 var(--strokeWidthThick) var(--colorNeutralForegroundOnBrand) inset;}",".f32wu9k[data-fui-focus-visible]:hover{box-shadow:var(--shadow2),0 0 0 var(--strokeWidthThin) var(--colorStrokeFocus2) inset;}",".fu5nqqq[data-fui-focus-visible]:hover{border-top-color:var(--colorStrokeFocus2);}",".f13prjl2[data-fui-focus-visible]:hover{border-right-color:var(--colorStrokeFocus2);}",".f1nl83rv[data-fui-focus-visible]:hover{border-left-color:var(--colorStrokeFocus2);}",".f1czftr5[data-fui-focus-visible]:hover{border-bottom-color:var(--colorStrokeFocus2);}",[".fazmxh[data-fui-focus-visible]{border-radius:var(--borderRadiusSmall);}",{p:-1}],[".f1b6alqh[data-fui-focus-visible]{border-radius:var(--borderRadiusLarge);}",{p:-1}]],t:["@supports (-moz-appearance:button){.f18mfu3r[data-fui-focus-visible]{box-shadow:var(--shadow2),0 0 0 calc(var(--strokeWidthThin) + 0.25px) var(--colorStrokeFocus2) inset,0 0 0 var(--strokeWidthThick) var(--colorNeutralForegroundOnBrand) inset;}.fixhny3[data-fui-focus-visible]{box-shadow:var(--shadow2),0 0 0 calc(var(--strokeWidthThin) + 0.25px) var(--colorStrokeFocus2) inset,0 0 0 var(--strokeWidthThick) var(--colorNeutralForegroundOnBrand) inset;}}","@supports (-moz-appearance:button){.feygou5[data-fui-focus-visible]:hover{box-shadow:var(--shadow2),0 0 0 calc(var(--strokeWidthThin) + 0.25px) var(--colorStrokeFocus2) inset;}}"]}),XS=Pe({small:{Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"fu97m5z",Bf4jedk:"f17fgpbq",B2u0y6b:"f1jt17bm"},medium:{Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"f18ktai2",Bf4jedk:"fwbmr0d",B2u0y6b:"f44c6la"},large:{Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"f1hbd1aw",Bf4jedk:"f12clzc2",B2u0y6b:"fjy1crr"}},{d:[[".fu97m5z{padding:1px;}",{p:-1}],".f17fgpbq{min-width:24px;}",".f1jt17bm{max-width:24px;}",[".f18ktai2{padding:5px;}",{p:-1}],".fwbmr0d{min-width:32px;}",".f44c6la{max-width:32px;}",[".f1hbd1aw{padding:7px;}",{p:-1}],".f12clzc2{min-width:40px;}",".fjy1crr{max-width:40px;}"]}),QS=Pe({small:{Be2twd7:"fe5j1ua",Bqenvij:"fjamq6b",a9b677:"f64fuq3",Bqrlyyl:"fbaiahx"},medium:{},large:{Be2twd7:"f1rt2boy",Bqenvij:"frvgh55",a9b677:"fq4mcun",Bqrlyyl:"f1exjqw5"},before:{t21cq0:["f1nizpg2","f1a695kz"]},after:{Frg6f3:["f1a695kz","f1nizpg2"]}},{d:[".fe5j1ua{font-size:20px;}",".fjamq6b{height:20px;}",".f64fuq3{width:20px;}",".fbaiahx{--fui-Button__icon--spacing:var(--spacingHorizontalXS);}",".f1rt2boy{font-size:24px;}",".frvgh55{height:24px;}",".fq4mcun{width:24px;}",".f1exjqw5{--fui-Button__icon--spacing:var(--spacingHorizontalSNudge);}",".f1nizpg2{margin-right:var(--fui-Button__icon--spacing);}",".f1a695kz{margin-left:var(--fui-Button__icon--spacing);}"]}),ZS=e=>{"use no memo";const t=US(),n=HS(),i=KS(),s=WS(),a=GS(),o=XS(),l=QS(),{appearance:c,disabled:u,disabledFocusable:m,icon:d,iconOnly:h,iconPosition:x,shape:g,size:p}=e;return e.root.className=_e(op.root,t,c&&i[c],i[p],d&&p==="small"&&i.smallWithIcon,d&&p==="large"&&i.largeWithIcon,i[g],(u||m)&&s.base,(u||m)&&s.highContrast,c&&(u||m)&&s[c],c==="primary"&&a.primary,a[p],a[g],h&&o[p],e.root.className),e.icon&&(e.icon.className=_e(op.icon,n,!!e.root.children&&l[x],l[p],e.icon.className)),e},Jn=f.forwardRef((e,t)=>{const n=qS(e,t);return ZS(n),zn("useButtonStyles_unstable")(n),MS(n)});Jn.displayName="Button";const Cv=f.createContext(void 0),YS=Cv.Provider,JS=()=>f.useContext(Cv),eN=e=>{var t,n,i,s;const{generatedControlId:a,orientation:o,required:l,size:c,validationState:u}=e,m=(t=e.label)===null||t===void 0?void 0:t.htmlFor,d=(n=e.label)===null||n===void 0?void 0:n.id,h=(i=e.validationMessage)===null||i===void 0?void 0:i.id,x=(s=e.hint)===null||s===void 0?void 0:s.id;return{field:f.useMemo(()=>({generatedControlId:a,hintId:x,labelFor:m,labelId:d,orientation:o,required:l,size:c,validationMessageId:h,validationState:u}),[a,x,m,d,o,l,c,h,u])}};function rl(e,t){return zv(JS(),e,t)}function zv(e,t,n){if(!e)return t;t={...t};const{generatedControlId:i,hintId:s,labelFor:a,labelId:o,required:l,validationMessageId:c,validationState:u}=e;if(i){var m,d;(d=(m=t).id)!==null&&d!==void 0||(m.id=i)}if(o&&(!(n!=null&&n.supportsLabelFor)||a!==t.id)){var h,x,g;(g=(h=t)[x="aria-labelledby"])!==null&&g!==void 0||(h[x]=o)}if((c||s)&&(t["aria-describedby"]=[c,s,t==null?void 0:t["aria-describedby"]].filter(Boolean).join(" ")),u==="error"){var p,w,b;(b=(p=t)[w="aria-invalid"])!==null&&b!==void 0||(p[w]=!0)}if(l)if(n!=null&&n.supportsRequired){var v,y;(y=(v=t).required)!==null&&y!==void 0||(v.required=!0)}else{var j,k,N;(N=(j=t)[k="aria-required"])!==null&&N!==void 0||(j[k]=!0)}if(n!=null&&n.supportsSize){var _,S;(S=(_=t).size)!==null&&S!==void 0||(_.size=e.size)}return t}const tN=(e,t)=>{let{children:n}=e;return typeof n=="function"&&(n=n(zv(t.field)||{})),ke(YS,{value:t==null?void 0:t.field,children:wn(e.root,{children:[e.label&&ke(e.label,{}),n,e.validationMessage&&wn(e.validationMessage,{children:[e.validationMessageIcon&&ke(e.validationMessageIcon,{}),e.validationMessage.children]}),e.hint&&ke(e.hint,{})]})})},nN=(e,t)=>{const{weight:n="regular",size:i="medium",...s}=e,a=rN(s,t);return{weight:n,size:i,...a}},rN=(e,t)=>{const{disabled:n=!1,required:i=!1,...s}=e;return{disabled:n,required:et(i===!0?"*":i||void 0,{defaultProps:{"aria-hidden":"true"},elementType:"span"}),components:{root:"label",required:"span"},root:vt(Ds("label",{ref:t,...s}),{elementType:"label"})}},iN=e=>wn(e.root,{children:[e.root.children,e.required&&ke(e.required,{})]}),lp={root:"fui-Label",required:"fui-Label__required"},sN=Pe({root:{Bahqtrf:"fk6fouc",sj55zd:"f19n0e5"},disabled:{sj55zd:"f1s2aq7o",B7iucu3:"f1cyfu5x"},required:{sj55zd:"f1whyuy6",uwmqm3:["fruq291","f7x41pl"]},small:{Be2twd7:"fy9rknc",Bg96gwp:"fwrc4pm"},medium:{Be2twd7:"fkhj508",Bg96gwp:"f1i3iumi"},large:{Be2twd7:"fod5ikn",Bg96gwp:"faaz57k",Bhrd7zp:"fl43uef"},semibold:{Bhrd7zp:"fl43uef"}},{d:[".fk6fouc{font-family:var(--fontFamilyBase);}",".f19n0e5{color:var(--colorNeutralForeground1);}",".f1s2aq7o{color:var(--colorNeutralForegroundDisabled);}",".f1whyuy6{color:var(--colorPaletteRedForeground3);}",".fruq291{padding-left:var(--spacingHorizontalXS);}",".f7x41pl{padding-right:var(--spacingHorizontalXS);}",".fy9rknc{font-size:var(--fontSizeBase200);}",".fwrc4pm{line-height:var(--lineHeightBase200);}",".fkhj508{font-size:var(--fontSizeBase300);}",".f1i3iumi{line-height:var(--lineHeightBase300);}",".fod5ikn{font-size:var(--fontSizeBase400);}",".faaz57k{line-height:var(--lineHeightBase400);}",".fl43uef{font-weight:var(--fontWeightSemibold);}"],m:[["@media (forced-colors: active){.f1cyfu5x{color:GrayText;}}",{m:"(forced-colors: active)"}]]}),aN=e=>{"use no memo";const t=sN();return e.root.className=_e(lp.root,t.root,e.disabled&&t.disabled,t[e.size],e.weight==="semibold"&&t.semibold,e.root.className),e.required&&(e.required.className=_e(lp.required,t.required,e.disabled&&t.disabled,e.required.className)),e},Cs=f.forwardRef((e,t)=>{const n=nN(e,t);return aN(n),zn("useLabelStyles_unstable")(n),iN(n)});Cs.displayName="Label";const oN=(e,t)=>{const{children:n,required:i=!1,validationState:s=e.validationMessage?"error":"none"}=e,a=Zo("field-"),o=a+"__control",l=vt(Ds("div",{...e,ref:t},["children"]),{elementType:"div"}),c=et(e.label,{defaultProps:{htmlFor:o,id:a+"__label",required:i},elementType:"label"}),u=et(e.validationMessage,{defaultProps:{id:a+"__validationMessage",role:s==="error"||s==="warning"?"alert":void 0},elementType:"div"}),m=et(e.hint,{defaultProps:{id:a+"__hint"},elementType:"div"}),d=et(e.validationMessageIcon,{renderByDefault:!1,elementType:"span"});return{children:n,generatedControlId:o,required:i,validationState:s,components:{root:"div",label:"label",validationMessage:"div",validationMessageIcon:"span",hint:"div"},root:l,label:c,validationMessageIcon:d,validationMessage:u,hint:m}},lN={error:f.createElement(ik,null),warning:f.createElement(Yw,null),success:f.createElement(rk,null),none:void 0},cN=(e,t)=>{const{orientation:n="vertical",size:i="medium",...s}=e,a=oN(s,t),o=lN[a.validationState];return{...a,components:{...a.components,label:Cs},label:et(e.label,{defaultProps:{size:i,...a.label},elementType:Cs}),validationMessageIcon:et(e.validationMessageIcon,{renderByDefault:!!o,defaultProps:{children:o},elementType:"span"}),orientation:n,size:i}},Ii={root:"fui-Field",label:"fui-Field__label",validationMessage:"fui-Field__validationMessage",validationMessageIcon:"fui-Field__validationMessageIcon",hint:"fui-Field__hint"},uN=Pe({base:{mc9l5x:"f13qh94s"},horizontal:{Budl1dq:"f2wwaib",wkccdc:"f1645dqt"},horizontalNoLabel:{uwmqm3:["f15jqgz8","fggqkej"],Budl1dq:"f1c2z91y"}},{d:[".f13qh94s{display:grid;}",".f2wwaib{grid-template-columns:33% 1fr;}",".f1645dqt{grid-template-rows:auto auto auto 1fr;}",".f15jqgz8{padding-left:33%;}",".fggqkej{padding-right:33%;}",".f1c2z91y{grid-template-columns:1fr;}"]}),dN=Pe({base:{B2u0y6b:"f6nezus",Bxyxcbc:"f1iqmcbn"},vertical:{z8tnut:"fclwglc",Byoj8tv:"fywfov9",jrapky:"fyacil5"},verticalLarge:{z8tnut:"f1sl3k7w",Byoj8tv:"f1brlhvm",jrapky:"f8l5zjj"},horizontal:{z8tnut:"fp2oml8",Byoj8tv:"f1tdddsa",t21cq0:["fkujibs","f199hnxi"],Ijaq50:"f16hsg94",nk6f5a:"f1nzqi2z"},horizontalSmall:{z8tnut:"f1ywm7hm",Byoj8tv:"f14wxoun"},horizontalLarge:{z8tnut:"f1hqyr95",Byoj8tv:"fm4hlj0"}},{d:[".f6nezus{max-width:max-content;}",".f1iqmcbn{max-height:max-content;}",".fclwglc{padding-top:var(--spacingVerticalXXS);}",".fywfov9{padding-bottom:var(--spacingVerticalXXS);}",".fyacil5{margin-bottom:var(--spacingVerticalXXS);}",".f1sl3k7w{padding-top:1px;}",".f1brlhvm{padding-bottom:1px;}",".f8l5zjj{margin-bottom:var(--spacingVerticalXS);}",".fp2oml8{padding-top:var(--spacingVerticalSNudge);}",".f1tdddsa{padding-bottom:var(--spacingVerticalSNudge);}",".fkujibs{margin-right:var(--spacingHorizontalM);}",".f199hnxi{margin-left:var(--spacingHorizontalM);}",".f16hsg94{grid-row-start:1;}",".f1nzqi2z{grid-row-end:-1;}",".f1ywm7hm{padding-top:var(--spacingVerticalXS);}",".f14wxoun{padding-bottom:var(--spacingVerticalXS);}",".f1hqyr95{padding-top:9px;}",".fm4hlj0{padding-bottom:9px;}"]}),fN=en("r5c4z9l",null,[".r5c4z9l{margin-top:var(--spacingVerticalXXS);color:var(--colorNeutralForeground3);font-family:var(--fontFamilyBase);font-size:var(--fontSizeBase200);font-weight:var(--fontWeightRegular);line-height:var(--lineHeightBase200);}"]),mN=Pe({error:{sj55zd:"f1hcrxcs"},withIcon:{uwmqm3:["frawy03","fg4c52"]}},{d:[".f1hcrxcs{color:var(--colorPaletteRedForeground1);}",".frawy03{padding-left:calc(12px + var(--spacingHorizontalXS));}",".fg4c52{padding-right:calc(12px + var(--spacingHorizontalXS));}"]}),pN=en("ra7h1uk","r1rh6bd7",[".ra7h1uk{display:inline-block;font-size:12px;margin-left:calc(-12px - var(--spacingHorizontalXS));margin-right:var(--spacingHorizontalXS);line-height:0;vertical-align:-1px;}",".r1rh6bd7{display:inline-block;font-size:12px;margin-right:calc(-12px - var(--spacingHorizontalXS));margin-left:var(--spacingHorizontalXS);line-height:0;vertical-align:-1px;}"]),hN=Pe({error:{sj55zd:"f1hcrxcs"},warning:{sj55zd:"f1k5f75o"},success:{sj55zd:"ffmvakt"}},{d:[".f1hcrxcs{color:var(--colorPaletteRedForeground1);}",".f1k5f75o{color:var(--colorPaletteDarkOrangeForeground1);}",".ffmvakt{color:var(--colorPaletteGreenForeground1);}"]}),gN=e=>{"use no memo";const{validationState:t,size:n}=e,i=e.orientation==="horizontal",s=uN();e.root.className=_e(Ii.root,s.base,i&&s.horizontal,i&&!e.label&&s.horizontalNoLabel,e.root.className);const a=dN();e.label&&(e.label.className=_e(Ii.label,a.base,i&&a.horizontal,i&&n==="small"&&a.horizontalSmall,i&&n==="large"&&a.horizontalLarge,!i&&a.vertical,!i&&n==="large"&&a.verticalLarge,e.label.className));const o=pN(),l=hN();e.validationMessageIcon&&(e.validationMessageIcon.className=_e(Ii.validationMessageIcon,o,t!=="none"&&l[t],e.validationMessageIcon.className));const c=fN(),u=mN();return e.validationMessage&&(e.validationMessage.className=_e(Ii.validationMessage,c,t==="error"&&u.error,!!e.validationMessageIcon&&u.withIcon,e.validationMessage.className)),e.hint&&(e.hint.className=_e(Ii.hint,c,e.hint.className)),e},Tt=f.forwardRef((e,t)=>{const n=cN(e,t);gN(n),zn("useFieldStyles_unstable")(n);const i=eN(n);return tN(n,i)});Tt.displayName="Field";const Bv=Qx({activeOption:void 0,appearance:"outline",focusVisible:!1,open:!1,registerOption(){return()=>{}},selectedOptions:[],selectOption(){},setActiveOption(){},setOpen(){},size:"medium"});Bv.Provider;const xN={activeOption:void 0,focusVisible:!1,multiselect:!1,getOptionById(){},getOptionsMatchingValue(){return[]},registerOption(){return()=>{}},selectedOptions:[],onOptionClick(){},onActiveDescendantChange(){},selectOption(){},setActiveOption(){}},Ni=Qx(void 0),jt=e=>Pw(Ni,(t=xN)=>e(t));Ni.Provider;function vN(e){const{appearance:t,open:n,getOptionById:i,getOptionsMatchingValue:s,registerOption:a,selectedOptions:o,selectOption:l,setOpen:c,size:u,activeDescendantController:m,onOptionClick:d,onActiveDescendantChange:h}=e,x={activeOption:void 0,appearance:t,focusVisible:!1,open:n,registerOption:a,selectedOptions:o,selectOption:l,setActiveOption:()=>null,setOpen:c,size:u},g={activeOption:void 0,focusVisible:!1,getOptionById:i,getOptionsMatchingValue:s,registerOption:a,selectedOptions:o,selectOption:l,setActiveOption:()=>null,onOptionClick:d,onActiveDescendantChange:h},p=f.useMemo(()=>({controller:m}),[m]);return{combobox:x,activeDescendant:p,listbox:g}}function bN(e){const t=Zx(Ni),{getOptionById:n,getOptionsMatchingValue:i,multiselect:s,registerOption:a,selectedOptions:o,selectOption:l,activeDescendantController:c}=e,u=jt(p=>p.registerOption),m=jt(p=>p.onOptionClick),d=jt(p=>p.onActiveDescendantChange),x={activeOption:void 0,focusVisible:!1,getOptionById:n,getOptionsMatchingValue:i,multiselect:s,registerOption:t?u:a,selectedOptions:o,selectOption:l,setActiveOption:()=>{},onOptionClick:m,onActiveDescendantChange:d},g=f.useMemo(()=>({controller:c}),[c]);return{listbox:x,activeDescendant:g}}function Hd(e,t={}){const{open:n=!0,multiselect:i=!1}=t,s=e.key,{altKey:a,ctrlKey:o,key:l,metaKey:c}=e;return l.length===1&&s!==qn&&!a&&!o&&!c?"Type":n?s===Wl&&a||s===ns||!i&&s===qn?"CloseSelect":i&&s===qn?"Select":s===Iw?"Close":s===qm?"Next":s===Wl?"Previous":s===Aw?"First":s===Dw?"Last":s===Ow?"PageUp":s===Rw?"PageDown":s===Ew?"Tab":"None":s===qm||s===Wl||s===ns||s===qn?"Open":"None"}const Tv=()=>{const e=f.useRef(new Map),t=f.useMemo(()=>({getCount:()=>e.current.size,getOptionAtIndex:()=>{},getIndexOfId:()=>-1,getOptionById:u=>e.current.get(u),getOptionsMatchingText:u=>Array.from(e.current.values()).filter(({text:m})=>u(m)),getOptionsMatchingValue:u=>{const m=[];for(const d of e.current.values())u(d.value)&&m.push(d);return m}}),[]),n=f.useCallback(i=>(e.current.set(i.id,i),()=>e.current.delete(i.id)),[]);return{...t,options:Array.from(e.current.values()),registerOption:n}},Pv=e=>{const{defaultSelectedOptions:t,multiselect:n,onOptionSelect:i}=e,[s,a]=ks({state:e.selectedOptions,defaultState:t,initialState:[]}),o=f.useCallback((c,u)=>{if(u.disabled)return;let m=[u.value];if(n){const d=s.findIndex(h=>h===u.value);d>-1?m=[...s.slice(0,d),...s.slice(d+1)]:m=[...s,u.value]}a(m),i==null||i(c,{optionValue:u.value,optionText:u.text,selectedOptions:m})},[i,n,s,a]);return{clearSelection:c=>{a([]),i==null||i(c,{optionValue:void 0,optionText:void 0,selectedOptions:[]})},selectOption:o,selectedOptions:s}},jo={root:"fui-Option",checkIcon:"fui-Option__checkIcon"},yN=Pe({root:{Bt984gj:"f122n59",Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"ft85np5",sj55zd:"f19n0e5",i8kkvl:"f1ufnopg",Bceei9c:"f1k6fduh",mc9l5x:"f22iagw",Bahqtrf:"fk6fouc",Be2twd7:"fkhj508",Bg96gwp:"f1i3iumi",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"fm5eomj",qhf8xq:"f10pi13n",Jwef8y:"f1knas48",Bi91k9c:"feu1g3u",zqbkvg:"fo79ri9",h82x05:["f1osiabc","f1e8le25"],cqj998:"f1yusjty",j3hlsh:["f1e8le25","f1osiabc"],ecr2s2:"fb40n2d",lj723h:"f1g4hkjv",Btxx2vb:"f1lnr2zp",sltcwy:["f1ogfk9z","f1g7j8ec"],dnwvvm:"fiuf46r",Blyvkvs:["f1g7j8ec","f1ogfk9z"]},active:{Bowz1zl:"f11vrvdw",oxogb1:"f17hxjb7",Ix2sn8:"f1dha69c",q7v32p:"f1lm7500",B7cbj04:0,Bewtojm:0,b50fsz:0,B1wzb3v:0,Bqwk70n:0,B37u8z8:0,avt0cx:0,f0sref:0,B9fkznv:0,Be3o27t:0,Bertapg:0,B53xpsf:0,Bsv72rj:0,B39dzdd:0,Btq9bd3:0,Bqfxd14:0,atup0s:"fo7xqb",Fffuxt:0,Bttcd12:0,Beitzug:0,Bqougee:0,B86i8pi:"f1kurthe",Bhijsxg:"fwq15dy",kktds4:"f1pb3wry",Bmau3bo:["ftjv2f4","f1flhb1f"],npektv:["f1flhb1f","ftjv2f4"]},disabled:{sj55zd:"f1s2aq7o",Jwef8y:"f9ql6rf",Bi91k9c:"fvgxktp",zqbkvg:"f185j3qj",h82x05:["f1dligi3","f1vydzie"],cqj998:"fjw1di3",j3hlsh:["f1vydzie","f1dligi3"],ecr2s2:"fgj9um3",lj723h:"f19wldhg",Btxx2vb:"f1ss0kt2",sltcwy:["f1t6oli3","fjy9ci8"],dnwvvm:"fresaxk",Blyvkvs:["fjy9ci8","f1t6oli3"],B7iucu3:"f1cyfu5x"},selected:{},checkIcon:{Bnnss6s:"fi64zpg",Be2twd7:"fod5ikn",Frg6f3:["f18b9hdq","fn6qj8t"],t21cq0:["f1xk557c","f1h9en5y"],Bcdw1i0:"fd7fpy0",Bo70h7d:"fvc9v3g"},selectedCheck:{Bcdw1i0:"f1022m68"},multiselectCheck:{Bgfg5da:0,B9xav0g:0,oivjwe:0,Bn0qgzm:0,B4g9neb:0,zhjwy3:0,wvpqe5:0,ibv6hh:0,u1mtju:0,h3c5rm:0,vrafjx:0,Bekrc4i:0,i8vvqc:0,g2u3we:0,icvyot:0,B4j52fo:0,irswps:"f1l3cf7o",Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"fq9zq91",B7ck84d:"f1ewtqcl",mc9l5x:"f22iagw",Bt984gj:"f122n59",Brf1p80:"f4d9j23",Bkfmm31:"f1w9h62z",Be2twd7:"f1ugzwwg",Bqenvij:"fd461yt",a9b677:"fjw5fx7",Bcdw1i0:"f1022m68"},selectedMultiselectCheck:{De3pzq:"ftywsgz",sj55zd:"fqpbvvt",g2u3we:"f3xi7mh",h3c5rm:["ftovhe4","f1wczvin"],B9xav0g:"f68vbr6",zhjwy3:["f1wczvin","ftovhe4"]},checkDisabled:{sj55zd:"f1s2aq7o",B7iucu3:"f1cyfu5x"},multiselectCheckDisabled:{g2u3we:"f1r1t4y1",h3c5rm:["fmj8ijw","figx54m"],B9xav0g:"f360ss8",zhjwy3:["figx54m","fmj8ijw"]}},{d:[".f122n59{align-items:center;}",[".ft85np5{border-radius:var(--borderRadiusMedium);}",{p:-1}],".f19n0e5{color:var(--colorNeutralForeground1);}",".f1ufnopg{column-gap:var(--spacingHorizontalXS);}",".f1k6fduh{cursor:pointer;}",".f22iagw{display:flex;}",".fk6fouc{font-family:var(--fontFamilyBase);}",".fkhj508{font-size:var(--fontSizeBase300);}",".f1i3iumi{line-height:var(--lineHeightBase300);}",[".fm5eomj{padding:var(--spacingVerticalSNudge) var(--spacingHorizontalS);}",{p:-1}],".f10pi13n{position:relative;}",'.f11vrvdw[data-activedescendant-focusvisible]::after{content:"";}',".f17hxjb7[data-activedescendant-focusvisible]::after{position:absolute;}",".f1dha69c[data-activedescendant-focusvisible]::after{pointer-events:none;}",".f1lm7500[data-activedescendant-focusvisible]::after{z-index:1;}",[".fo7xqb[data-activedescendant-focusvisible]::after{border:2px solid var(--colorStrokeFocus2);}",{p:-2}],[".f1kurthe[data-activedescendant-focusvisible]::after{border-radius:var(--borderRadiusMedium);}",{p:-1}],".fwq15dy[data-activedescendant-focusvisible]::after{top:-2px;}",".f1pb3wry[data-activedescendant-focusvisible]::after{bottom:-2px;}",".ftjv2f4[data-activedescendant-focusvisible]::after{left:-2px;}",".f1flhb1f[data-activedescendant-focusvisible]::after{right:-2px;}",".f1s2aq7o{color:var(--colorNeutralForegroundDisabled);}",".fi64zpg{flex-shrink:0;}",".fod5ikn{font-size:var(--fontSizeBase400);}",".f18b9hdq{margin-left:calc(var(--spacingHorizontalXXS) * -1);}",".fn6qj8t{margin-right:calc(var(--spacingHorizontalXXS) * -1);}",".f1xk557c{margin-right:var(--spacingHorizontalXXS);}",".f1h9en5y{margin-left:var(--spacingHorizontalXXS);}",".fd7fpy0{visibility:hidden;}",".fvc9v3g svg{display:block;}",".f1022m68{visibility:visible;}",[".f1l3cf7o{border:var(--strokeWidthThin) solid var(--colorNeutralStrokeAccessible);}",{p:-2}],[".fq9zq91{border-radius:var(--borderRadiusSmall);}",{p:-1}],".f1ewtqcl{box-sizing:border-box;}",".f4d9j23{justify-content:center;}",".f1w9h62z{fill:currentColor;}",".f1ugzwwg{font-size:12px;}",".fd461yt{height:16px;}",".fjw5fx7{width:16px;}",".ftywsgz{background-color:var(--colorCompoundBrandBackground);}",".fqpbvvt{color:var(--colorNeutralForegroundInverted);}",".f3xi7mh{border-top-color:var(--colorCompoundBrandBackground);}",".ftovhe4{border-right-color:var(--colorCompoundBrandBackground);}",".f1wczvin{border-left-color:var(--colorCompoundBrandBackground);}",".f68vbr6{border-bottom-color:var(--colorCompoundBrandBackground);}",".f1r1t4y1{border-top-color:var(--colorNeutralForegroundDisabled);}",".fmj8ijw{border-right-color:var(--colorNeutralForegroundDisabled);}",".figx54m{border-left-color:var(--colorNeutralForegroundDisabled);}",".f360ss8{border-bottom-color:var(--colorNeutralForegroundDisabled);}"],h:[".f1knas48:hover{background-color:var(--colorNeutralBackground1Hover);}",".feu1g3u:hover{color:var(--colorNeutralForeground1Hover);}",".fo79ri9:hover .fui-Option__checkIcon{border-top-color:var(--colorNeutralForeground1Hover);}",".f1osiabc:hover .fui-Option__checkIcon{border-right-color:var(--colorNeutralForeground1Hover);}",".f1e8le25:hover .fui-Option__checkIcon{border-left-color:var(--colorNeutralForeground1Hover);}",".f1yusjty:hover .fui-Option__checkIcon{border-bottom-color:var(--colorNeutralForeground1Hover);}",".f9ql6rf:hover{background-color:var(--colorTransparentBackground);}",".fvgxktp:hover{color:var(--colorNeutralForegroundDisabled);}",".f185j3qj:hover .fui-Option__checkIcon{border-top-color:var(--colorNeutralForegroundDisabled);}",".f1dligi3:hover .fui-Option__checkIcon{border-right-color:var(--colorNeutralForegroundDisabled);}",".f1vydzie:hover .fui-Option__checkIcon{border-left-color:var(--colorNeutralForegroundDisabled);}",".fjw1di3:hover .fui-Option__checkIcon{border-bottom-color:var(--colorNeutralForegroundDisabled);}"],a:[".fb40n2d:active{background-color:var(--colorNeutralBackground1Pressed);}",".f1g4hkjv:active{color:var(--colorNeutralForeground1Pressed);}",".f1lnr2zp:active .fui-Option__checkIcon{border-top-color:var(--colorNeutralForeground1Hover);}",".f1ogfk9z:active .fui-Option__checkIcon{border-right-color:var(--colorNeutralForeground1Hover);}",".f1g7j8ec:active .fui-Option__checkIcon{border-left-color:var(--colorNeutralForeground1Hover);}",".fiuf46r:active .fui-Option__checkIcon{border-bottom-color:var(--colorNeutralForeground1Hover);}",".fgj9um3:active{background-color:var(--colorTransparentBackground);}",".f19wldhg:active{color:var(--colorNeutralForegroundDisabled);}",".f1ss0kt2:active .fui-Option__checkIcon{border-top-color:var(--colorNeutralForegroundDisabled);}",".f1t6oli3:active .fui-Option__checkIcon{border-right-color:var(--colorNeutralForegroundDisabled);}",".fjy9ci8:active .fui-Option__checkIcon{border-left-color:var(--colorNeutralForegroundDisabled);}",".fresaxk:active .fui-Option__checkIcon{border-bottom-color:var(--colorNeutralForegroundDisabled);}"],m:[["@media (forced-colors: active){.f1cyfu5x{color:GrayText;}}",{m:"(forced-colors: active)"}]]}),jN=e=>{"use no memo";const{disabled:t,multiselect:n,selected:i}=e,s=yN();return e.root.className=_e(jo.root,s.root,s.active,t&&s.disabled,i&&s.selected,e.root.className),e.checkIcon&&(e.checkIcon.className=_e(jo.checkIcon,s.checkIcon,n&&s.multiselectCheck,i&&s.selectedCheck,i&&n&&s.selectedMultiselectCheck,t&&s.checkDisabled,t&&n&&s.multiselectCheckDisabled,e.checkIcon.className)),e},cp={activeOption:void 0,focusVisible:!1,setActiveOption:()=>null},_N=(e,t)=>{"use no memo";const{multiselect:n,disableAutoFocus:i=!1}=e,s=Tv(),{listboxRef:a,activeParentRef:o,controller:l}=nv({matchOption:z=>z.classList.contains(jo.root)}),c=Zx(Ni),u=jt(z=>z.onActiveDescendantChange),m=jt(z=>z.getOptionById),d=jt(z=>z.getOptionsMatchingValue),h=c?m:s.getOptionById,x=c?d:s.getOptionsMatchingValue,g=f.useMemo(()=>{let z=null;const A=$=>{const Z=$;u==null||u(Z)};return $=>{if(!$){z==null||z.removeEventListener("activedescendantchange",A);return}z=$,z.addEventListener("activedescendantchange",A)}},[u]),[p,w]=f.useState(!1);Xx(w);const b=Jx(),v=Fw(),y=v?b.controller:l,{clearSelection:j,selectedOptions:k,selectOption:N}=Pv(e),_=z=>{const A=Hd(z,{open:!0}),$=y.active(),Z=$?h($):null;switch(A){case"First":case"Last":case"Next":case"Previous":case"PageDown":case"PageUp":case"CloseSelect":case"Select":z.preventDefault();break}switch(A){case"Next":Z?y.next():y.first();break;case"Previous":Z?y.prev():y.first();break;case"PageUp":case"First":y.first();break;case"PageDown":case"Last":y.last();break;case"Select":case"CloseSelect":Z&&N(z,Z);break}},S=jt(z=>z.selectedOptions),P=jt(z=>z.selectOption),D=c?{selectedOptions:S,selectOption:P,...cp}:{selectedOptions:k,selectOption:N,...cp};f.useEffect(()=>{if(!v){if(y.hideFocusVisibleAttributes(),!i)if(!n&&D.selectedOptions.length>0){const z=x(A=>A===D.selectedOptions[0]).pop();z!=null&&z.id&&y.focus(z.id)}else y.first();return()=>{y.blur()}}},[]);const F=f.useCallback(()=>{v||(y.showFocusVisibleAttributes(),p&&y.scrollActiveIntoView())},[y,v,p]),T=f.useCallback(()=>{v||y.hideFocusVisibleAttributes()},[y,v]),M={components:{root:"div"},root:vt(Ds("div",{ref:Yn(t,o,a,g),role:n?"menu":"listbox",tabIndex:0,...e}),{elementType:"div"}),standalone:!c,multiselect:n,clearSelection:j,activeDescendantController:y,onActiveDescendantChange:u,...s,...D};return M.root.onKeyDown=qe(Wt(M.root.onKeyDown,_)),M.root.onFocus=qe(Wt(M.root.onFocus,F)),M.root.onBlur=qe(Wt(M.root.onBlur,T)),M},wN=(e,t)=>ke(Yx,{value:t.activeDescendant,children:ke(Ni.Provider,{value:t.listbox,children:ke(e.root,{})})}),kN={root:"fui-Listbox"},SN=Pe({root:{De3pzq:"fxugw4r",B7ck84d:"f1ewtqcl",mc9l5x:"f22iagw",Beiy3e4:"f1vx9l62",Bf4jedk:"f3hsy1e",Bmxbyg5:"f5zp4f",Bw0xxkn:0,oeaueh:0,Bpd4iqm:0,Befb4lg:"f1iepc6i",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"f1t35pdg",Belr9w4:"fiut8dr"}},{d:[".fxugw4r{background-color:var(--colorNeutralBackground1);}",".f1ewtqcl{box-sizing:border-box;}",".f22iagw{display:flex;}",".f1vx9l62{flex-direction:column;}",".f3hsy1e{min-width:160px;}",".f5zp4f{overflow-y:auto;}",[".f1iepc6i{outline:1px solid var(--colorTransparentStroke);}",{p:-1}],[".f1t35pdg{padding:var(--spacingHorizontalXS);}",{p:-1}],".fiut8dr{row-gap:var(--spacingHorizontalXXS);}"]}),NN=e=>{"use no memo";const t=SN();return e.root.className=_e(kN.root,t.root,e.root.className),e},Kd=f.forwardRef((e,t)=>{const n=_N(e,t),i=bN(n);return NN(n),zn("useListboxStyles_unstable")(n),wN(n,i)});Kd.displayName="Listbox";function CN(e,t){if(e!==void 0)return e;let n="",i=!1;return f.Children.forEach(t,s=>{typeof s=="string"?n+=s:i=!0}),i&&console.warn("Provide a `text` prop to Option components when they contain non-string children."),n}const zN=(e,t)=>{"use no memo";const n=BN(e,t);let i=f.createElement(Gw,null);if(n.multiselect&&(i=n.selected?f.createElement(nk,null):""),n.checkIcon){var s,a;(a=(s=n.checkIcon).children)!==null&&a!==void 0||(s.children=i)}return n},BN=(e,t)=>{const{children:n,disabled:i,text:s,value:a}=e,o=f.useRef(null),l=CN(s,n),c=a??l,u=Zo("fluent-option",e.id),m=f.useMemo(()=>({id:u,disabled:i,text:l,value:c}),[u,i,l,c]),{controller:d}=Jx(),h=jt(y=>y.multiselect),x=jt(y=>y.registerOption),g=jt(y=>{const j=y.selectedOptions;return c!==void 0&&j.find(k=>k===c)!==void 0}),p=jt(y=>y.selectOption),w=jt(y=>y.onOptionClick),b=y=>{var j;if(i){y.preventDefault();return}d.focus(u),p(y,m),w(y),(j=e.onClick)===null||j===void 0||j.call(e,y)};f.useEffect(()=>{if(u&&o.current)return x(m,o.current)},[u,m,x]);const v=h?{role:"menuitemcheckbox","aria-checked":g}:{role:"option","aria-selected":g};return{components:{root:"div",checkIcon:"span"},root:vt(Ds("div",{ref:Yn(t,o),"aria-disabled":i?"true":void 0,id:u,...v,...e,onClick:b}),{elementType:"div"}),checkIcon:et(e.checkIcon,{renderByDefault:!0,defaultProps:{"aria-hidden":"true"},elementType:"span"}),disabled:i,multiselect:h,selected:g,focusVisible:!1,active:!1}},TN=e=>wn(e.root,{children:[e.checkIcon&&ke(e.checkIcon,{}),e.root.children]}),Pr=f.forwardRef((e,t)=>{const n=zN(e,t);return jN(n),zn("useOptionStyles_unstable")(n),TN(n)});Pr.displayName="Option";const PN=e=>{"use no memo";const{appearance:t="outline",disableAutoFocus:n,children:i,clearable:s=!1,editable:a=!1,inlinePopup:o=!1,mountNode:l=void 0,multiselect:c,onOpenChange:u,size:m="medium",activeDescendantController:d,freeform:h=!1,disabled:x=!1,onActiveOptionChange:g=null}=e,p=Tv(),{getOptionsMatchingValue:w}=p,{getOptionById:b}=p,v=f.useCallback(()=>{const J=d.active();return J?b(J):void 0},[d,b]),y=v(),j=f.useCallback(J=>{let ue;if(typeof J=="function"){const pe=v();ue=J(pe)}ue?d.focus(ue.id):d.blur()},[d,v]),[k,N]=f.useState(!1),[_,S]=f.useState(!1),P=f.useRef(!1),D=lw(),[F,T]=ks({state:e.value,initialState:void 0}),{selectedOptions:M,selectOption:z,clearSelection:A}=Pv(e),$=f.useCallback((J,ue)=>{fo.unstable_batchedUpdates(()=>{T(void 0),z(J,ue)})},[T,z]),Z=f.useMemo(()=>{if(F!==void 0)return F;if(D&&e.defaultValue!==void 0)return e.defaultValue;const J=w(ue=>M.includes(ue)).map(ue=>ue.text);return c?a?"":J.join(", "):J[0]},[F,a,w,c,M]),[R,K]=ks({state:e.open,defaultState:e.defaultOpen,initialState:!1}),X=f.useCallback((J,ue)=>{x||(u==null||u(J,{open:ue}),fo.unstable_batchedUpdates(()=>{!ue&&!h&&T(void 0),K(ue)}))},[u,K,T,h,x]);f.useEffect(()=>{if(R){if(!c&&M.length>0){const J=w(ue=>ue===M[0]).pop();J!=null&&J.id&&d.focus(J.id)}}else d.blur()},[R,d]),f.useEffect(()=>{R&&!n&&!d.active()&&d.first()},[R,i,n,d,b]);const ae=qe(J=>{const ue=J.detail.previousId?p.getOptionById(J.detail.previousId):null,pe=p.getOptionById(J.detail.id);g==null||g(J,{event:J,type:"change",previousOption:ue,nextOption:pe})});return{...p,freeform:h,disabled:x,selectOption:$,clearSelection:A,selectedOptions:M,activeOption:y,appearance:t,clearable:s,focusVisible:k,ignoreNextBlur:P,inlinePopup:o,mountNode:l,open:R,hasFocus:_,setActiveOption:j,setFocusVisible:N,setHasFocus:S,setOpen:X,setValue:T,size:m,value:Z,multiselect:c,onOptionClick:qe(J=>{c||X(J,!1)}),onActiveDescendantChange:ae}};function EN(e){const{positioning:t}=e,i={position:"below",align:"start",offset:{crossAxis:0,mainAxis:2},fallbackPositions:["above","after","after-top","before","before-top"],matchTargetSize:"width",autoSize:!0,...yv(t)},{targetRef:s,containerRef:a}=SS(i);return[a,s]}function DN(e,t,n){const{state:{multiselect:i},triggerRef:s,defaultProps:a}=n,o=Zo("fluent-listbox",w_(e)?e.id:void 0),l=et(e,{renderByDefault:!0,elementType:Kd,defaultProps:{id:o,multiselect:i,tabIndex:void 0,...a}}),c=rl({id:o},{supportsLabelFor:!0});l&&!l["aria-label"]&&!l["aria-labelledby"]&&c["aria-labelledby"]&&(l["aria-labelledby"]=c["aria-labelledby"]);const u=qe(Wt(h=>{h.preventDefault()},l==null?void 0:l.onMouseDown)),m=qe(Wt(h=>{var x;h.preventDefault(),(x=s.current)===null||x===void 0||x.focus()},l==null?void 0:l.onClick)),d=Yn(l==null?void 0:l.ref,t);return l&&(l.ref=d,l.onMouseDown=u,l.onClick=m),l}function AN(e,t,n){const{state:{open:i,setOpen:s,setHasFocus:a},defaultProps:o,elementType:l,activeDescendantController:c}=n,u=vt(e,{defaultProps:{type:"text","aria-expanded":i,role:"combobox",...typeof o=="object"&&o},elementType:l}),m=f.useRef(null);return u.ref=Yn(m,u.ref,t),u.onBlur=Wt(d=>{s(d,!1),a(!1)},u.onBlur),u.onFocus=Wt(d=>{d.target===d.currentTarget&&a(!0)},u.onFocus),u.onClick=Wt(d=>{s(d,!i)},u.onClick),u.onKeyDown=Wt(RN({activeDescendantController:c,...n.state}),u.onKeyDown),u}function RN(e){const{activeDescendantController:t,getOptionById:n,setOpen:i,selectOption:s,multiselect:a,open:o}=e,l=f.useCallback(()=>{const p=t.active();return p?n(p):void 0},[t,n]),c=()=>{t.first()},u=()=>{t.last()},m=p=>{p?t.next():t.first()},d=p=>{p?t.prev():t.first()},h=()=>{for(let p=0;p<10;p++)t.prev()},x=()=>{for(let p=0;p<10;p++)t.next()},g=Bw();return qe(p=>{const w=Hd(p,{open:o,multiselect:a}),b=l();switch(w){case"First":case"Last":case"Next":case"Previous":case"PageDown":case"PageUp":case"Open":case"Close":case"CloseSelect":case"Select":p.preventDefault();break}switch(g(!0),w){case"First":c();break;case"Last":u();break;case"Next":m(b);break;case"Previous":d(b);break;case"PageDown":x();break;case"PageUp":h();break;case"Open":i(p,!0);break;case"Close":p.stopPropagation(),i(p,!1);break;case"CloseSelect":!a&&!(b!=null&&b.disabled)&&i(p,!1);case"Select":b&&s(p,b);break;case"Tab":!a&&b&&s(p,b);break}})}function ON(e,t,n){"use no memo";const{state:{open:i,setOpen:s,getOptionById:a},defaultProps:o,activeDescendantController:l}=n,c=f.useRef(""),[u,m]=Mx(),d=(p,w={startFromNext:!1})=>{const{startFromNext:b}=w,v=l.active(),y=l.find(j=>{const k=a(j);return!!k&&p(k.text)},{startFrom:b?l.next({passive:!0}):v});return y||l.find(j=>{const k=a(j);return!!k&&p(k.text)})},h=()=>{d(p=>p.toLocaleLowerCase().indexOf(c.current)===0,{startFromNext:c.current.length===1})||IN(c.current)&&d(p=>p.toLocaleLowerCase().indexOf(c.current[0])===0,{startFromNext:!0})||l.blur()},x=p=>{m(),Hd(p)==="Type"&&(c.current+=p.key.toLowerCase(),u(()=>{c.current=""},500),i&&h(),!i&&s(p,!0))},g=AN(e,t,{state:n.state,defaultProps:o,elementType:"button",activeDescendantController:l});return g.onKeyDown=Wt(x,g.onKeyDown),g}function IN(e){for(let t=1;t{"use no memo";var n;e=rl(e,{supportsLabelFor:!0});const{listboxRef:i,activeParentRef:s,controller:a}=nv({matchOption:$=>$.classList.contains(jo.root)}),o=PN({...e,activeDescendantController:a,freeform:!1}),{appearance:l,size:c,freeform:u,...m}=o,{clearable:d,clearSelection:h,disabled:x,hasFocus:g,multiselect:p,open:w,selectedOptions:b,setOpen:v}=m,{primary:y,root:j}=Od({props:e,primarySlotTagName:"button",excludedPropNames:["children"]}),[k,N]=EN(e),_=f.useRef(null),S=DN(e.listbox,Yn(k,i),{state:o,triggerRef:_,defaultProps:{children:e.children}}),{targetDocument:P}=un();uw({element:P,callback:$=>v($,!1),refs:[_,k,N],disabled:!w});var D;const F=ON((D=e.button)!==null&&D!==void 0?D:{},Yn(_,s,t),{state:o,defaultProps:{type:"button",tabIndex:y.disabled?void 0:0,children:m.value||e.placeholder,"aria-controls":w?S==null?void 0:S.id:void 0,...y},activeDescendantController:a}),T=vt(e.root,{defaultProps:{"aria-owns":!e.inlinePopup&&w?S==null?void 0:S.id:void 0,children:e.children,...j},elementType:"div"});T.ref=Yn(T.ref,N);const M=b.length>0&&!x&&d&&!p,z={components:{root:"div",button:"button",clearButton:"button",expandIcon:"span",listbox:Kd},root:T,button:F,listbox:w||g?S:void 0,clearButton:et(e.clearButton,{defaultProps:{"aria-label":"Clear selection",tabIndex:M?0:void 0,type:"button"},elementType:"button",renderByDefault:!0}),expandIcon:et(e.expandIcon,{renderByDefault:!0,elementType:"span"}),placeholderVisible:!m.value&&!!e.placeholder,showClearButton:M,activeDescendantController:a,...m},A=qe(Wt((n=z.clearButton)===null||n===void 0?void 0:n.onClick,$=>{var Z;h($),(Z=_.current)===null||Z===void 0||Z.focus()}));return z.clearButton&&(z.clearButton.onClick=A),p&&(z.clearButton=void 0),z},MN=(e,t)=>{"use no memo";const{appearance:n="outline",size:i="medium",...s}=e,a=LN(s,t);if(a.clearButton){var o,l;(l=(o=a.clearButton).children)!==null&&l!==void 0||(o.children=f.createElement(Qw,null))}if(a.expandIcon){var c,u;(u=(c=a.expandIcon).children)!==null&&u!==void 0||(c.children=f.createElement(Xw,null))}return{...a,appearance:n,size:i}},FN=(e,t)=>ke(e.root,{children:ke(Yx,{value:t.activeDescendant,children:ke(Ni.Provider,{value:t.listbox,children:wn(Bv.Provider,{value:t.combobox,children:[wn(e.button,{children:[e.button.children,e.expandIcon&&ke(e.expandIcon,{})]}),e.clearButton&&ke(e.clearButton,{}),e.listbox&&(e.inlinePopup?ke(e.listbox,{}):ke(Sv,{mountNode:e.mountNode,children:ke(e.listbox,{})}))]})})})}),Li={root:"fui-Dropdown",button:"fui-Dropdown__button",clearButton:"fui-Dropdown__clearButton",expandIcon:"fui-Dropdown__expandIcon",listbox:"fui-Dropdown__listbox"},$N=Pe({root:{Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"ft85np5",B7ck84d:"f1ewtqcl",mc9l5x:"ftuwxu6",Bf4jedk:"f1exfvgq",qhf8xq:"f10pi13n",ha4doy:"fmrv4ls",Bbr2w1p:"f14a1fxs",Bduesf4:"f3e99gv",Bpq79vn:"fhljsf7",li1rpt:"f1gw3sf2",Bsft5z2:"f13zj6fq",E3zdtr:"f1mdlcz9",Eqx8gd:["f1a7op3","f1cjjd47"],By385i5:"f1gboi2j",B1piin3:["f1cjjd47","f1a7op3"],Dlnsje:"ffyw7fx",d9w3h3:["f1kp91vd","f1ibwz09"],B3778ie:["f1ibwz09","f1kp91vd"],B1q35kw:0,Bw17bha:0,Bcgy8vk:0,Bjuhk93:"f1mnjydx",Gjdm7m:"f13evtba",b1kco5:"f1yk9hq",Ba2ppi3:"fhwpy7i",F2fol1:"f14ee0xe",lck23g:"f1xhbsuh",wi16st:"fsrmcvb",ywj3b2:"f1t3k7v9",umuwi5:"fjw5xc1",Blcqepd:"f1xdyd5c",nplu4u:"fatpbeo",Bioka5o:"fb7uyps",Bnupc0a:"fx04xgm",bing71:"f1c7in40",Bercvud:"f1ibeo51",Br277j9:"f18qfb8s",Btis473:"f1m082s7"},listbox:{B7ck84d:"f1ewtqcl",E5pizo:"f1hg901r",Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"ft85np5",Bxyxcbc:"fmmk62d"},listboxCollapsed:{mc9l5x:"fjseox"},inlineListbox:{Bj3rh1h:"f19g0ac"},button:{Bt984gj:"f122n59",De3pzq:"f1c21dwh",Bgfg5da:0,B9xav0g:0,oivjwe:0,Bn0qgzm:0,B4g9neb:0,zhjwy3:0,wvpqe5:0,ibv6hh:0,u1mtju:0,h3c5rm:0,vrafjx:0,Bekrc4i:0,i8vvqc:0,g2u3we:0,icvyot:0,B4j52fo:0,irswps:"f3bhgqh",B7ck84d:"f1ewtqcl",sj55zd:"f19n0e5",i8kkvl:"f14mj54c",Bceei9c:"f1k6fduh",mc9l5x:"f13qh94s",Bahqtrf:"fk6fouc",Budl1dq:"f12nh0o2",Brf1p80:"f1869bpl",fsow6f:["f1o700av","fes3tcz"],a9b677:"fly5x3f",Brovlpu:"ftqa4ok"},placeholder:{sj55zd:"fxc4j92"},small:{Bahqtrf:"fk6fouc",Be2twd7:"fy9rknc",Bhrd7zp:"figsok6",Bg96gwp:"fwrc4pm",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:["fye6m5k","f3cq2dl"]},medium:{Bahqtrf:"fk6fouc",Be2twd7:"fkhj508",Bhrd7zp:"figsok6",Bg96gwp:"f1i3iumi",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:["f14ev680","f58uxzw"]},large:{i8kkvl:"f1rjii52",Bahqtrf:"fk6fouc",Be2twd7:"fod5ikn",Bhrd7zp:"figsok6",Bg96gwp:"faaz57k",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:["f139mn7i","f1v3q0m"]},outline:{De3pzq:"fxugw4r",Bgfg5da:0,B9xav0g:"f1c1zstj",oivjwe:0,Bn0qgzm:0,B4g9neb:0,zhjwy3:0,wvpqe5:0,ibv6hh:0,u1mtju:0,h3c5rm:0,vrafjx:0,Bekrc4i:0,i8vvqc:0,g2u3we:0,icvyot:0,B4j52fo:0,irswps:"fhz96rm"},outlineInteractive:{Bgoe8wy:"fvcxoqz",Bwzppfd:["f1ub3y4t","f1m52nbi"],oetu4i:"f1l4zc64",gg5e9n:["f1m52nbi","f1ub3y4t"],B6oc9vd:"fvs00aa",ak43y8:["f1assf6x","f4ruux4"],wmxk5l:"f1z0osm6",B50zh58:["f4ruux4","f1assf6x"],Bvq3b66:"f1b473iu",Brahy3i:["f381qr8","ft4skwv"],zoxjo1:"f1qzcrsd",an54nd:["ft4skwv","f381qr8"]},underline:{De3pzq:"f1c21dwh",B9xav0g:0,oivjwe:0,Bn0qgzm:0,Bgfg5da:"f9ez7ne",Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"fokr779"},"filled-lighter":{De3pzq:"fxugw4r",Bgfg5da:0,B9xav0g:0,oivjwe:0,Bn0qgzm:0,B4g9neb:0,zhjwy3:0,wvpqe5:0,ibv6hh:0,u1mtju:0,h3c5rm:0,vrafjx:0,Bekrc4i:0,i8vvqc:0,g2u3we:0,icvyot:0,B4j52fo:0,irswps:"fs2rfia"},"filled-darker":{De3pzq:"f16xq7d1",Bgfg5da:0,B9xav0g:0,oivjwe:0,Bn0qgzm:0,B4g9neb:0,zhjwy3:0,wvpqe5:0,ibv6hh:0,u1mtju:0,h3c5rm:0,vrafjx:0,Bekrc4i:0,i8vvqc:0,g2u3we:0,icvyot:0,B4j52fo:0,irswps:"fs2rfia"},invalid:{tvckwq:"fs4k3qj",gk2u95:["fcee079","fmyw78r"],hhx65j:"f1fgmyf4",Bxowmz0:["fmyw78r","fcee079"]},invalidUnderline:{hhx65j:"f1fgmyf4"},disabled:{Bceei9c:"fdrzuqr",De3pzq:"f1c21dwh",g2u3we:"f1jj8ep1",h3c5rm:["f15xbau","fy0fskl"],B9xav0g:"f4ikngz",zhjwy3:["fy0fskl","f15xbau"],Bcq6wej:"f9dbb4x",Jcjdmf:["f3qs60o","f5u9ap2"],sc4o1m:"fwd1oij",Bosien3:["f5u9ap2","f3qs60o"]},disabledText:{sj55zd:"f1s2aq7o",Bceei9c:"fdrzuqr"},hidden:{mc9l5x:"fjseox"}},{d:[[".ft85np5{border-radius:var(--borderRadiusMedium);}",{p:-1}],".f1ewtqcl{box-sizing:border-box;}",".ftuwxu6{display:inline-flex;}",".f1exfvgq{min-width:250px;}",".f10pi13n{position:relative;}",".fmrv4ls{vertical-align:middle;}",".f1gw3sf2::after{box-sizing:border-box;}",'.f13zj6fq::after{content:"";}',".f1mdlcz9::after{position:absolute;}",".f1a7op3::after{left:-1px;}",".f1cjjd47::after{right:-1px;}",".f1gboi2j::after{bottom:-1px;}",".ffyw7fx::after{height:max(var(--strokeWidthThick), var(--borderRadiusMedium));}",".f1kp91vd::after{border-bottom-left-radius:var(--borderRadiusMedium);}",".f1ibwz09::after{border-bottom-right-radius:var(--borderRadiusMedium);}",[".f1mnjydx::after{border-bottom:var(--strokeWidthThick) solid var(--colorCompoundBrandStroke);}",{p:-1}],".f13evtba::after{clip-path:inset(calc(100% - 2px) 0 0 0);}",".f1yk9hq::after{transform:scaleX(0);}",".fhwpy7i::after{transition-property:transform;}",".f14ee0xe::after{transition-duration:var(--durationUltraFast);}",".f1xhbsuh::after{transition-delay:var(--curveAccelerateMid);}",".f1hg901r{box-shadow:var(--shadow16);}",[".ft85np5{border-radius:var(--borderRadiusMedium);}",{p:-1}],".fmmk62d{max-height:80vh;}",".fjseox{display:none;}",".f19g0ac{z-index:1;}",".f122n59{align-items:center;}",".f1c21dwh{background-color:var(--colorTransparentBackground);}",[".f3bhgqh{border:none;}",{p:-2}],".f19n0e5{color:var(--colorNeutralForeground1);}",".f14mj54c{column-gap:var(--spacingHorizontalXXS);}",".f1k6fduh{cursor:pointer;}",".f13qh94s{display:grid;}",".fk6fouc{font-family:var(--fontFamilyBase);}",".f12nh0o2{grid-template-columns:[content] 1fr [icon] auto [end];}",".f1869bpl{justify-content:space-between;}",".f1o700av{text-align:left;}",".fes3tcz{text-align:right;}",".fly5x3f{width:100%;}",".fxc4j92{color:var(--colorNeutralForeground4);}",".fy9rknc{font-size:var(--fontSizeBase200);}",".figsok6{font-weight:var(--fontWeightRegular);}",".fwrc4pm{line-height:var(--lineHeightBase200);}",[".fye6m5k{padding:3px var(--spacingHorizontalSNudge) 3px calc(var(--spacingHorizontalSNudge) + var(--spacingHorizontalXXS));}",{p:-1}],[".f3cq2dl{padding:3px calc(var(--spacingHorizontalSNudge) + var(--spacingHorizontalXXS)) 3px var(--spacingHorizontalSNudge);}",{p:-1}],".fkhj508{font-size:var(--fontSizeBase300);}",".f1i3iumi{line-height:var(--lineHeightBase300);}",[".f14ev680{padding:5px var(--spacingHorizontalMNudge) 5px calc(var(--spacingHorizontalMNudge) + var(--spacingHorizontalXXS));}",{p:-1}],[".f58uxzw{padding:5px calc(var(--spacingHorizontalMNudge) + var(--spacingHorizontalXXS)) 5px var(--spacingHorizontalMNudge);}",{p:-1}],".f1rjii52{column-gap:var(--spacingHorizontalSNudge);}",".fod5ikn{font-size:var(--fontSizeBase400);}",".faaz57k{line-height:var(--lineHeightBase400);}",[".f139mn7i{padding:7px var(--spacingHorizontalM) 7px calc(var(--spacingHorizontalM) + var(--spacingHorizontalSNudge));}",{p:-1}],[".f1v3q0m{padding:7px calc(var(--spacingHorizontalM) + var(--spacingHorizontalSNudge)) 7px var(--spacingHorizontalM);}",{p:-1}],".fxugw4r{background-color:var(--colorNeutralBackground1);}",[".fhz96rm{border:var(--strokeWidthThin) solid var(--colorNeutralStroke1);}",{p:-2}],".f1c1zstj{border-bottom-color:var(--colorNeutralStrokeAccessible);}",[".f9ez7ne{border-bottom:var(--strokeWidthThin) solid var(--colorNeutralStrokeAccessible);}",{p:-1}],[".fokr779{border-radius:0;}",{p:-1}],[".fs2rfia{border:var(--strokeWidthThin) solid transparent;}",{p:-2}],".f16xq7d1{background-color:var(--colorNeutralBackground3);}",[".fs2rfia{border:var(--strokeWidthThin) solid transparent;}",{p:-2}],".fs4k3qj:not(:focus-within),.fs4k3qj:hover:not(:focus-within){border-top-color:var(--colorPaletteRedBorder2);}",".fcee079:not(:focus-within),.fcee079:hover:not(:focus-within){border-right-color:var(--colorPaletteRedBorder2);}",".fmyw78r:not(:focus-within),.fmyw78r:hover:not(:focus-within){border-left-color:var(--colorPaletteRedBorder2);}",".f1fgmyf4:not(:focus-within),.f1fgmyf4:hover:not(:focus-within){border-bottom-color:var(--colorPaletteRedBorder2);}",".fdrzuqr{cursor:not-allowed;}",".f1jj8ep1{border-top-color:var(--colorNeutralStrokeDisabled);}",".f15xbau{border-right-color:var(--colorNeutralStrokeDisabled);}",".fy0fskl{border-left-color:var(--colorNeutralStrokeDisabled);}",".f4ikngz{border-bottom-color:var(--colorNeutralStrokeDisabled);}",".f1s2aq7o{color:var(--colorNeutralForegroundDisabled);}"],w:[".f14a1fxs:focus-within{outline-width:2px;}",".f3e99gv:focus-within{outline-style:solid;}",".fhljsf7:focus-within{outline-color:transparent;}",".fjw5xc1:focus-within::after{transform:scaleX(1);}",".f1xdyd5c:focus-within::after{transition-property:transform;}",".fatpbeo:focus-within::after{transition-duration:var(--durationNormal);}",".fb7uyps:focus-within::after{transition-delay:var(--curveDecelerateMid);}",".f1ibeo51:focus-within:active::after{border-bottom-color:var(--colorCompoundBrandStrokePressed);}",".f1b473iu:focus-within{border-top-color:var(--colorNeutralStroke1Pressed);}",".f381qr8:focus-within{border-right-color:var(--colorNeutralStroke1Pressed);}",".ft4skwv:focus-within{border-left-color:var(--colorNeutralStroke1Pressed);}",".f1qzcrsd:focus-within{border-bottom-color:var(--colorNeutralStrokeAccessiblePressed);}"],m:[["@media screen and (prefers-reduced-motion: reduce){.fsrmcvb::after{transition-duration:0.01ms;}}",{m:"screen and (prefers-reduced-motion: reduce)"}],["@media screen and (prefers-reduced-motion: reduce){.f1t3k7v9::after{transition-delay:0.01ms;}}",{m:"screen and (prefers-reduced-motion: reduce)"}],["@media screen and (prefers-reduced-motion: reduce){.fx04xgm:focus-within::after{transition-duration:0.01ms;}}",{m:"screen and (prefers-reduced-motion: reduce)"}],["@media screen and (prefers-reduced-motion: reduce){.f1c7in40:focus-within::after{transition-delay:0.01ms;}}",{m:"screen and (prefers-reduced-motion: reduce)"}],["@media (forced-colors: active){.f9dbb4x{border-top-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f3qs60o{border-right-color:GrayText;}.f5u9ap2{border-left-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fwd1oij{border-bottom-color:GrayText;}}",{m:"(forced-colors: active)"}]],t:["@supports selector(:has(*)){.f18qfb8s:has(.fui-Dropdown__clearButton:focus)::after{border-bottom-color:initial;}}","@supports selector(:has(*)){.f1m082s7:has(.fui-Dropdown__clearButton:focus)::after{transform:scaleX(0);}}"],f:[".ftqa4ok:focus{outline-style:none;}"],h:[".fvcxoqz:hover{border-top-color:var(--colorNeutralStroke1Hover);}",".f1ub3y4t:hover{border-right-color:var(--colorNeutralStroke1Hover);}",".f1m52nbi:hover{border-left-color:var(--colorNeutralStroke1Hover);}",".f1l4zc64:hover{border-bottom-color:var(--colorNeutralStrokeAccessibleHover);}"],a:[".fvs00aa:active{border-top-color:var(--colorNeutralStroke1Pressed);}",".f1assf6x:active{border-right-color:var(--colorNeutralStroke1Pressed);}",".f4ruux4:active{border-left-color:var(--colorNeutralStroke1Pressed);}",".f1z0osm6:active{border-bottom-color:var(--colorNeutralStrokeAccessiblePressed);}"]}),qN=Pe({icon:{B7ck84d:"f1ewtqcl",sj55zd:"fxkbij4",mc9l5x:"ftgm304",Be2twd7:"f1pp30po",Br312pm:"f12w6cgp",Bw0ie65:"f8bv1bt",Bo70h7d:"fvc9v3g"},small:{Be2twd7:"f4ybsrx",Frg6f3:["f1h9en5y","f1xk557c"]},medium:{Be2twd7:"fe5j1ua",Frg6f3:["f1h9en5y","f1xk557c"]},large:{Be2twd7:"f1rt2boy",Frg6f3:["f1t5qyk5","f1ikr372"]},disabled:{sj55zd:"f1s2aq7o"}},{d:[".f1ewtqcl{box-sizing:border-box;}",".fxkbij4{color:var(--colorNeutralStrokeAccessible);}",".ftgm304{display:block;}",".f1pp30po{font-size:var(--fontSizeBase500);}",".f12w6cgp{grid-column-start:icon;}",".f8bv1bt{grid-column-end:end;}",".fvc9v3g svg{display:block;}",".f4ybsrx{font-size:16px;}",".f1h9en5y{margin-left:var(--spacingHorizontalXXS);}",".f1xk557c{margin-right:var(--spacingHorizontalXXS);}",".fe5j1ua{font-size:20px;}",".f1rt2boy{font-size:24px;}",".f1t5qyk5{margin-left:var(--spacingHorizontalSNudge);}",".f1ikr372{margin-right:var(--spacingHorizontalSNudge);}",".f1s2aq7o{color:var(--colorNeutralForegroundDisabled);}"]}),VN=en("rticfuj","r1vp6jef",{r:[".rticfuj{align-self:center;background-color:var(--colorTransparentBackground);border:none;cursor:pointer;height:fit-content;margin:0;margin-right:var(--spacingHorizontalMNudge);padding:0;position:relative;}",".rticfuj:focus{outline-style:none;}",".rticfuj:focus-visible{outline-style:none;}",".rticfuj[data-fui-focus-visible]{border-top-color:transparent;border-right-color:transparent;border-bottom-color:transparent;border-left-color:transparent;}",'.rticfuj[data-fui-focus-visible]::after{content:"";position:absolute;pointer-events:none;z-index:1;border:2px solid var(--colorStrokeFocus2);border-radius:var(--borderRadiusMedium);top:calc(2px * -1);right:calc(2px * -1);bottom:calc(2px * -1);left:calc(2px * -1);}',".r1vp6jef{align-self:center;background-color:var(--colorTransparentBackground);border:none;cursor:pointer;height:fit-content;margin:0;margin-left:var(--spacingHorizontalMNudge);padding:0;position:relative;}",".r1vp6jef:focus{outline-style:none;}",".r1vp6jef:focus-visible{outline-style:none;}",".r1vp6jef[data-fui-focus-visible]{border-top-color:transparent;border-left-color:transparent;border-bottom-color:transparent;border-right-color:transparent;}",'.r1vp6jef[data-fui-focus-visible]::after{content:"";position:absolute;pointer-events:none;z-index:1;border:2px solid var(--colorStrokeFocus2);border-radius:var(--borderRadiusMedium);top:calc(2px * -1);left:calc(2px * -1);bottom:calc(2px * -1);right:calc(2px * -1);}'],s:["@media (forced-colors: active){.rticfuj[data-fui-focus-visible]::after{border-top-color:Highlight;border-right-color:Highlight;border-bottom-color:Highlight;border-left-color:Highlight;}}","@media (forced-colors: active){.r1vp6jef[data-fui-focus-visible]::after{border-top-color:Highlight;border-left-color:Highlight;border-bottom-color:Highlight;border-right-color:Highlight;}}"]}),UN=e=>{"use no memo";const{appearance:t,open:n,placeholderVisible:i,showClearButton:s,size:a}=e,o=`${e.button["aria-invalid"]}`=="true",l=e.button.disabled,c=$N(),u=qN(),m=VN();return e.root.className=_e(Li.root,c.root,c[t],!l&&t==="outline"&&c.outlineInteractive,o&&t!=="underline"&&c.invalid,o&&t==="underline"&&c.invalidUnderline,l&&c.disabled,e.root.className),e.button.className=_e(Li.button,c.button,c[a],i&&c.placeholder,l&&c.disabledText,e.button.className),e.listbox&&(e.listbox.className=_e(Li.listbox,c.listbox,e.inlinePopup&&c.inlineListbox,!n&&c.listboxCollapsed,e.listbox.className)),e.expandIcon&&(e.expandIcon.className=_e(Li.expandIcon,u.icon,u[a],l&&u.disabled,s&&c.hidden,e.expandIcon.className)),e.clearButton&&(e.clearButton.className=_e(Li.clearButton,m,u.icon,u[a],l&&u.disabled,!s&&c.hidden,e.clearButton.className)),e},Fa=f.forwardRef((e,t)=>{const n=MN(e,t),i=vN(n);return UN(n),zn("useDropdownStyles_unstable")(n),FN(n,i)});Fa.displayName="Dropdown";const HN=(e,t)=>{e=rl(e,{supportsLabelFor:!0,supportsRequired:!0,supportsSize:!0});const n=Dx();var i;const{size:s="medium",appearance:a=(i=n.inputDefaultAppearance)!==null&&i!==void 0?i:"outline",...o}=e,l=KN(o,t);return{size:s,appearance:a,...l}},KN=(e,t)=>{const{onChange:n}=e,[i,s]=ks({state:e.value,defaultState:e.defaultValue,initialState:""}),a=Od({props:e,primarySlotTagName:"input",excludedPropNames:["onChange","value","defaultValue"]}),o={components:{root:"span",input:"input",contentBefore:"span",contentAfter:"span"},input:vt(e.input,{defaultProps:{type:"text",ref:t,...a.primary},elementType:"input"}),contentAfter:et(e.contentAfter,{elementType:"span"}),contentBefore:et(e.contentBefore,{elementType:"span"}),root:vt(e.root,{defaultProps:a.root,elementType:"span"})};return o.input.value=i,o.input.onChange=qe(l=>{const c=l.target.value;n==null||n(l,{value:c}),s(c)}),o},WN=e=>wn(e.root,{children:[e.contentBefore&&ke(e.contentBefore,{}),ke(e.input,{}),e.contentAfter&&ke(e.contentAfter,{})]}),xa={root:"fui-Input",input:"fui-Input__input",contentBefore:"fui-Input__contentBefore",contentAfter:"fui-Input__contentAfter"},GN=en("r1oeeo9n","r9sxh5",{r:[".r1oeeo9n{display:inline-flex;align-items:center;flex-wrap:nowrap;gap:var(--spacingHorizontalXXS);border-radius:var(--borderRadiusMedium);position:relative;box-sizing:border-box;vertical-align:middle;min-height:32px;font-family:var(--fontFamilyBase);font-size:var(--fontSizeBase300);font-weight:var(--fontWeightRegular);line-height:var(--lineHeightBase300);background-color:var(--colorNeutralBackground1);border:1px solid var(--colorNeutralStroke1);border-bottom-color:var(--colorNeutralStrokeAccessible);}",'.r1oeeo9n::after{box-sizing:border-box;content:"";position:absolute;left:-1px;bottom:-1px;right:-1px;height:max(2px, var(--borderRadiusMedium));border-bottom-left-radius:var(--borderRadiusMedium);border-bottom-right-radius:var(--borderRadiusMedium);border-bottom:2px solid var(--colorCompoundBrandStroke);clip-path:inset(calc(100% - 2px) 0 0 0);transform:scaleX(0);transition-property:transform;transition-duration:var(--durationUltraFast);transition-delay:var(--curveAccelerateMid);}',".r1oeeo9n:focus-within::after{transform:scaleX(1);transition-property:transform;transition-duration:var(--durationNormal);transition-delay:var(--curveDecelerateMid);}",".r1oeeo9n:focus-within:active::after{border-bottom-color:var(--colorCompoundBrandStrokePressed);}",".r1oeeo9n:focus-within{outline:2px solid transparent;}",".r9sxh5{display:inline-flex;align-items:center;flex-wrap:nowrap;gap:var(--spacingHorizontalXXS);border-radius:var(--borderRadiusMedium);position:relative;box-sizing:border-box;vertical-align:middle;min-height:32px;font-family:var(--fontFamilyBase);font-size:var(--fontSizeBase300);font-weight:var(--fontWeightRegular);line-height:var(--lineHeightBase300);background-color:var(--colorNeutralBackground1);border:1px solid var(--colorNeutralStroke1);border-bottom-color:var(--colorNeutralStrokeAccessible);}",'.r9sxh5::after{box-sizing:border-box;content:"";position:absolute;right:-1px;bottom:-1px;left:-1px;height:max(2px, var(--borderRadiusMedium));border-bottom-right-radius:var(--borderRadiusMedium);border-bottom-left-radius:var(--borderRadiusMedium);border-bottom:2px solid var(--colorCompoundBrandStroke);clip-path:inset(calc(100% - 2px) 0 0 0);transform:scaleX(0);transition-property:transform;transition-duration:var(--durationUltraFast);transition-delay:var(--curveAccelerateMid);}',".r9sxh5:focus-within::after{transform:scaleX(1);transition-property:transform;transition-duration:var(--durationNormal);transition-delay:var(--curveDecelerateMid);}",".r9sxh5:focus-within:active::after{border-bottom-color:var(--colorCompoundBrandStrokePressed);}",".r9sxh5:focus-within{outline:2px solid transparent;}"],s:["@media screen and (prefers-reduced-motion: reduce){.r1oeeo9n::after{transition-duration:0.01ms;transition-delay:0.01ms;}}","@media screen and (prefers-reduced-motion: reduce){.r1oeeo9n:focus-within::after{transition-duration:0.01ms;transition-delay:0.01ms;}}","@media screen and (prefers-reduced-motion: reduce){.r9sxh5::after{transition-duration:0.01ms;transition-delay:0.01ms;}}","@media screen and (prefers-reduced-motion: reduce){.r9sxh5:focus-within::after{transition-duration:0.01ms;transition-delay:0.01ms;}}"]}),XN=Pe({small:{sshi5w:"f1pha7fy",Bahqtrf:"fk6fouc",Be2twd7:"fy9rknc",Bhrd7zp:"figsok6",Bg96gwp:"fwrc4pm"},medium:{},large:{sshi5w:"f1w5jphr",Bahqtrf:"fk6fouc",Be2twd7:"fod5ikn",Bhrd7zp:"figsok6",Bg96gwp:"faaz57k",i8kkvl:0,Belr9w4:0,rmohyg:"f1eyhf9v"},outline:{},outlineInteractive:{Bgoe8wy:"fvcxoqz",Bwzppfd:["f1ub3y4t","f1m52nbi"],oetu4i:"f1l4zc64",gg5e9n:["f1m52nbi","f1ub3y4t"],Drbcw7:"f8vnjqi",udz0bu:["fz1etlk","f1hc16gm"],Be8ivqh:"f1klwx88",ofdepl:["f1hc16gm","fz1etlk"]},underline:{De3pzq:"f1c21dwh",Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"fokr779",icvyot:"f1ern45e",vrafjx:["f1n71otn","f1deefiw"],wvpqe5:["f1deefiw","f1n71otn"],Eqx8gd:["f1n6gb5g","f15yvnhg"],B1piin3:["f15yvnhg","f1n6gb5g"]},underlineInteractive:{oetu4i:"f1l4zc64",Be8ivqh:"f1klwx88",d9w3h3:0,B3778ie:0,B4j8arr:0,Bl18szs:0,Blrzh8d:"f2ale1x"},filled:{g2u3we:"fghlq4f",h3c5rm:["f1gn591s","fjscplz"],B9xav0g:"fb073pr",zhjwy3:["fjscplz","f1gn591s"]},filledInteractive:{q7v0qe:"ftmjh5b",kmh5ft:["f17blpuu","fsrcdbj"],nagaa4:"f1tpwn32",B1yhkcb:["fsrcdbj","f17blpuu"]},invalid:{tvckwq:"fs4k3qj",gk2u95:["fcee079","fmyw78r"],hhx65j:"f1fgmyf4",Bxowmz0:["fmyw78r","fcee079"]},"filled-darker":{De3pzq:"f16xq7d1"},"filled-lighter":{De3pzq:"fxugw4r"},"filled-darker-shadow":{De3pzq:"f16xq7d1",E5pizo:"fyed02w"},"filled-lighter-shadow":{De3pzq:"fxugw4r",E5pizo:"fyed02w"},disabled:{Bceei9c:"fdrzuqr",De3pzq:"f1c21dwh",g2u3we:"f1jj8ep1",h3c5rm:["f15xbau","fy0fskl"],B9xav0g:"f4ikngz",zhjwy3:["fy0fskl","f15xbau"],Bcq6wej:"f9dbb4x",Jcjdmf:["f3qs60o","f5u9ap2"],sc4o1m:"fwd1oij",Bosien3:["f5u9ap2","f3qs60o"],Bsft5z2:"fhr9occ",Bduesf4:"f99w1ws"},smallWithContentBefore:{uwmqm3:["fk8j09s","fdw0yi8"]},smallWithContentAfter:{z189sj:["fdw0yi8","fk8j09s"]},mediumWithContentBefore:{uwmqm3:["f1ng84yb","f11gcy0p"]},mediumWithContentAfter:{z189sj:["f11gcy0p","f1ng84yb"]},largeWithContentBefore:{uwmqm3:["f1uw59to","fw5db7e"]},largeWithContentAfter:{z189sj:["fw5db7e","f1uw59to"]}},{d:[".f1pha7fy{min-height:24px;}",".fk6fouc{font-family:var(--fontFamilyBase);}",".fy9rknc{font-size:var(--fontSizeBase200);}",".figsok6{font-weight:var(--fontWeightRegular);}",".fwrc4pm{line-height:var(--lineHeightBase200);}",".f1w5jphr{min-height:40px;}",".fod5ikn{font-size:var(--fontSizeBase400);}",".faaz57k{line-height:var(--lineHeightBase400);}",[".f1eyhf9v{gap:var(--spacingHorizontalSNudge);}",{p:-1}],".f1c21dwh{background-color:var(--colorTransparentBackground);}",[".fokr779{border-radius:0;}",{p:-1}],".f1ern45e{border-top-style:none;}",".f1n71otn{border-right-style:none;}",".f1deefiw{border-left-style:none;}",".f1n6gb5g::after{left:0;}",".f15yvnhg::after{right:0;}",[".f2ale1x::after{border-radius:0;}",{p:-1}],".fghlq4f{border-top-color:var(--colorTransparentStroke);}",".f1gn591s{border-right-color:var(--colorTransparentStroke);}",".fjscplz{border-left-color:var(--colorTransparentStroke);}",".fb073pr{border-bottom-color:var(--colorTransparentStroke);}",".fs4k3qj:not(:focus-within),.fs4k3qj:hover:not(:focus-within){border-top-color:var(--colorPaletteRedBorder2);}",".fcee079:not(:focus-within),.fcee079:hover:not(:focus-within){border-right-color:var(--colorPaletteRedBorder2);}",".fmyw78r:not(:focus-within),.fmyw78r:hover:not(:focus-within){border-left-color:var(--colorPaletteRedBorder2);}",".f1fgmyf4:not(:focus-within),.f1fgmyf4:hover:not(:focus-within){border-bottom-color:var(--colorPaletteRedBorder2);}",".f16xq7d1{background-color:var(--colorNeutralBackground3);}",".fxugw4r{background-color:var(--colorNeutralBackground1);}",".fyed02w{box-shadow:var(--shadow2);}",".fdrzuqr{cursor:not-allowed;}",".f1jj8ep1{border-top-color:var(--colorNeutralStrokeDisabled);}",".f15xbau{border-right-color:var(--colorNeutralStrokeDisabled);}",".fy0fskl{border-left-color:var(--colorNeutralStrokeDisabled);}",".f4ikngz{border-bottom-color:var(--colorNeutralStrokeDisabled);}",".fhr9occ::after{content:unset;}",".fk8j09s{padding-left:var(--spacingHorizontalSNudge);}",".fdw0yi8{padding-right:var(--spacingHorizontalSNudge);}",".f1ng84yb{padding-left:var(--spacingHorizontalMNudge);}",".f11gcy0p{padding-right:var(--spacingHorizontalMNudge);}",".f1uw59to{padding-left:var(--spacingHorizontalM);}",".fw5db7e{padding-right:var(--spacingHorizontalM);}"],h:[".fvcxoqz:hover{border-top-color:var(--colorNeutralStroke1Hover);}",".f1ub3y4t:hover{border-right-color:var(--colorNeutralStroke1Hover);}",".f1m52nbi:hover{border-left-color:var(--colorNeutralStroke1Hover);}",".f1l4zc64:hover{border-bottom-color:var(--colorNeutralStrokeAccessibleHover);}",".ftmjh5b:hover,.ftmjh5b:focus-within{border-top-color:var(--colorTransparentStrokeInteractive);}",".f17blpuu:hover,.f17blpuu:focus-within{border-right-color:var(--colorTransparentStrokeInteractive);}",".fsrcdbj:hover,.fsrcdbj:focus-within{border-left-color:var(--colorTransparentStrokeInteractive);}",".f1tpwn32:hover,.f1tpwn32:focus-within{border-bottom-color:var(--colorTransparentStrokeInteractive);}"],a:[".f8vnjqi:active,.f8vnjqi:focus-within{border-top-color:var(--colorNeutralStroke1Pressed);}",".fz1etlk:active,.fz1etlk:focus-within{border-right-color:var(--colorNeutralStroke1Pressed);}",".f1hc16gm:active,.f1hc16gm:focus-within{border-left-color:var(--colorNeutralStroke1Pressed);}",".f1klwx88:active,.f1klwx88:focus-within{border-bottom-color:var(--colorNeutralStrokeAccessiblePressed);}"],m:[["@media (forced-colors: active){.f9dbb4x{border-top-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f3qs60o{border-right-color:GrayText;}.f5u9ap2{border-left-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fwd1oij{border-bottom-color:GrayText;}}",{m:"(forced-colors: active)"}]],w:[".f99w1ws:focus-within{outline-style:none;}"]}),QN=en("r12stul0",null,[".r12stul0{align-self:stretch;box-sizing:border-box;flex-grow:1;min-width:0;border-style:none;padding:0 var(--spacingHorizontalM);color:var(--colorNeutralForeground1);background-color:transparent;outline-style:none;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;}",".r12stul0::-webkit-input-placeholder{color:var(--colorNeutralForeground4);opacity:1;}",".r12stul0::-moz-placeholder{color:var(--colorNeutralForeground4);opacity:1;}",".r12stul0::placeholder{color:var(--colorNeutralForeground4);opacity:1;}"]),ZN=Pe({small:{uwmqm3:["f1f5gg8d","f1vdfbxk"],z189sj:["f1vdfbxk","f1f5gg8d"]},medium:{},large:{uwmqm3:["fnphzt9","flt1dlf"],z189sj:["flt1dlf","fnphzt9"]},smallWithContentBefore:{uwmqm3:["fgiv446","ffczdla"]},smallWithContentAfter:{z189sj:["ffczdla","fgiv446"]},mediumWithContentBefore:{uwmqm3:["fgiv446","ffczdla"]},mediumWithContentAfter:{z189sj:["ffczdla","fgiv446"]},largeWithContentBefore:{uwmqm3:["fk8j09s","fdw0yi8"]},largeWithContentAfter:{z189sj:["fdw0yi8","fk8j09s"]},disabled:{sj55zd:"f1s2aq7o",De3pzq:"f1c21dwh",Bceei9c:"fdrzuqr",yvdlaj:"fahhnxm"}},{d:[".f1f5gg8d{padding-left:var(--spacingHorizontalS);}",".f1vdfbxk{padding-right:var(--spacingHorizontalS);}",".fnphzt9{padding-left:calc(var(--spacingHorizontalM) + var(--spacingHorizontalSNudge));}",".flt1dlf{padding-right:calc(var(--spacingHorizontalM) + var(--spacingHorizontalSNudge));}",".fgiv446{padding-left:var(--spacingHorizontalXXS);}",".ffczdla{padding-right:var(--spacingHorizontalXXS);}",".fk8j09s{padding-left:var(--spacingHorizontalSNudge);}",".fdw0yi8{padding-right:var(--spacingHorizontalSNudge);}",".f1s2aq7o{color:var(--colorNeutralForegroundDisabled);}",".f1c21dwh{background-color:var(--colorTransparentBackground);}",".fdrzuqr{cursor:not-allowed;}",".fahhnxm::-webkit-input-placeholder{color:var(--colorNeutralForegroundDisabled);}",".fahhnxm::-moz-placeholder{color:var(--colorNeutralForegroundDisabled);}"]}),YN=en("r1572tok",null,[".r1572tok{box-sizing:border-box;color:var(--colorNeutralForeground3);display:flex;}",".r1572tok>svg{font-size:20px;}"]),JN=Pe({disabled:{sj55zd:"f1s2aq7o"},small:{Duoase:"f3qv9w"},medium:{},large:{Duoase:"f16u2scb"}},{d:[".f1s2aq7o{color:var(--colorNeutralForegroundDisabled);}",".f3qv9w>svg{font-size:16px;}",".f16u2scb>svg{font-size:24px;}"]}),e2=e=>{"use no memo";const{size:t,appearance:n}=e,i=e.input.disabled,s=`${e.input["aria-invalid"]}`=="true",a=n.startsWith("filled"),o=XN(),l=ZN(),c=JN();e.root.className=_e(xa.root,GN(),o[t],e.contentBefore&&o[`${t}WithContentBefore`],e.contentAfter&&o[`${t}WithContentAfter`],o[n],!i&&n==="outline"&&o.outlineInteractive,!i&&n==="underline"&&o.underlineInteractive,!i&&a&&o.filledInteractive,a&&o.filled,!i&&s&&o.invalid,i&&o.disabled,e.root.className),e.input.className=_e(xa.input,QN(),l[t],e.contentBefore&&l[`${t}WithContentBefore`],e.contentAfter&&l[`${t}WithContentAfter`],i&&l.disabled,e.input.className);const u=[YN(),i&&c.disabled,c[t]];return e.contentBefore&&(e.contentBefore.className=_e(xa.contentBefore,...u,e.contentBefore.className)),e.contentAfter&&(e.contentAfter.className=_e(xa.contentAfter,...u,e.contentAfter.className)),e},mn=f.forwardRef((e,t)=>{const n=HN(e,t);return e2(n),zn("useInputStyles_unstable")(n),WN(n)});mn.displayName="Input";const Ev=f.createContext(void 0),t2={};Ev.Provider;const n2=()=>{var e;return(e=f.useContext(Ev))!==null&&e!==void 0?e:t2},r2=(e,t)=>{const{size:n}=n2(),{appearance:i="primary",size:s=n??"medium",...a}=e,o=i2(a,t);return{...o,appearance:i,size:s,components:{...o.components,label:Cs},label:et(e.label,{defaultProps:o.label,elementType:Cs})}},i2=(e,t)=>{const{delay:n=0,labelPosition:i="after"}=e,s=Zo("spinner"),{role:a="progressbar",...o}=e,l=vt(Ds("div",{ref:t,role:a,...o}),{elementType:"div"}),[c,u]=f.useState(!1),[m,d]=Mx();f.useEffect(()=>{if(!(n<=0))return m(()=>{u(!0)},n),()=>{d()}},[m,d,n]);const h=et(e.label,{defaultProps:{id:s},renderByDefault:!1,elementType:"label"}),x=et(e.spinner,{renderByDefault:!0,elementType:"span"});return h&&l&&!l["aria-labelledby"]&&(l["aria-labelledby"]=h.id),{delay:n,labelPosition:i,shouldRenderSpinner:!n||c,components:{root:"div",spinner:"span",spinnerTail:"span",label:"label"},root:l,spinner:x,spinnerTail:vt(e.spinnerTail,{elementType:"span"}),label:h}},s2=e=>{const{labelPosition:t,shouldRenderSpinner:n}=e;return wn(e.root,{children:[e.label&&n&&(t==="above"||t==="before")&&ke(e.label,{}),e.spinner&&n&&ke(e.spinner,{children:e.spinnerTail&&ke(e.spinnerTail,{})}),e.label&&n&&(t==="below"||t==="after")&&ke(e.label,{})]})},va={root:"fui-Spinner",spinner:"fui-Spinner__spinner",spinnerTail:"fui-Spinner__spinnerTail",label:"fui-Spinner__label"},a2=en("rpp59a7",null,[".rpp59a7{display:flex;align-items:center;justify-content:center;line-height:0;gap:8px;overflow:hidden;min-width:min-content;}"]),o2=Pe({vertical:{Beiy3e4:"f1vx9l62"}},{d:[".f1vx9l62{flex-direction:column;}"]}),l2=en("rvgcg50","r15nd2jo",{r:[".rvgcg50{position:relative;flex-shrink:0;-webkit-mask-image:radial-gradient(closest-side, transparent calc(100% - var(--fui-Spinner--strokeWidth) - 1px), white calc(100% - var(--fui-Spinner--strokeWidth)) calc(100% - 1px), transparent 100%);mask-image:radial-gradient(closest-side, transparent calc(100% - var(--fui-Spinner--strokeWidth) - 1px), white calc(100% - var(--fui-Spinner--strokeWidth)) calc(100% - 1px), transparent 100%);background-color:var(--colorBrandStroke2Contrast);color:var(--colorBrandStroke1);animation-duration:1.5s;animation-iteration-count:infinite;animation-timing-function:linear;animation-name:rb7n1on;}","@keyframes rb7n1on{0%{transform:rotate(0deg);}100%{transform:rotate(360deg);}}",".r15nd2jo{position:relative;flex-shrink:0;-webkit-mask-image:radial-gradient(closest-side, transparent calc(100% - var(--fui-Spinner--strokeWidth) - 1px), white calc(100% - var(--fui-Spinner--strokeWidth)) calc(100% - 1px), transparent 100%);mask-image:radial-gradient(closest-side, transparent calc(100% - var(--fui-Spinner--strokeWidth) - 1px), white calc(100% - var(--fui-Spinner--strokeWidth)) calc(100% - 1px), transparent 100%);background-color:var(--colorBrandStroke2Contrast);color:var(--colorBrandStroke1);animation-duration:1.5s;animation-iteration-count:infinite;animation-timing-function:linear;animation-name:r1gx3jof;}","@keyframes r1gx3jof{0%{transform:rotate(0deg);}100%{transform:rotate(-360deg);}}"],s:["@media screen and (forced-colors: active){.rvgcg50{background-color:HighlightText;color:Highlight;forced-color-adjust:none;}}","@media screen and (prefers-reduced-motion: reduce){.rvgcg50{animation-duration:1.8s;}}","@media screen and (forced-colors: active){.r15nd2jo{background-color:HighlightText;color:Highlight;forced-color-adjust:none;}}","@media screen and (prefers-reduced-motion: reduce){.r15nd2jo{animation-duration:1.8s;}}"]}),c2=en("rxov3xa","r1o544mv",{r:[".rxov3xa{position:absolute;display:block;width:100%;height:100%;-webkit-mask-image:conic-gradient(transparent 105deg, white 105deg);mask-image:conic-gradient(transparent 105deg, white 105deg);animation-duration:1.5s;animation-iteration-count:infinite;animation-timing-function:var(--curveEasyEase);animation-name:r15mim6k;}",'.rxov3xa::before,.rxov3xa::after{content:"";position:absolute;display:block;width:100%;height:100%;animation:inherit;background-image:conic-gradient(currentcolor 135deg, transparent 135deg);}',"@keyframes r15mim6k{0%{transform:rotate(-135deg);}50%{transform:rotate(0deg);}100%{transform:rotate(225deg);}}",".rxov3xa::before{animation-name:r18vhmn8;}","@keyframes r18vhmn8{0%{transform:rotate(0deg);}50%{transform:rotate(105deg);}100%{transform:rotate(0deg);}}",".rxov3xa::after{animation-name:rkgrvoi;}","@keyframes rkgrvoi{0%{transform:rotate(0deg);}50%{transform:rotate(225deg);}100%{transform:rotate(0deg);}}",".r1o544mv{position:absolute;display:block;width:100%;height:100%;-webkit-mask-image:conic-gradient(transparent 105deg, white 105deg);mask-image:conic-gradient(transparent 105deg, white 105deg);animation-duration:1.5s;animation-iteration-count:infinite;animation-timing-function:var(--curveEasyEase);animation-name:r109gmi5;}",'.r1o544mv::before,.r1o544mv::after{content:"";position:absolute;display:block;width:100%;height:100%;animation:inherit;background-image:conic-gradient(currentcolor 135deg, transparent 135deg);}',"@keyframes r109gmi5{0%{transform:rotate(135deg);}50%{transform:rotate(0deg);}100%{transform:rotate(-225deg);}}",".r1o544mv::before{animation-name:r17whflh;}","@keyframes r17whflh{0%{transform:rotate(0deg);}50%{transform:rotate(-105deg);}100%{transform:rotate(0deg);}}",".r1o544mv::after{animation-name:re4odhl;}","@keyframes re4odhl{0%{transform:rotate(0deg);}50%{transform:rotate(-225deg);}100%{transform:rotate(0deg);}}"],s:["@media screen and (prefers-reduced-motion: reduce){.rxov3xa{animation-iteration-count:0;background-image:conic-gradient(transparent 120deg, currentcolor 360deg);}.rxov3xa::before,.rxov3xa::after{content:none;}}","@media screen and (prefers-reduced-motion: reduce){.r1o544mv{animation-iteration-count:0;background-image:conic-gradient(transparent 120deg, currentcolor 360deg);}.r1o544mv::before,.r1o544mv::after{content:none;}}"]}),u2=Pe({inverted:{De3pzq:"fr407j0",sj55zd:"f1f7voed"},rtlTail:{btxmck:"f179dep3",gb5jj2:"fbz9ihp",Bdya8wy:"f1pme1qz"},"extra-tiny":{Bqenvij:"fd461yt",a9b677:"fjw5fx7",qmp6fs:"f1v3ph3m"},tiny:{Bqenvij:"fjamq6b",a9b677:"f64fuq3",qmp6fs:"f1v3ph3m"},"extra-small":{Bqenvij:"frvgh55",a9b677:"fq4mcun",qmp6fs:"f1v3ph3m"},small:{Bqenvij:"fxldao9",a9b677:"f1w9dchk",qmp6fs:"f1v3ph3m"},medium:{Bqenvij:"f1d2rq10",a9b677:"f1szoe96",qmp6fs:"fb52u90"},large:{Bqenvij:"f8ljn23",a9b677:"fpdz1er",qmp6fs:"fb52u90"},"extra-large":{Bqenvij:"fbhnoac",a9b677:"feqmc2u",qmp6fs:"fb52u90"},huge:{Bqenvij:"f1ft4266",a9b677:"fksc0bp",qmp6fs:"fa3u9ii"}},{d:[".fr407j0{background-color:var(--colorNeutralStrokeAlpha2);}",".f1f7voed{color:var(--colorNeutralStrokeOnBrand2);}",".f179dep3{-webkit-mask-image:conic-gradient(white 255deg, transparent 255deg);mask-image:conic-gradient(white 255deg, transparent 255deg);}",".fbz9ihp::before,.fbz9ihp::after{background-image:conic-gradient(transparent 225deg, currentcolor 225deg);}",".fd461yt{height:16px;}",".fjw5fx7{width:16px;}",".f1v3ph3m{--fui-Spinner--strokeWidth:var(--strokeWidthThick);}",".fjamq6b{height:20px;}",".f64fuq3{width:20px;}",".frvgh55{height:24px;}",".fq4mcun{width:24px;}",".fxldao9{height:28px;}",".f1w9dchk{width:28px;}",".f1d2rq10{height:32px;}",".f1szoe96{width:32px;}",".fb52u90{--fui-Spinner--strokeWidth:var(--strokeWidthThicker);}",".f8ljn23{height:36px;}",".fpdz1er{width:36px;}",".fbhnoac{height:40px;}",".feqmc2u{width:40px;}",".f1ft4266{height:44px;}",".fksc0bp{width:44px;}",".fa3u9ii{--fui-Spinner--strokeWidth:var(--strokeWidthThickest);}"],m:[["@media screen and (prefers-reduced-motion: reduce){.f1pme1qz{background-image:conic-gradient(currentcolor 0deg, transparent 240deg);}}",{m:"screen and (prefers-reduced-motion: reduce)"}]]}),d2=Pe({inverted:{sj55zd:"fonrgv7"},"extra-tiny":{Bahqtrf:"fk6fouc",Be2twd7:"fkhj508",Bhrd7zp:"figsok6",Bg96gwp:"f1i3iumi"},tiny:{Bahqtrf:"fk6fouc",Be2twd7:"fkhj508",Bhrd7zp:"figsok6",Bg96gwp:"f1i3iumi"},"extra-small":{Bahqtrf:"fk6fouc",Be2twd7:"fkhj508",Bhrd7zp:"figsok6",Bg96gwp:"f1i3iumi"},small:{Bahqtrf:"fk6fouc",Be2twd7:"fkhj508",Bhrd7zp:"figsok6",Bg96gwp:"f1i3iumi"},medium:{Bahqtrf:"fk6fouc",Be2twd7:"fod5ikn",Bhrd7zp:"fl43uef",Bg96gwp:"faaz57k"},large:{Bahqtrf:"fk6fouc",Be2twd7:"fod5ikn",Bhrd7zp:"fl43uef",Bg96gwp:"faaz57k"},"extra-large":{Bahqtrf:"fk6fouc",Be2twd7:"fod5ikn",Bhrd7zp:"fl43uef",Bg96gwp:"faaz57k"},huge:{Bahqtrf:"fk6fouc",Be2twd7:"f1pp30po",Bhrd7zp:"fl43uef",Bg96gwp:"f106mvju"}},{d:[".fonrgv7{color:var(--colorNeutralForegroundStaticInverted);}",".fk6fouc{font-family:var(--fontFamilyBase);}",".fkhj508{font-size:var(--fontSizeBase300);}",".figsok6{font-weight:var(--fontWeightRegular);}",".f1i3iumi{line-height:var(--lineHeightBase300);}",".fod5ikn{font-size:var(--fontSizeBase400);}",".fl43uef{font-weight:var(--fontWeightSemibold);}",".faaz57k{line-height:var(--lineHeightBase400);}",".f1pp30po{font-size:var(--fontSizeBase500);}",".f106mvju{line-height:var(--lineHeightBase500);}"]}),f2=e=>{"use no memo";const{labelPosition:t,size:n,appearance:i}=e,{dir:s}=un(),a=a2(),o=o2(),l=l2(),c=u2(),u=c2(),m=d2();return e.root.className=_e(va.root,a,(t==="above"||t==="below")&&o.vertical,e.root.className),e.spinner&&(e.spinner.className=_e(va.spinner,l,c[n],i==="inverted"&&c.inverted,e.spinner.className)),e.spinnerTail&&(e.spinnerTail.className=_e(va.spinnerTail,u,s==="rtl"&&c.rtlTail,e.spinnerTail.className)),e.label&&(e.label.className=_e(va.label,m[n],i==="inverted"&&m.inverted,e.label.className)),e},Dv=f.forwardRef((e,t)=>{const n=r2(e,t);return f2(n),zn("useSpinnerStyles_unstable")(n),s2(n)});Dv.displayName="Spinner";const m2=e=>ke(e.root,{children:ke(e.textarea,{})}),p2=(e,t)=>{const n=Dx();var i;const{size:s="medium",appearance:a=(i=n.inputDefaultAppearance)!==null&&i!==void 0?i:"outline",...o}=e;return{...h2(o,t),size:s,appearance:a}},h2=(e,t)=>{e=rl(e,{supportsLabelFor:!0,supportsRequired:!0,supportsSize:!0});const{resize:n="none",onChange:i}=e,[s,a]=ks({state:e.value,defaultState:e.defaultValue,initialState:void 0}),o=Od({props:e,primarySlotTagName:"textarea",excludedPropNames:["onChange","value","defaultValue"]}),l={resize:n,components:{root:"span",textarea:"textarea"},textarea:vt(e.textarea,{defaultProps:{ref:t,...o.primary},elementType:"textarea"}),root:vt(e.root,{defaultProps:o.root,elementType:"span"})};return l.textarea.value=s,l.textarea.onChange=qe(c=>{const u=c.target.value;i==null||i(c,{value:u}),a(u)}),l},up={root:"fui-Textarea",textarea:"fui-Textarea__textarea"},g2=Pe({base:{mc9l5x:"ftuwxu6",B7ck84d:"f1ewtqcl",qhf8xq:"f10pi13n",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"f1yiegib",jrapky:0,Frg6f3:0,t21cq0:0,B6of3ja:0,B74szlk:"f1s184ao",Beyfa6y:0,Bbmb7ep:0,Btl43ni:0,B7oj6ja:0,Dimara:"ft85np5",ha4doy:"f12kltsn"},disabled:{De3pzq:"f1c21dwh",Bgfg5da:0,B9xav0g:0,oivjwe:0,Bn0qgzm:0,B4g9neb:0,zhjwy3:0,wvpqe5:0,ibv6hh:0,u1mtju:0,h3c5rm:0,vrafjx:0,Bekrc4i:0,i8vvqc:0,g2u3we:0,icvyot:0,B4j52fo:0,irswps:"ff3nzm7",Bcq6wej:"f9dbb4x",Jcjdmf:["f3qs60o","f5u9ap2"],sc4o1m:"fwd1oij",Bosien3:["f5u9ap2","f3qs60o"]},interactive:{li1rpt:"f1gw3sf2",Bsft5z2:"f13zj6fq",E3zdtr:"f1mdlcz9",Eqx8gd:["f1a7op3","f1cjjd47"],By385i5:"f1gboi2j",B1piin3:["f1cjjd47","f1a7op3"],Dlnsje:"ffyw7fx",d9w3h3:["f1kp91vd","f1ibwz09"],B3778ie:["f1ibwz09","f1kp91vd"],B1q35kw:0,Bw17bha:0,Bcgy8vk:0,Bjuhk93:"f1mnjydx",Gjdm7m:"fj2g8qd",b1kco5:"f1yk9hq",Ba2ppi3:"fhwpy7i",F2fol1:"f14ee0xe",lck23g:"f1xhbsuh",wi16st:"fsrmcvb",ywj3b2:"f1t3k7v9",umuwi5:"fjw5xc1",Blcqepd:"f1xdyd5c",nplu4u:"fatpbeo",Bioka5o:"fb7uyps",Bnupc0a:"fx04xgm",bing71:"f1c7in40",Bercvud:"f1ibeo51",Bbr2w1p:"f1vnc8sk",Bduesf4:"f3e99gv",Bpq79vn:"fhljsf7"},filled:{Bgfg5da:0,B9xav0g:0,oivjwe:0,Bn0qgzm:0,B4g9neb:0,zhjwy3:0,wvpqe5:0,ibv6hh:0,u1mtju:0,h3c5rm:0,vrafjx:0,Bekrc4i:0,i8vvqc:0,g2u3we:0,icvyot:0,B4j52fo:0,irswps:"f88035w",q7v0qe:"ftmjh5b",kmh5ft:["f17blpuu","fsrcdbj"],nagaa4:"f1tpwn32",B1yhkcb:["fsrcdbj","f17blpuu"]},"filled-darker":{De3pzq:"f16xq7d1"},"filled-lighter":{De3pzq:"fxugw4r"},"filled-darker-shadow":{De3pzq:"f16xq7d1",Bgfg5da:0,B9xav0g:0,oivjwe:0,Bn0qgzm:0,B4g9neb:0,zhjwy3:0,wvpqe5:0,ibv6hh:0,u1mtju:0,h3c5rm:0,vrafjx:0,Bekrc4i:0,i8vvqc:0,g2u3we:0,icvyot:0,B4j52fo:0,irswps:"f1gmd7mu",E5pizo:"fyed02w"},"filled-lighter-shadow":{De3pzq:"fxugw4r",Bgfg5da:0,B9xav0g:0,oivjwe:0,Bn0qgzm:0,B4g9neb:0,zhjwy3:0,wvpqe5:0,ibv6hh:0,u1mtju:0,h3c5rm:0,vrafjx:0,Bekrc4i:0,i8vvqc:0,g2u3we:0,icvyot:0,B4j52fo:0,irswps:"f1gmd7mu",E5pizo:"fyed02w"},outline:{De3pzq:"fxugw4r",Bgfg5da:0,B9xav0g:"f1c1zstj",oivjwe:0,Bn0qgzm:0,B4g9neb:0,zhjwy3:0,wvpqe5:0,ibv6hh:0,u1mtju:0,h3c5rm:0,vrafjx:0,Bekrc4i:0,i8vvqc:0,g2u3we:0,icvyot:0,B4j52fo:0,irswps:"fhz96rm"},outlineInteractive:{kzujx5:0,oetu4i:"f1l4zc64",gvrnp0:0,xv9156:0,jek2p4:0,gg5e9n:0,Beu9t3s:0,dt87k2:0,Bt1vbvt:0,Bwzppfd:0,Bop6t4b:0,B2zwrfe:0,Bwp2tzp:0,Bgoe8wy:0,Bf40cpq:0,ckks6v:0,Baalond:"f9mts5e",v2iqwr:0,wmxk5l:"f1z0osm6",Bj33j0h:0,Bs0cc2w:0,qwjtx1:0,B50zh58:0,f7epvg:0,e1hlit:0,B7mkhst:0,ak43y8:0,Bbcopvn:0,Bvecx4l:0,lwioe0:0,B6oc9vd:0,e2sjt0:0,uqwnxt:0,asj8p9:"f1acnei2",Br8fjdy:0,zoxjo1:"f1so894s",Bt3ojkv:0,B7pmvfx:0,Bfht2n1:0,an54nd:0,t1ykpo:0,Belqbek:0,bbt1vd:0,Brahy3i:0,r7b1zc:0,rexu52:0,ovtnii:0,Bvq3b66:0,Bawrxx6:0,Bbs6y8j:0,B2qpgjt:"f19ezbcq"},invalid:{tvckwq:"fs4k3qj",gk2u95:["fcee079","fmyw78r"],hhx65j:"f1fgmyf4",Bxowmz0:["fmyw78r","fcee079"]}},{d:[".ftuwxu6{display:inline-flex;}",".f1ewtqcl{box-sizing:border-box;}",".f10pi13n{position:relative;}",[".f1yiegib{padding:0 0 var(--strokeWidthThick) 0;}",{p:-1}],[".f1s184ao{margin:0;}",{p:-1}],[".ft85np5{border-radius:var(--borderRadiusMedium);}",{p:-1}],".f12kltsn{vertical-align:top;}",".f1c21dwh{background-color:var(--colorTransparentBackground);}",[".ff3nzm7{border:var(--strokeWidthThin) solid var(--colorNeutralStrokeDisabled);}",{p:-2}],".f1gw3sf2::after{box-sizing:border-box;}",'.f13zj6fq::after{content:"";}',".f1mdlcz9::after{position:absolute;}",".f1a7op3::after{left:-1px;}",".f1cjjd47::after{right:-1px;}",".f1gboi2j::after{bottom:-1px;}",".ffyw7fx::after{height:max(var(--strokeWidthThick), var(--borderRadiusMedium));}",".f1kp91vd::after{border-bottom-left-radius:var(--borderRadiusMedium);}",".f1ibwz09::after{border-bottom-right-radius:var(--borderRadiusMedium);}",[".f1mnjydx::after{border-bottom:var(--strokeWidthThick) solid var(--colorCompoundBrandStroke);}",{p:-1}],".fj2g8qd::after{clip-path:inset(calc(100% - var(--strokeWidthThick)) 0 0 0);}",".f1yk9hq::after{transform:scaleX(0);}",".fhwpy7i::after{transition-property:transform;}",".f14ee0xe::after{transition-duration:var(--durationUltraFast);}",".f1xhbsuh::after{transition-delay:var(--curveAccelerateMid);}",[".f88035w{border:var(--strokeWidthThin) solid var(--colorTransparentStroke);}",{p:-2}],".f16xq7d1{background-color:var(--colorNeutralBackground3);}",".fxugw4r{background-color:var(--colorNeutralBackground1);}",[".f1gmd7mu{border:var(--strokeWidthThin) solid var(--colorTransparentStrokeInteractive);}",{p:-2}],".fyed02w{box-shadow:var(--shadow2);}",[".f1gmd7mu{border:var(--strokeWidthThin) solid var(--colorTransparentStrokeInteractive);}",{p:-2}],[".fhz96rm{border:var(--strokeWidthThin) solid var(--colorNeutralStroke1);}",{p:-2}],".f1c1zstj{border-bottom-color:var(--colorNeutralStrokeAccessible);}",".fs4k3qj:not(:focus-within),.fs4k3qj:hover:not(:focus-within){border-top-color:var(--colorPaletteRedBorder2);}",".fcee079:not(:focus-within),.fcee079:hover:not(:focus-within){border-right-color:var(--colorPaletteRedBorder2);}",".fmyw78r:not(:focus-within),.fmyw78r:hover:not(:focus-within){border-left-color:var(--colorPaletteRedBorder2);}",".f1fgmyf4:not(:focus-within),.f1fgmyf4:hover:not(:focus-within){border-bottom-color:var(--colorPaletteRedBorder2);}"],m:[["@media (forced-colors: active){.f9dbb4x{border-top-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.f3qs60o{border-right-color:GrayText;}.f5u9ap2{border-left-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media (forced-colors: active){.fwd1oij{border-bottom-color:GrayText;}}",{m:"(forced-colors: active)"}],["@media screen and (prefers-reduced-motion: reduce){.fsrmcvb::after{transition-duration:0.01ms;}}",{m:"screen and (prefers-reduced-motion: reduce)"}],["@media screen and (prefers-reduced-motion: reduce){.f1t3k7v9::after{transition-delay:0.01ms;}}",{m:"screen and (prefers-reduced-motion: reduce)"}],["@media screen and (prefers-reduced-motion: reduce){.fx04xgm:focus-within::after{transition-duration:0.01ms;}}",{m:"screen and (prefers-reduced-motion: reduce)"}],["@media screen and (prefers-reduced-motion: reduce){.f1c7in40:focus-within::after{transition-delay:0.01ms;}}",{m:"screen and (prefers-reduced-motion: reduce)"}]],w:[".fjw5xc1:focus-within::after{transform:scaleX(1);}",".f1xdyd5c:focus-within::after{transition-property:transform;}",".fatpbeo:focus-within::after{transition-duration:var(--durationNormal);}",".fb7uyps:focus-within::after{transition-delay:var(--curveDecelerateMid);}",".f1ibeo51:focus-within:active::after{border-bottom-color:var(--colorCompoundBrandStrokePressed);}",".f1vnc8sk:focus-within{outline-width:var(--strokeWidthThick);}",".f3e99gv:focus-within{outline-style:solid;}",".fhljsf7:focus-within{outline-color:transparent;}",[".f19ezbcq:focus-within{border:var(--strokeWidthThin) solid var(--colorNeutralStroke1Pressed);}",{p:-2}],".f1so894s:focus-within{border-bottom-color:var(--colorCompoundBrandStroke);}"],h:[".ftmjh5b:hover,.ftmjh5b:focus-within{border-top-color:var(--colorTransparentStrokeInteractive);}",".f17blpuu:hover,.f17blpuu:focus-within{border-right-color:var(--colorTransparentStrokeInteractive);}",".fsrcdbj:hover,.fsrcdbj:focus-within{border-left-color:var(--colorTransparentStrokeInteractive);}",".f1tpwn32:hover,.f1tpwn32:focus-within{border-bottom-color:var(--colorTransparentStrokeInteractive);}",[".f9mts5e:hover{border:var(--strokeWidthThin) solid var(--colorNeutralStroke1Hover);}",{p:-2}],".f1l4zc64:hover{border-bottom-color:var(--colorNeutralStrokeAccessibleHover);}"],a:[[".f1acnei2:active{border:var(--strokeWidthThin) solid var(--colorNeutralStroke1Pressed);}",{p:-2}],".f1z0osm6:active{border-bottom-color:var(--colorNeutralStrokeAccessiblePressed);}"]}),x2=Pe({base:{icvyot:"f1ern45e",vrafjx:["f1n71otn","f1deefiw"],oivjwe:"f1h8hb77",wvpqe5:["f1deefiw","f1n71otn"],jrapky:0,Frg6f3:0,t21cq0:0,B6of3ja:0,B74szlk:"f1s184ao",De3pzq:"f3rmtva",B7ck84d:"f1ewtqcl",sj55zd:"f19n0e5",Bh6795r:"fqerorx",Bahqtrf:"fk6fouc",Bqenvij:"f1l02sjl",yvdlaj:"fwyc1cq",B3o7kgh:"f13ta7ih",oeaueh:"f1s6fcnf"},disabled:{sj55zd:"f1s2aq7o",Bceei9c:"fdrzuqr",yvdlaj:"fahhnxm"},small:{sshi5w:"f1w5jphr",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"f1pnffij",Bxyxcbc:"f192z54u",Bahqtrf:"fk6fouc",Be2twd7:"fy9rknc",Bhrd7zp:"figsok6",Bg96gwp:"fwrc4pm"},medium:{sshi5w:"fvmd9f",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"f1ww82xo",Bxyxcbc:"f1if7ixc",Bahqtrf:"fk6fouc",Be2twd7:"fkhj508",Bhrd7zp:"figsok6",Bg96gwp:"f1i3iumi"},large:{sshi5w:"f1kfson",Byoj8tv:0,uwmqm3:0,z189sj:0,z8tnut:0,B0ocmuz:"f15hvtkj",Bxyxcbc:"f3kip1f",Bahqtrf:"fk6fouc",Be2twd7:"fod5ikn",Bhrd7zp:"figsok6",Bg96gwp:"faaz57k"}},{d:[".f1ern45e{border-top-style:none;}",".f1n71otn{border-right-style:none;}",".f1deefiw{border-left-style:none;}",".f1h8hb77{border-bottom-style:none;}",[".f1s184ao{margin:0;}",{p:-1}],".f3rmtva{background-color:transparent;}",".f1ewtqcl{box-sizing:border-box;}",".f19n0e5{color:var(--colorNeutralForeground1);}",".fqerorx{flex-grow:1;}",".fk6fouc{font-family:var(--fontFamilyBase);}",".f1l02sjl{height:100%;}",".fwyc1cq::-webkit-input-placeholder{color:var(--colorNeutralForeground4);}",".fwyc1cq::-moz-placeholder{color:var(--colorNeutralForeground4);}",".f13ta7ih::-webkit-input-placeholder{opacity:1;}",".f13ta7ih::-moz-placeholder{opacity:1;}",".f1s6fcnf{outline-style:none;}",".f1s2aq7o{color:var(--colorNeutralForegroundDisabled);}",".fdrzuqr{cursor:not-allowed;}",".fahhnxm::-webkit-input-placeholder{color:var(--colorNeutralForegroundDisabled);}",".fahhnxm::-moz-placeholder{color:var(--colorNeutralForegroundDisabled);}",".f1w5jphr{min-height:40px;}",[".f1pnffij{padding:var(--spacingVerticalXS) calc(var(--spacingHorizontalSNudge) + var(--spacingHorizontalXXS));}",{p:-1}],".f192z54u{max-height:200px;}",".fy9rknc{font-size:var(--fontSizeBase200);}",".figsok6{font-weight:var(--fontWeightRegular);}",".fwrc4pm{line-height:var(--lineHeightBase200);}",".fvmd9f{min-height:52px;}",[".f1ww82xo{padding:var(--spacingVerticalSNudge) calc(var(--spacingHorizontalMNudge) + var(--spacingHorizontalXXS));}",{p:-1}],".f1if7ixc{max-height:260px;}",".fkhj508{font-size:var(--fontSizeBase300);}",".f1i3iumi{line-height:var(--lineHeightBase300);}",".f1kfson{min-height:64px;}",[".f15hvtkj{padding:var(--spacingVerticalS) calc(var(--spacingHorizontalM) + var(--spacingHorizontalXXS));}",{p:-1}],".f3kip1f{max-height:320px;}",".fod5ikn{font-size:var(--fontSizeBase400);}",".faaz57k{line-height:var(--lineHeightBase400);}"]}),v2=Pe({none:{B3rzk8w:"f1o1s39h"},both:{B3rzk8w:"f1pxm0xe"},horizontal:{B3rzk8w:"fq6nmtn"},vertical:{B3rzk8w:"f1f5ktr4"}},{d:[".f1o1s39h{resize:none;}",".f1pxm0xe{resize:both;}",".fq6nmtn{resize:horizontal;}",".f1f5ktr4{resize:vertical;}"]}),b2=e=>{"use no memo";const{size:t,appearance:n,resize:i}=e,s=e.textarea.disabled,a=`${e.textarea["aria-invalid"]}`=="true",o=n.startsWith("filled"),l=g2();e.root.className=_e(up.root,l.base,s&&l.disabled,!s&&o&&l.filled,!s&&l[n],!s&&l.interactive,!s&&n==="outline"&&l.outlineInteractive,!s&&a&&l.invalid,e.root.className);const c=x2(),u=v2();return e.textarea.className=_e(up.textarea,c.base,c[t],u[i],s&&c.disabled,e.textarea.className),e},Av=f.forwardRef((e,t)=>{const n=p2(e,t);return b2(n),zn("useTextareaStyles_unstable")(n),m2(n)});Av.displayName="Textarea";const Su=[{value:"offen",label:"Offen"},{value:"in_bearbeitung",label:"In Bearbeitung"},{value:"erledigt",label:"Erledigt"},{value:"ueberfaellig",label:"Überfällig"}],Rv=y_({toolbar:{display:"flex",gap:"12px",alignItems:"center",justifyContent:"space-between",flexWrap:"wrap"},search:{minWidth:"280px",flexGrow:1},formGrid:{display:"grid",gridTemplateColumns:"repeat(2, minmax(0, 1fr))",gap:"16px",alignItems:"start","@media (max-width: 860px)":{gridTemplateColumns:"1fr"}},full:{gridColumn:"1 / -1"},helperText:{fontSize:$m.fontSizeBase200,color:$m.colorNeutralForeground3,marginTop:"6px"},tableWrap:{overflowX:"auto"},rowActions:{display:"flex",gap:"8px",flexWrap:"wrap"},attachmentHeader:{marginBottom:"8px",fontWeight:600},fluentInput:{backgroundColor:"rgba(255, 255, 255, 0.92)",borderRadius:"10px",border:"1px solid rgba(148, 163, 184, 0.35)",boxShadow:"none","& input":{backgroundColor:"#FFFFFF",color:"#0f172a"}},fluentTextareaLarge:{backgroundColor:"rgba(255, 255, 255, 0.92)",borderRadius:"10px",border:"1px solid rgba(148, 163, 184, 0.35)","& textarea":{backgroundColor:"#FFFFFF",color:"#0f172a",minHeight:"220px"}},fluentDropdown:{backgroundColor:"rgba(255, 255, 255, 0.92)",borderRadius:"10px",border:"1px solid rgba(148, 163, 184, 0.35)",color:"#0f172a"}});function Zl(){return{case_id:"",title:"",deadline_type:"",due_date_date:null,due_date_time:"",status:"offen",remind_days_before:3,assigned_user_id:"",committee_id:"",description:""}}function Ov(e){if(!e)return"";const t=new Date(e);return Number.isNaN(t.getTime())?String(e):t.toLocaleString("de-DE")}function y2(e){if(!e)return{date:null,time:""};const t=new Date(e);if(!Number.isNaN(t.getTime()))return{date:new Date(t.getFullYear(),t.getMonth(),t.getDate()),time:`${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`};const n=String(e),[i="",s=""]=n.split("T"),[a,o,l]=i.split("-").map(u=>Number(u)),c=s.slice(0,5);return a&&o&&l?{date:new Date(a,o-1,l),time:c}:{date:null,time:""}}function j2(e,t){if(!e||!(e instanceof Date)||Number.isNaN(e.getTime()))return null;const[n="0",i="0"]=String(t||"00:00").split(":"),s=Number(n||0),a=Number(i||0),o=e.getFullYear(),l=String(e.getMonth()+1).padStart(2,"0"),c=String(e.getDate()).padStart(2,"0"),u=String(s).padStart(2,"0"),m=String(a).padStart(2,"0");return`${o}-${l}-${c}T${u}:${m}`}function _2(e){const t=y2(e.due_date);return{case_id:e.case_id?String(e.case_id):"",title:e.title??"",deadline_type:e.deadline_type??"",due_date_date:t.date,due_date_time:t.time,status:e.status??"offen",remind_days_before:e.remind_days_before===null||e.remind_days_before===void 0?3:Number(e.remind_days_before),assigned_user_id:e.assigned_user_id?String(e.assigned_user_id):"",committee_id:e.committee_id?String(e.committee_id):"",description:e.description??""}}function w2({form:e,setForm:t,users:n,committees:i,attachments:s,pendingFiles:a,setPendingFiles:o,selected:l,onRemoveAttachment:c,onViewAttachment:u,onDownloadAttachment:m}){var h,x,g;const d=Rv();return r.jsxs("div",{className:d.formGrid,children:[r.jsx(Tt,{label:"Vorgangs-ID",children:r.jsx(mn,{className:d.fluentInput,value:e.case_id||(l==null?void 0:l.case_number)||"wird automatisch vergeben",disabled:!0})}),r.jsx(Tt,{label:"Titel",required:!0,children:r.jsx(mn,{className:d.fluentInput,value:e.title,onChange:(p,w)=>t(b=>({...b,title:w.value}))})}),r.jsx(Tt,{label:"Fristart",required:!0,children:r.jsx(mn,{className:d.fluentInput,value:e.deadline_type,onChange:(p,w)=>t(b=>({...b,deadline_type:w.value}))})}),r.jsx(Tt,{label:"Fällig am",required:!0,children:r.jsx(mn,{className:d.fluentInput,type:"date",value:e.due_date_date instanceof Date&&!Number.isNaN(e.due_date_date.getTime())?[e.due_date_date.getFullYear(),String(e.due_date_date.getMonth()+1).padStart(2,"0"),String(e.due_date_date.getDate()).padStart(2,"0")].join("-"):"",onChange:(p,w)=>{const b=w.value;if(!b){t(N=>({...N,due_date_date:null}));return}const[v,y,j]=b.split("-").map(Number),k=new Date(v,(y||1)-1,j||1);t(N=>({...N,due_date_date:k}))}})}),r.jsx(Tt,{label:"Uhrzeit",children:r.jsx(mn,{className:d.fluentInput,type:"time",value:e.due_date_time||"",onChange:(p,w)=>t(b=>({...b,due_date_time:w.value||""}))})}),r.jsx(Tt,{label:"Status",children:r.jsx(Fa,{className:d.fluentDropdown,selectedOptions:[e.status],value:((h=Su.find(p=>p.value===e.status))==null?void 0:h.label)||"",onOptionSelect:(p,w)=>t(b=>({...b,status:w.optionValue||"offen"})),children:Su.map(p=>r.jsx(Pr,{value:p.value,children:p.label},p.value))})}),r.jsx(Tt,{label:"Erinnerung Tage vorher",children:r.jsx(mn,{className:d.fluentInput,type:"number",min:"0",value:String(e.remind_days_before??""),onChange:(p,w)=>t(b=>({...b,remind_days_before:w.value===""?"":Number(w.value)}))})}),r.jsx(Tt,{label:"Zugewiesener Benutzer",children:r.jsxs(Fa,{className:d.fluentDropdown,selectedOptions:[e.assigned_user_id||"__none__"],value:e.assigned_user_id?((x=n.find(p=>String(p.id)===String(e.assigned_user_id)))==null?void 0:x.full_name)||"":"Kein Benutzer",onOptionSelect:(p,w)=>t(b=>({...b,assigned_user_id:w.optionValue==="__none__"?"":String(w.optionValue||"")})),children:[r.jsx(Pr,{value:"__none__",children:"Kein Benutzer"}),n.map(p=>r.jsxs(Pr,{value:String(p.id),children:[p.full_name," ",p.email?`(${p.email})`:""]},p.id))]})}),r.jsx(Tt,{label:"Gremium",children:r.jsxs(Fa,{className:d.fluentDropdown,selectedOptions:[e.committee_id||"__none__"],value:e.committee_id?((g=i.find(p=>String(p.id)===String(e.committee_id)))==null?void 0:g.name)||"":"Kein Gremium",onOptionSelect:(p,w)=>t(b=>({...b,committee_id:w.optionValue==="__none__"?"":String(w.optionValue||"")})),children:[r.jsx(Pr,{value:"__none__",children:"Kein Gremium"}),i.map(p=>r.jsx(Pr,{value:String(p.id),children:p.name},p.id))]})}),r.jsx("div",{className:d.full,children:r.jsx(Tt,{label:"Beschreibung",children:r.jsx(Av,{className:d.fluentTextareaLarge,value:e.description,resize:"vertical",onChange:(p,w)=>t(b=>({...b,description:w.value}))})})}),r.jsxs("div",{className:d.full,children:[r.jsx(Tt,{label:"Dokumente anhängen",children:r.jsx(mn,{className:d.fluentInput,type:"file",multiple:!0,onChange:p=>o(Array.from(p.target.files||[]))})}),a.length?r.jsxs("div",{className:d.helperText,children:["Ausgewählt: ",a.map(p=>p.name).join(", ")]}):null]}),l?r.jsxs("div",{className:d.full,children:[r.jsx("div",{className:d.attachmentHeader,children:"Vorhandene Anhänge"}),r.jsx("div",{className:d.tableWrap,children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Datei"}),r.jsx("th",{children:"Hochgeladen"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[s.map(p=>r.jsxs("tr",{children:[r.jsx("td",{children:p.id}),r.jsx("td",{children:p.file_name||p.title||""}),r.jsx("td",{children:Ov(p.uploaded_at)}),r.jsx("td",{children:r.jsxs("div",{className:d.rowActions,children:[r.jsx(Jn,{appearance:"secondary",size:"small",icon:r.jsx(Zw,{}),onClick:()=>u(p),children:"Anzeigen"}),r.jsx(Jn,{appearance:"secondary",size:"small",icon:r.jsx(tk,{}),onClick:()=>m(p),children:"Download"}),r.jsx(Jn,{appearance:"secondary",size:"small",icon:r.jsx(rv,{}),onClick:()=>c(p),children:"Entfernen"})]})})]},p.id)),s.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:4,children:"Keine Anhänge vorhanden"})})]})]})})]}):null]})}function k2({open:e,selected:t,form:n,setForm:i,users:s,committees:a,attachments:o,pendingFiles:l,setPendingFiles:c,loading:u,error:m,onClose:d,onSubmit:h,onRemoveAttachment:x,onViewAttachment:g,onDownloadAttachment:p}){return e?r.jsx("div",{className:"modal-backdrop",onClick:d,children:r.jsxs("div",{className:"modal-panel",onClick:w=>w.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:t?"Frist bearbeiten":"Neue Frist anlegen"}),r.jsx("p",{className:"modal-subtitle",children:"Vorgangs-ID wird nicht mehr manuell gepflegt und nur noch angezeigt."})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:d,"aria-label":"Schließen",children:"×"})]}),m?r.jsx("div",{className:"alert error",children:m}):null,r.jsxs("form",{onSubmit:h,children:[r.jsx(w2,{form:n,setForm:i,users:s,committees:a,attachments:o,pendingFiles:l,setPendingFiles:c,selected:t,onRemoveAttachment:x,onViewAttachment:g,onDownloadAttachment:p}),r.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:10,marginTop:20},children:[r.jsx(Jn,{appearance:"secondary",onClick:d,children:"Abbrechen"}),r.jsx(Jn,{appearance:"primary",type:"submit",disabled:u,children:u?"Speichern...":t?"Speichern":"Erstellen"})]})]})]})}):null}function S2(){const e=Rv(),[t,n]=f.useState([]),[i,s]=f.useState([]),[a,o]=f.useState([]),[l,c]=f.useState([]),[u,m]=f.useState([]),[d,h]=f.useState(null),[x,g]=f.useState(Zl()),[p,w]=f.useState(""),[b,v]=f.useState(""),[y,j]=f.useState(""),[k,N]=f.useState(""),[_,S]=f.useState(!1),[P,D]=f.useState(null),[F,T]=f.useState(!1);async function M(){j("");try{const[ee,le,ge]=await Promise.all([O.get("/deadlines"),O.get("/users"),O.get("/committees")]);n(Array.isArray(ee)?ee:[]),s(Array.isArray(le)?le.filter(Y=>Y.is_active!==!1):[]),o(Array.isArray(ge)?ge:[])}catch(ee){j(ee.message||"Fristen konnten nicht geladen werden.")}}async function z(ee){try{const le=await O.get(`/deadlines/${ee}/attachments`);c(Array.isArray(le)?le:[])}catch{c([])}}f.useEffect(()=>{M()},[]);const A=f.useMemo(()=>{const ee=p.trim().toLowerCase();return ee?t.filter(le=>JSON.stringify(le).toLowerCase().includes(ee)):t},[t,p]);function $(){h(null),g(Zl()),c([]),m([]),N(""),v(""),j(""),T(!0)}async function Z(ee){h(ee),g(_2(ee)),m([]),N(""),v(""),j(""),T(!0),await z(ee.id)}function R(){h(null),g(Zl()),c([]),m([]),N(""),T(!1)}async function K(ee){if(!u.length)return;const le=new FormData;u.forEach(ge=>le.append("files",ge)),await O.post(`/deadlines/${ee}/attachments`,le)}async function X(ee){if(ee.preventDefault(),N(""),v(""),!x.title.trim()){N("Bitte einen Titel eingeben.");return}if(!x.deadline_type.trim()){N("Bitte eine Fristart eingeben.");return}if(!x.due_date_date){N("Bitte ein Datum auswählen.");return}S(!0);try{const le={title:x.title.trim(),deadline_type:x.deadline_type.trim(),due_date:j2(x.due_date_date,x.due_date_time),status:x.status,remind_days_before:x.remind_days_before===""||x.remind_days_before===null?null:Number(x.remind_days_before),assigned_user_id:x.assigned_user_id?Number(x.assigned_user_id):null,committee_id:x.committee_id?Number(x.committee_id):null,description:x.description.trim()||null};let ge=(d==null?void 0:d.id)||null;if(d!=null&&d.id)await O.put(`/deadlines/${d.id}`,le),v("Frist gespeichert");else{const Y=await O.post("/deadlines",le);ge=(Y==null?void 0:Y.id)||null,v("Frist erstellt")}ge&&u.length&&await K(ge),await M(),R()}catch(le){N(le.message||"Frist konnte nicht gespeichert werden.")}finally{S(!1)}}async function ae(ee){j(""),v(""),D(ee.id);try{await O.delete(`/deadlines/${ee.id}`),(d==null?void 0:d.id)===ee.id&&R(),await M(),v("Frist gelöscht")}catch(le){j(le.message||"Frist konnte nicht gelöscht werden.")}finally{D(null)}}async function J(ee){if(d!=null&&d.id){N("");try{await O.delete(`/deadlines/${d.id}/attachments/${ee.id}`),await z(d.id)}catch(le){N(le.message||"Anhang konnte nicht entfernt werden.")}}}async function ue(ee){if(d!=null&&d.id)try{await _i(`/deadlines/${d.id}/attachments/${ee.id}/view`)}catch(le){N(le.message||"Anhang konnte nicht angezeigt werden.")}}async function pe(ee){if(d!=null&&d.id)try{await ji(`/deadlines/${d.id}/attachments/${ee.id}/download`,ee.file_name||`anhang-${ee.id}`)}catch(le){N(le.message||"Anhang konnte nicht heruntergeladen werden.")}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; + } + + .modal-panel { + width: min(1040px, 100%); + max-height: 92vh; + overflow: auto; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + padding: 18px; + } + + .modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .modal-subtitle { + margin: 6px 0 0; + opacity: 0.75; + font-size: 0.95rem; + } + + .modal-close { + border: 0; + background: transparent; + font-size: 28px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + } + `}),r.jsxs(de,{title:"Fristen",subtitle:"Fristen werden über ein Dialogfenster angelegt, bearbeitet und verwaltet. Benutzer, Gremien und mehrere Dokumente können zugeordnet werden.",actions:r.jsxs("div",{className:e.toolbar,children:[r.jsx(mn,{className:e.search,placeholder:"Suchen...",value:p,onChange:(ee,le)=>w(le.value)}),r.jsx("button",{className:"btn",type:"button",onClick:$,children:r.jsxs("span",{style:{display:"inline-flex",alignItems:"center",gap:8},children:[r.jsx(Jw,{}),"Neue Frist"]})})]}),children:[y?r.jsx("div",{className:"alert error",children:y}):null,b?r.jsx("div",{className:"alert success",children:b}):null,_&&!t.length?r.jsx(Dv,{label:"Fristen werden geladen..."}):null,r.jsx("div",{className:e.tableWrap,children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Vorgang"}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Fristart"}),r.jsx("th",{children:"Fällig"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Benutzer"}),r.jsx("th",{children:"Gremium"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[A.map(ee=>{var le;return r.jsxs("tr",{children:[r.jsx("td",{children:ee.id}),r.jsx("td",{children:ee.case_number||ee.case_id||""}),r.jsx("td",{children:ee.title}),r.jsx("td",{children:ee.deadline_type}),r.jsx("td",{children:Ov(ee.due_date)}),r.jsx("td",{children:((le=Su.find(ge=>ge.value===ee.status))==null?void 0:le.label)||ee.status}),r.jsx("td",{children:ee.assigned_user_name||ee.assigned_user_id||""}),r.jsx("td",{children:ee.committee_name||ee.committee_id||""}),r.jsx("td",{children:r.jsxs("div",{className:e.rowActions,children:[r.jsx(Jn,{appearance:"secondary",size:"small",icon:r.jsx(ek,{}),onClick:()=>Z(ee),children:"Bearbeiten"}),r.jsx(Jn,{appearance:"secondary",size:"small",icon:r.jsx(rv,{}),disabled:P===ee.id,onClick:()=>ae(ee),children:"Löschen"})]})})]},ee.id)}),A.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:9,children:"Keine Fristen vorhanden"})})]})]})})]}),r.jsx(k2,{open:F,selected:d,form:x,setForm:g,users:i,committees:a,attachments:l,pendingFiles:u,setPendingFiles:m,loading:_,error:k,onClose:R,onSubmit:X,onRemoveAttachment:J,onViewAttachment:ue,onDownloadAttachment:pe})]})}const Iv=[{value:"ordentlich",label:"Ordentlich"},{value:"ausserordentlich",label:"Außerordentlich"}],Lv=[{value:"geplant",label:"Geplant"},{value:"ungeplant",label:"Ungeplant"},{value:"gestartet",label:"Gestartet"},{value:"beendet",label:"Beendet"}],Yl=25;function ba(){return{meeting_number:"",title:"",meeting_type:"ordentlich",meeting_date:"",start_time:"",end_time:"",location:"",status:"geplant",protocol_text:"",committee_id:"",chair_user_id:"",secretary_user_id:""}}function pn(e){return(e==null?void 0:e.status)==="gestartet"||(e==null?void 0:e.status)==="beendet"}function Mv(e){if(!e)return"";const t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString("de-DE")}function N2(e){const t=Number(e||0);return t?t<1024?`${t} B`:t<1024*1024?`${(t/1024).toFixed(1)} KB`:`${(t/(1024*1024)).toFixed(1)} MB`:""}function C2(e){return e===!0?"Ja":e===!1?"Nein":"–"}function dp(e){return{offen:"Offen",in_sitzung:"In Sitzung",rueckfrage:"Rückfrage",klaerung:"Klärung",erledigt:"Erledigt"}[e]||e||"Offen"}function z2(e){return e==="klaerung"?"Klärung":e==="bemerkung"?"Bemerkung / Wiedervorlage":"Rückfrage"}function B2({meeting:e,data:t,onAssignExisting:n,onCreateAndAssign:i,onUnassign:s,onCreateFollowUp:a,onOpenReports:o,onOpenComments:l,onClose:c,loading:u}){const m=(t==null?void 0:t.assigned)||[],d=(t==null?void 0:t.unassigned)||[],h=pn(e),x=(e==null?void 0:e.status)==="beendet",[g,p]=f.useState(""),[w,b]=f.useState(""),[v,y]=f.useState(""),[j,k]=f.useState(""),[N,_]=f.useState(""),S=N.trim().toLowerCase(),P=T=>S?JSON.stringify(T).toLowerCase().includes(S):!0,D=m.filter(P),F=d.filter(P);return r.jsx("div",{style:{position:"fixed",inset:0,background:"rgba(15, 23, 42, 0.55)",display:"grid",placeItems:"center",zIndex:999},children:r.jsxs("div",{className:"glass",style:{width:"min(1280px, 96vw)",maxHeight:"88vh",overflow:"auto",padding:18,borderRadius:20},children:[r.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",gap:12,marginBottom:14,flexWrap:"wrap"},children:[r.jsxs("div",{children:[r.jsx("h3",{style:{margin:0},children:"TOPs für Sitzung"}),r.jsxs("p",{style:{margin:"6px 0 0 0",opacity:.75},children:[e==null?void 0:e.meeting_number," – ",e==null?void 0:e.title]}),h?r.jsx("p",{style:{margin:"6px 0 0 0",color:"#b45309"},children:"Diese Sitzung ist gesperrt. TOPs und zugehörige Berichte können angezeigt, aber nicht mehr geändert werden."}):null]}),r.jsxs("div",{style:{display:"flex",gap:10,alignItems:"center",flexWrap:"wrap"},children:[r.jsx("input",{className:"input search",placeholder:"TOPs / Themen suchen...",value:N,onChange:T=>_(T.target.value)}),r.jsx("button",{className:"btn secondary",type:"button",onClick:c,children:"Schließen"})]})]}),r.jsxs("div",{className:"page-grid",style:{gridTemplateColumns:"1fr 1fr"},children:[r.jsx(de,{title:"Bereits zugewiesene TOPs",subtitle:"TOP-Nummern werden automatisch beim Hinzufügen vergeben. Zugehörige Berichte lassen sich direkt anzeigen oder nachdrucken.",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"TOP"}),r.jsx("th",{children:"Vorgang"}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Min."}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Berichte"}),r.jsx("th",{children:"Kommentare"}),r.jsx("th",{})]})}),r.jsxs("tbody",{children:[D.map(T=>r.jsxs("tr",{children:[r.jsx("td",{children:T.agenda_number}),r.jsx("td",{children:T.case_number||T.case_id||""}),r.jsxs("td",{children:[T.title,T.parent_agenda_item_id?r.jsxs(r.Fragment,{children:[r.jsx("br",{}),r.jsxs("small",{children:["Folge von TOP ",T.parent_agenda_number||T.parent_agenda_item_id,T.follow_up_source_meeting_number?` · Sitzung ${T.follow_up_source_meeting_number}`:""]})]}):null]}),r.jsx("td",{children:T.duration_minutes??""}),r.jsx("td",{children:dp(T.status||"in_sitzung")}),r.jsx("td",{children:T.report_count??0}),r.jsxs("td",{children:[T.comment_count??0,T.protocol_comment_count?` / ${T.protocol_comment_count} im Protokoll`:""]}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>o(T),children:"Berichte"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>l(T),children:"Kommentare"}),r.jsx("button",{className:"mini-btn",type:"button",disabled:u||x,onClick:()=>a(T),children:"Folge-TOP"}),r.jsx("button",{className:"mini-btn danger",type:"button",disabled:u||h,onClick:()=>s(T.id),children:"Entfernen"})]})]},T.id)),D.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:8,children:"Keine TOPs zugewiesen"})})]})]})})}),r.jsxs(de,{title:"TOP-Bestand hinzufügen",subtitle:"Vorhandenen TOP auswählen oder direkt einen neuen TOP für diese Sitzung erzeugen.",children:[r.jsx("div",{className:"table-wrap",style:{marginBottom:16},children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Vorgang"}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Min."}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Berichte"}),r.jsx("th",{children:"Bemerkung"}),r.jsx("th",{})]})}),r.jsxs("tbody",{children:[F.map(T=>r.jsxs("tr",{children:[r.jsx("td",{children:T.id}),r.jsx("td",{children:T.case_number||T.case_id||""}),r.jsxs("td",{children:[T.title,T.parent_agenda_item_id?r.jsxs(r.Fragment,{children:[r.jsx("br",{}),r.jsxs("small",{children:["Folge von TOP ",T.parent_agenda_number||T.parent_agenda_item_id,T.follow_up_source_meeting_number?` · Sitzung ${T.follow_up_source_meeting_number}`:""]})]}):null]}),r.jsx("td",{children:T.duration_minutes??""}),r.jsx("td",{children:dp(T.status||"offen")}),r.jsx("td",{children:T.report_count??0}),r.jsx("td",{children:T.follow_up_note||T.last_return_remark?r.jsxs("small",{children:[z2(T.follow_up_type||T.last_return_type),": ",T.follow_up_note||T.last_return_remark]}):""}),r.jsx("td",{children:r.jsx("button",{className:"mini-btn",type:"button",disabled:u||h,onClick:()=>n(T.id),children:"Hinzufügen"})})]},T.id)),F.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:8,children:"Keine freien TOPs vorhanden"})})]})]})}),r.jsxs("div",{className:"form-grid",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Neuer TOP-Titel"}),r.jsx("input",{className:"input",value:g,onChange:T=>p(T.target.value),disabled:h})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Vorgangs-ID optional"}),r.jsx("input",{className:"input",value:j,onChange:T=>k(T.target.value.replace(/[^\d]/g,"")),disabled:h})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Dauer in Minuten"}),r.jsx("input",{className:"input",type:"number",min:"0",max:"600",value:v,onChange:T=>y(T.target.value),disabled:h})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"input textarea",value:w,onChange:T=>b(T.target.value),disabled:h})]}),r.jsx("button",{className:"btn",type:"button",disabled:u||h||!g.trim(),onClick:()=>{i({title:g.trim(),description:w.trim()||null,duration_minutes:v?Number(v):null,case_id:j?Number(j):null}),p(""),b(""),y(""),k("")},children:"Neuen TOP erzeugen und zuweisen"})]})]})]})]})})}function T2({open:e,data:t,onClose:n,onView:i,onDownload:s,onPrint:a,onPrintSummary:o}){if(!e)return null;const l=(t==null?void 0:t.agenda_item)||null,c=(t==null?void 0:t.reports)||[];return r.jsx("div",{className:"modal-backdrop",children:r.jsxs("div",{className:"modal-panel",style:{width:"min(1080px, 100%)"},children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsxs("h3",{style:{margin:0},children:["Berichte zu TOP ",(l==null?void 0:l.agenda_number)||""]}),r.jsxs("p",{className:"modal-subtitle",children:[(l==null?void 0:l.title)||"",l!=null&&l.case_number?` · Vorgang ${l.case_number}`:""]})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:n,"aria-label":"Schließen",children:"×"})]}),l!=null&&l.case_summary?r.jsx(de,{title:"Bericht / Sachverhalt",subtitle:"Der Berichtstext des verknüpften Vorgangs kann direkt nachgedruckt werden.",actions:r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>o(l),children:"Bericht drucken"}),children:r.jsx("div",{style:{whiteSpace:"pre-wrap",lineHeight:1.5},children:l.case_summary})}):null,r.jsx(de,{title:"Verknüpfte Dokumente",subtitle:"Dokumente des TOP-Vorgangs und der Sitzung können angezeigt, heruntergeladen oder nachgedruckt werden.",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Dokument-Nr."}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Art"}),r.jsx("th",{children:"Datei"}),r.jsx("th",{children:"Größe"}),r.jsx("th",{children:"Hochgeladen"}),r.jsx("th",{children:"Von"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[c.map(u=>r.jsxs("tr",{children:[r.jsx("td",{children:u.id}),r.jsx("td",{children:u.document_number||""}),r.jsx("td",{children:u.title}),r.jsx("td",{children:u.document_type||""}),r.jsx("td",{children:u.file_name||""}),r.jsx("td",{children:N2(u.size_bytes)}),r.jsx("td",{children:Mv(u.uploaded_at)}),r.jsx("td",{children:u.uploaded_by_name||""}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>i(u),children:"Anzeigen"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>a(u),children:"Nachdrucken"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>s(u),children:"Download"})]})]},u.id)),c.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:9,children:"Keine Berichte oder Dokumente zu diesem TOP gefunden"})})]})]})})})]})})}function P2({open:e,data:t,loading:n,onClose:i,onCreate:s,onUpdate:a,onDelete:o}){var F;const[l,c]=f.useState(""),[u,m]=f.useState("intern"),[d,h]=f.useState(!1),[x,g]=f.useState(""),[p,w]=f.useState(""),[b,v]=f.useState("intern"),[y,j]=f.useState(!1);if(f.useEffect(()=>{e||(c(""),m("intern"),h(!1),g(""))},[e]),!e)return null;const k=(t==null?void 0:t.agenda_item)||null,N=(t==null?void 0:t.comments)||[],_=((F=t==null?void 0:t.meeting)==null?void 0:F.status)==="beendet";function S(T){g(String(T.id)),w(T.comment_text||""),v(T.visibility||"intern"),j(!!T.include_in_protocol)}async function P(T){T.preventDefault(),l.trim()&&(await s({comment_text:l.trim(),visibility:u,include_in_protocol:d}),c(""),m("intern"),h(!1))}async function D(T){p.trim()&&(await a(T,{comment_text:p.trim(),visibility:b,include_in_protocol:y}),g(""))}return r.jsx("div",{className:"modal-backdrop",children:r.jsxs("div",{className:"modal-panel",style:{width:"min(1080px, 100%)"},children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsxs("h3",{style:{margin:0},children:["Kommentare zu TOP ",(k==null?void 0:k.agenda_number)||""]}),r.jsxs("p",{className:"modal-subtitle",children:[(k==null?void 0:k.title)||"",_?" · Sitzung beendet":""]})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:i,"aria-label":"Schließen",children:"×"})]}),_?r.jsx("div",{className:"alert",style:{marginBottom:14},children:"Diese Sitzung ist beendet. Kommentare können angezeigt, aber nicht mehr geändert werden."}):null,r.jsx(de,{title:"Neuer Kommentar",subtitle:"Mit dem Schalter „ins Protokoll“ wird der Kommentar beim Erzeugen des Sitzungsprotokolls beim jeweiligen TOP ausgegeben.",children:r.jsxs("form",{className:"form-grid",onSubmit:P,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Kommentar"}),r.jsx("textarea",{className:"input textarea",value:l,onChange:T=>c(T.target.value),disabled:_||n})]}),r.jsxs("div",{style:{display:"flex",gap:14,alignItems:"center",flexWrap:"wrap"},children:[r.jsxs("label",{style:{minWidth:220},children:[r.jsx("span",{children:"Sichtbarkeit"}),r.jsxs("select",{className:"input",value:u,onChange:T=>m(T.target.value),disabled:_||n,children:[r.jsx("option",{value:"intern",children:"Intern"}),r.jsx("option",{value:"vertraulich",children:"Vertraulich"})]})]}),r.jsxs("label",{className:"switch-line",children:[r.jsx("input",{type:"checkbox",checked:d,onChange:T=>h(T.target.checked),disabled:_||n}),r.jsx("span",{children:"Ins Sitzungsprotokoll übernehmen"})]}),r.jsx("button",{className:"btn",type:"submit",disabled:_||n||!l.trim(),children:"Kommentar hinzufügen"})]})]})}),r.jsx(de,{title:"Bestehende Kommentare",subtitle:"Kommentare können direkt hier bearbeitet und für das Protokoll ein- oder ausgeschaltet werden.",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Kommentar"}),r.jsx("th",{children:"Kommentar von"}),r.jsx("th",{children:"Sichtbarkeit"}),r.jsx("th",{children:"Protokoll"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[N.map(T=>{const M=String(T.id)===x;return r.jsxs("tr",{children:[r.jsxs("td",{style:{minWidth:320},children:[M?r.jsx("textarea",{className:"input textarea",value:p,onChange:z=>w(z.target.value),disabled:n}):r.jsx("div",{style:{whiteSpace:"pre-wrap"},children:T.comment_text}),r.jsx("small",{children:Mv(T.created_at)})]}),r.jsxs("td",{children:["Kommentar von: ",T.author_name||T.author_user_id||"unbekannt"]}),r.jsx("td",{children:M?r.jsxs("select",{className:"input",value:b,onChange:z=>v(z.target.value),disabled:n,children:[r.jsx("option",{value:"intern",children:"Intern"}),r.jsx("option",{value:"vertraulich",children:"Vertraulich"})]}):T.visibility==="vertraulich"?"Vertraulich":"Intern"}),r.jsx("td",{children:M?r.jsxs("label",{className:"switch-line",children:[r.jsx("input",{type:"checkbox",checked:y,onChange:z=>j(z.target.checked),disabled:n}),r.jsx("span",{children:"Übernehmen"})]}):T.include_in_protocol?"Ja":"Nein"}),r.jsx("td",{className:"row-actions",children:M?r.jsxs(r.Fragment,{children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>D(T),disabled:n||!p.trim(),children:"Speichern"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>g(""),disabled:n,children:"Abbrechen"})]}):r.jsxs(r.Fragment,{children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>S(T),disabled:_||n,children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn",type:"button",disabled:_||n,onClick:()=>a(T,{include_in_protocol:!T.include_in_protocol}),children:T.include_in_protocol?"Nicht ins Protokoll":"Ins Protokoll"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:()=>o(T),disabled:_||n,children:"Löschen"})]})})]},T.id)}),N.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:5,children:"Noch keine Kommentare zu diesem TOP vorhanden."})})]})]})})})]})})}function E2({open:e,meeting:t,agendaItem:n,loading:i,onClose:s,onSubmit:a}){const[o,l]=f.useState("rueckfrage"),[c,u]=f.useState(""),[m,d]=f.useState(!0);if(f.useEffect(()=>{e&&(l("rueckfrage"),u(""),d(!0))},[e,n==null?void 0:n.id]),!e||!n)return null;async function h(x){x.preventDefault(),c.trim()&&await a({return_type:o,remark:c.trim(),include_in_protocol:m})}return r.jsx("div",{className:"modal-backdrop",children:r.jsxs("div",{className:"modal-panel",style:{width:"min(760px, 100%)"},children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h3",{style:{margin:0},children:"Folge-TOP für Rückfrage / Klärung / Wiedervorlage anlegen"}),r.jsxs("p",{className:"modal-subtitle",children:["TOP ",n.agenda_number||n.id," – ",n.title,t!=null&&t.meeting_number?` · Sitzung ${t.meeting_number}`:""]})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:s,"aria-label":"Schließen",disabled:i,children:"×"})]}),r.jsx("div",{className:"alert",style:{marginBottom:14},children:"Der ursprüngliche TOP bleibt in dieser Sitzung erhalten. Zusätzlich wird ein neuer verknüpfter Folge-TOP im TOP-Bestand angelegt."}),r.jsxs("form",{className:"form-grid",onSubmit:h,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Art"}),r.jsxs("select",{className:"input",value:o,onChange:x=>l(x.target.value),disabled:i,children:[r.jsx("option",{value:"rueckfrage",children:"Rückfrage"}),r.jsx("option",{value:"klaerung",children:"Klärung"}),r.jsx("option",{value:"bemerkung",children:"Bemerkung / Wiedervorlage"})]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Bemerkung / Rückfrage / Klärung"}),r.jsx("textarea",{className:"input textarea",required:!0,value:c,onChange:x=>u(x.target.value),placeholder:"Was muss vor erneuter Behandlung geklärt oder bearbeitet werden?",disabled:i})]}),r.jsxs("label",{className:"switch-line",children:[r.jsx("input",{type:"checkbox",checked:m,onChange:x=>d(x.target.checked),disabled:i}),r.jsx("span",{children:"Bemerkung und Folge-TOP im Sitzungsprotokoll erwähnen"})]}),r.jsxs("div",{className:"row-actions",style:{justifyContent:"flex-end"},children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:s,disabled:i,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",disabled:i||!c.trim(),children:"Folge-TOP anlegen"})]})]})]})})}function D2({form:e,setForm:t,committees:n,committeeUsers:i,handleCommitteeChange:s,setLookup:a,disabled:o}){return r.jsxs(r.Fragment,{children:[r.jsxs("label",{children:[r.jsx("span",{children:"Sitzungsnummer"}),r.jsx("input",{className:"input",value:e.meeting_number,readOnly:!0,placeholder:"Wird automatisch generiert"})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Titel"}),r.jsx("input",{className:"input",value:e.title,onChange:l=>t({...e,title:l.target.value}),disabled:o})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Art"}),r.jsx("select",{className:"input",value:e.meeting_type,onChange:l=>t({...e,meeting_type:l.target.value}),disabled:o,children:Iv.map(l=>r.jsx("option",{value:l.value,children:l.label},l.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Sitzungsdatum"}),r.jsx("input",{className:"input",type:"date",value:e.meeting_date,onChange:l=>t({...e,meeting_date:l.target.value}),disabled:o})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Startzeit"}),r.jsx("input",{className:"input",type:"time",value:e.start_time,onChange:l=>t({...e,start_time:l.target.value}),disabled:o})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Endzeit"}),r.jsx("input",{className:"input",type:"time",value:e.end_time,onChange:l=>t({...e,end_time:l.target.value}),disabled:o})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Ort"}),r.jsx("input",{className:"input",value:e.location,onChange:l=>t({...e,location:l.target.value}),disabled:o})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Status"}),r.jsx("select",{className:"input",value:e.status,onChange:l=>t({...e,status:l.target.value}),disabled:o,children:Lv.map(l=>r.jsx("option",{value:l.value,children:l.label},l.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Ausschuss"}),r.jsxs("select",{className:"input",value:e.committee_id,onChange:l=>s(l.target.value),disabled:o,children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),n.map(l=>r.jsx("option",{value:l.id,children:l.name},l.id))]})]}),[["chair_user_id","Leitung User-ID"],["secretary_user_id","Schriftführung User-ID"]].map(([l,c])=>{const u=i.find(m=>String(m.id)===String(e[l]));return r.jsxs("label",{children:[r.jsx("span",{children:c}),r.jsxs("div",{style:{display:"flex",gap:8},children:[r.jsx("input",{className:"input",value:e[l],readOnly:!0}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>a(l),disabled:o,children:"Auswählen"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>t({...e,[l]:""}),disabled:o,children:"Leeren"})]}),r.jsx("small",{children:u?`${u.full_name} (${u.email})`:"Kein Benutzer ausgewählt"})]},l)}),r.jsxs("label",{children:[r.jsx("span",{children:"Protokoll"}),r.jsx("textarea",{className:"input textarea",value:e.protocol_text,onChange:l=>t({...e,protocol_text:l.target.value}),disabled:o})]})]})}function A2({open:e,selected:t,form:n,setForm:i,committees:s,committeeUsers:a,handleCommitteeChange:o,setLookup:l,onClose:c,onSubmit:u,error:m,lookupOpen:d}){if(!e)return null;const h=t?pn(t):!1;return r.jsx("div",{className:"modal-backdrop",onClick:x=>{d||x.target===x.currentTarget&&c()},children:r.jsxs("div",{className:"modal-panel",onClick:x=>x.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:t?"Sitzung bearbeiten":"Neue Sitzung anlegen"}),r.jsx("p",{className:"modal-subtitle",children:t?"Gleiche Funktion wie beim Anlegen. Die Sitzungsnummer ist bereits vergeben.":"Die Sitzungsnummer wird beim Speichern automatisch erzeugt."})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:c,"aria-label":"Schließen",children:"×"})]}),m?r.jsx("div",{className:"alert error",children:m}):null,h?r.jsx("div",{className:"alert",children:"Diese Sitzung ist gesperrt und kann nicht mehr bearbeitet werden."}):null,r.jsxs("form",{className:"form-grid",onSubmit:u,children:[r.jsx(D2,{form:n,setForm:i,committees:s,committeeUsers:a,handleCommitteeChange:o,setLookup:l,disabled:h}),r.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:10,gridColumn:"1 / -1"},children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:c,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",disabled:h,children:t?"Speichern":"Erstellen"})]})]})]})})}function R2({meeting:e,loading:t,onAnswer:n,onClose:i}){return e?r.jsx("div",{className:"modal-backdrop",onClick:s=>{!t&&s.target===s.currentTarget&&i()},children:r.jsxs("div",{className:"modal-panel",style:{width:"min(560px, 100%)"},onClick:s=>s.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:"Sitzung starten"}),r.jsxs("p",{className:"modal-subtitle",children:[e.meeting_number," – ",e.title]})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:i,disabled:t,"aria-label":"Schließen",children:"×"})]}),r.jsx("div",{className:"alert",style:{marginBottom:16},children:"Ist die Sitzung beschlussfähig?"}),r.jsx("p",{style:{marginTop:0,opacity:.8},children:"Die Antwort wird gespeichert und am Anfang des automatisch erzeugten Protokolls ausgegeben."}),r.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:10,flexWrap:"wrap"},children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:i,disabled:t,children:"Abbrechen"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>n(!1),disabled:t,children:"Nein"}),r.jsx("button",{className:"btn",type:"button",onClick:()=>n(!0),disabled:t,children:"Ja"})]})]})}):null}function O2(){const[e,t]=f.useState([]),[n,i]=f.useState([]),[s,a]=f.useState([]),[o,l]=f.useState(null),[c,u]=f.useState(ba()),[m,d]=f.useState(""),[h,x]=f.useState(""),[g,p]=f.useState(""),[w,b]=f.useState(""),[v,y]=f.useState(null),[j,k]=f.useState(null),[N,_]=f.useState(!1),[S,P]=f.useState(null),[D,F]=f.useState(!1),[T,M]=f.useState(null),[z,A]=f.useState(!1),[$,Z]=f.useState(null),[R,K]=f.useState(!1),[X,ae]=f.useState(1),[J,ue]=f.useState(!1),[pe,ee]=f.useState(null),[le,ge]=f.useState(!1),Y=Jt();f.useEffect(()=>{const L=new URLSearchParams(Y.search).get("open");if(!L||!e.length)return;const Q=e.find(oe=>String(oe.id)===String(L));Q&&!pn(Q)&&me(Q)},[Y.search,e]);const[fe,Se]=f.useState({total:0,page:1,page_size:Yl,total_pages:1});async function he({nextPage:L=X,nextShowEnded:Q=R,nextQuery:oe=m}={}){p("");try{const ve=new URLSearchParams({show_ended:Q?"true":"false",page:String(L),page_size:String(Yl)});String(oe||"").trim()&&ve.set("q",oe.trim());const[Me,Ve,zr]=await Promise.all([O.get(`/meetings?${ve.toString()}`),O.get("/committees"),O.get("/users")]);t(Array.isArray(Me==null?void 0:Me.items)?Me.items:[]),Se({total:(Me==null?void 0:Me.total)??0,page:(Me==null?void 0:Me.page)??1,page_size:(Me==null?void 0:Me.page_size)??Yl,total_pages:(Me==null?void 0:Me.total_pages)??1}),i(Array.isArray(Ve)?Ve:[]),a(Array.isArray(zr)?zr.filter(Gv=>Gv.is_active!==!1):[])}catch(ve){p(ve.message)}}f.useEffect(()=>{he()},[X,R]),f.useEffect(()=>{const L=setTimeout(()=>{he({nextPage:1,nextQuery:m}),ae(1)},250);return()=>clearTimeout(L)},[m]);const q=f.useMemo(()=>c.committee_id?s.filter(L=>!L.committee_id||String(L.committee_id)===String(c.committee_id)):s,[s,c.committee_id]);function re(L){return n.find(Q=>String(Q.id)===String(L))}function me(L){pn(L)||(l(L),u({meeting_number:L.meeting_number??"",title:L.title??"",meeting_type:L.meeting_type??"ordentlich",meeting_date:L.meeting_date??"",start_time:L.start_time??"",end_time:L.end_time??"",location:L.location??"",status:L.status??"geplant",protocol_text:L.protocol_text??"",committee_id:L.committee_id?String(L.committee_id):"",chair_user_id:L.chair_user_id?String(L.chair_user_id):"",secretary_user_id:L.secretary_user_id?String(L.secretary_user_id):""}),y(null),x(""),p(""),b(""),ue(!0))}function V(){l(null),u(ba()),y(null),x(""),p(""),b("")}function te(){l(null),u(ba()),y(null),x(""),p(""),b(""),ue(!0)}function Ne(){ue(!1),l(null),u(ba()),y(null),b("")}function Xe(L){const Q=re(L);u(oe=>({...oe,committee_id:L,chair_user_id:oe.chair_user_id||(Q!=null&&Q.chair_user_id?String(Q.chair_user_id):""),secretary_user_id:oe.secretary_user_id||(Q!=null&&Q.secretary_user_id?String(Q.secretary_user_id):"")}))}async function E(L,Q){if(typeof Q=="boolean"){ge(!0),p(""),x("");try{await O.post(`/meetings/${L}/start`,{is_quorate:Q}),await he(),(o==null?void 0:o.id)===L&&V(),ee(null),x(Q?"Sitzung gestartet: Die Sitzung ist Beschlussfähig.":"Sitzung gestartet: Die Sitzung ist nicht Beschlussfähig.")}catch(oe){p(oe.message)}finally{ge(!1)}}}async function B(L){var Q,oe;p(""),x("");try{const ve=await O.post(`/meetings/${L}/stop`,{});await he(),(o==null?void 0:o.id)===L&&V(),((Q=j==null?void 0:j.meeting)==null?void 0:Q.id)===L&&k(null),(S==null?void 0:S.meeting_id)===Number(L)&&ne(),(T==null?void 0:T.meeting_id)===Number(L)&&Ci(),((oe=$==null?void 0:$.meeting)==null?void 0:oe.id)===Number(L)&&Z(null),ve!=null&&ve.protocol_document&&(ve!=null&&ve.decision_report_document)?x("Sitzung beendet, Protokoll und Beschlussbericht erzeugt"):ve!=null&&ve.protocol_document?x("Sitzung beendet und Protokoll erzeugt"):ve!=null&&ve.decision_report_document?x("Sitzung beendet und Beschlussbericht erzeugt"):x("Sitzung beendet")}catch(ve){p(ve.message)}}async function U(L){_(!0),p("");try{const Q=await O.get(`/meetings/${L.id}/agenda-items`);k(Q)}catch(Q){p(Q.message)}finally{_(!1)}}async function C(L){const Q=await O.get(`/meetings/${L}/agenda-items`);k(Q)}async function G(L){var Q;if(!(!((Q=j==null?void 0:j.meeting)!=null&&Q.id)||!(L!=null&&L.id))){F(!0),p("");try{const oe=await O.get(`/meetings/${j.meeting.id}/agenda-items/${L.id}/reports`);P(oe)}catch(oe){p(oe.message||"Berichte konnten nicht geladen werden.")}finally{F(!1)}}}function ne(){P(null),F(!1)}async function ye(L){var Q;if(!(!((Q=j==null?void 0:j.meeting)!=null&&Q.id)||!(L!=null&&L.id))){A(!0),p("");try{const oe=await O.get(`/meetings/${j.meeting.id}/agenda-items/${L.id}/comments`);M({...oe,meeting:j.meeting})}catch(oe){p(oe.message||"Kommentare konnten nicht geladen werden.")}finally{A(!1)}}}async function Pn(){var Q;if(!(T!=null&&T.meeting_id)||!((Q=T==null?void 0:T.agenda_item)!=null&&Q.id))return;const L=await O.get(`/meetings/${T.meeting_id}/agenda-items/${T.agenda_item.id}/comments`);M(oe=>({...L,meeting:(oe==null?void 0:oe.meeting)||(j==null?void 0:j.meeting)||null}))}function Ci(){M(null),A(!1)}async function Rs(L){var Q;if(!(!(T!=null&&T.meeting_id)||!((Q=T==null?void 0:T.agenda_item)!=null&&Q.id))){A(!0),p("");try{await O.post(`/meetings/${T.meeting_id}/agenda-items/${T.agenda_item.id}/comments`,L),await Pn(),await C(T.meeting_id),x("Kommentar zum TOP gespeichert.")}catch(oe){p(oe.message||"Kommentar konnte nicht gespeichert werden.")}finally{A(!1)}}}async function Os(L,Q){var oe;if(!(!(T!=null&&T.meeting_id)||!((oe=T==null?void 0:T.agenda_item)!=null&&oe.id)||!(L!=null&&L.id))){A(!0),p("");try{await O.put(`/meetings/${T.meeting_id}/agenda-items/${T.agenda_item.id}/comments/${L.id}`,Q),await Pn(),await C(T.meeting_id),x("Kommentar aktualisiert.")}catch(ve){p(ve.message||"Kommentar konnte nicht aktualisiert werden.")}finally{A(!1)}}}async function Is(L){var Q;if(!(!(T!=null&&T.meeting_id)||!((Q=T==null?void 0:T.agenda_item)!=null&&Q.id)||!(L!=null&&L.id))&&window.confirm("Kommentar löschen?")){A(!0),p("");try{await O.delete(`/meetings/${T.meeting_id}/agenda-items/${T.agenda_item.id}/comments/${L.id}`),await Pn(),await C(T.meeting_id),x("Kommentar gelöscht.")}catch(oe){p(oe.message||"Kommentar konnte nicht gelöscht werden.")}finally{A(!1)}}}async function Ls(L){p("");try{await _i(`/documents/${L.id}/view`)}catch(Q){p(Q.message||"Bericht konnte nicht angezeigt werden.")}}async function Ms(L){p("");try{await ji(`/documents/${L.id}/download`,L.file_name||L.title||`bericht-${L.id}`)}catch(Q){p(Q.message||"Bericht konnte nicht heruntergeladen werden.")}}async function Fs(L){p("");try{await T1(`/documents/${L.id}/view`)}catch(Q){p(Q.message||"Bericht konnte nicht gedruckt werden.")}}function $s(L){const Q=window.open("","_blank","noopener,noreferrer,width=900,height=700");if(!Q){p("Druckfenster konnte nicht geöffnet werden.");return}const oe=`${L!=null&&L.agenda_number?`TOP ${L.agenda_number} – `:""}${(L==null?void 0:L.title)||"Bericht"}`,ve=(L==null?void 0:L.case_summary)||"Kein Berichtstext vorhanden.";Q.document.open(),Q.document.write(` + + + + ${oe} + + + +

${oe}

+
${L!=null&&L.case_number?`Vorgang ${L.case_number}`:""}
+
${ve.replaceAll("&","&").replaceAll("<","<").replaceAll(">",">")}
+ +`),Q.document.close(),Q.focus(),window.setTimeout(()=>Q.print(),250)}async function qs(L){var Q;if(!(!((Q=j==null?void 0:j.meeting)!=null&&Q.id)||pn(j.meeting))){_(!0),p(""),x("");try{await O.post(`/meetings/${j.meeting.id}/agenda-items/assign`,{agenda_item_id:L}),await C(j.meeting.id),await he(),x("TOP zur Sitzung hinzugefügt")}catch(oe){p(oe.message)}finally{_(!1)}}}async function Vs(L){var Q;if(!(!((Q=j==null?void 0:j.meeting)!=null&&Q.id)||pn(j.meeting))){_(!0),p(""),x("");try{await O.post(`/meetings/${j.meeting.id}/agenda-items/create-and-assign`,L),await C(j.meeting.id),await he(),x("Neuer TOP erzeugt und der Sitzung zugewiesen")}catch(oe){p(oe.message)}finally{_(!1)}}}function I(L){var Q;!((Q=j==null?void 0:j.meeting)!=null&&Q.id)||!(L!=null&&L.id)||(Z({meeting:j.meeting,agendaItem:L}),p(""),x(""))}async function se(L){var Q,oe;if(!(!((Q=$==null?void 0:$.meeting)!=null&&Q.id)||!((oe=$==null?void 0:$.agendaItem)!=null&&oe.id))){_(!0),p(""),x("");try{await O.post(`/meetings/${$.meeting.id}/agenda-items/${$.agendaItem.id}/return-to-pool`,L),Z(null),await C($.meeting.id),await he(),x("Verknüpfter Folge-TOP wurde im TOP-Bestand angelegt.")}catch(ve){p(ve.message||"Folge-TOP konnte nicht angelegt werden.")}finally{_(!1)}}}async function ce(L){var Q;if(!(!((Q=j==null?void 0:j.meeting)!=null&&Q.id)||pn(j.meeting))){_(!0),p(""),x("");try{await O.post(`/meetings/${j.meeting.id}/agenda-items/${L}/unassign`,{}),await C(j.meeting.id),await he(),x("TOP aus Sitzung entfernt und wieder in den Bestand eingeordnet")}catch(oe){p(oe.message)}finally{_(!1)}}}async function Ke(L){if(L.preventDefault(),b(""),x(""),!c.meeting_date){b("Bitte ein Sitzungsdatum auswählen.");return}const Q={title:c.title.trim(),meeting_type:c.meeting_type,meeting_date:c.meeting_date,start_time:c.start_time||null,end_time:c.end_time||null,location:c.location.trim()||null,status:c.status,protocol_text:c.protocol_text.trim()||null,committee_id:c.committee_id?Number(c.committee_id):null,chair_user_id:c.chair_user_id?Number(c.chair_user_id):null,secretary_user_id:c.secretary_user_id?Number(c.secretary_user_id):null};try{const oe=o!=null&&o.id?"Sitzung gespeichert":"Sitzung erstellt";o!=null&&o.id?await O.put(`/meetings/${o.id}`,Q):await O.post("/meetings",Q),await he({nextPage:1}),ae(1),Ne(),x(oe)}catch(oe){b(oe.message)}}async function Us(L,Q){var oe;if(!pn(Q)){p(""),x("");try{await O.delete(`/meetings/${L}`),(o==null?void 0:o.id)===L&&V(),((oe=j==null?void 0:j.meeting)==null?void 0:oe.id)===L&&k(null),await he(),x("Sitzung gelöscht")}catch(ve){p(ve.message)}}}function Hs(L,Q){u(oe=>({...oe,[L]:String(Q.id)})),y(null)}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; + } + + .modal-panel { + width: min(900px, 100%); + max-height: 92vh; + overflow: auto; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + padding: 18px; + } + + .modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .modal-subtitle { + margin: 6px 0 0; + opacity: 0.75; + font-size: 0.95rem; + } + + .modal-close { + border: 0; + background: transparent; + font-size: 28px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + } + + .switch-line { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 600; + } + `}),r.jsx("div",{className:"page-grid",children:r.jsxs(de,{title:"Sitzungen",subtitle:"Beendete Sitzungen sind standardmäßig ausgeblendet. Suche unterstützt auch SQL-Wildcards wie % und findet zusätzlich Themen/TOPs.",actions:r.jsxs("div",{style:{display:"flex",gap:12,alignItems:"center",flexWrap:"wrap"},children:[r.jsxs("label",{style:{display:"flex",gap:8,alignItems:"center"},children:[r.jsx("input",{type:"checkbox",checked:R,onChange:L=>{K(L.target.checked),ae(1)}}),r.jsx("span",{children:"Beendete einblenden"})]}),r.jsx("input",{className:"input search",placeholder:"Suchen... (% als Wildcard möglich)",value:m,onChange:L=>d(L.target.value)}),r.jsx("button",{className:"btn",type:"button",onClick:te,children:"Neue Sitzung"})]}),children:[g?r.jsx("div",{className:"alert error",children:g}):null,h?r.jsx("div",{className:"alert success",children:h}):null,r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Sitzung"}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Art"}),r.jsx("th",{children:"Datum"}),r.jsx("th",{children:"Ort"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Beschlussfähig"}),r.jsx("th",{children:"TOPs"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[e.map(L=>{var oe,ve,Me;const Q=pn(L);return r.jsxs("tr",{onClick:()=>{Q||me(L)},style:{cursor:Q?"default":"pointer",opacity:L.status==="beendet"?.75:1},children:[r.jsx("td",{children:L.id}),r.jsx("td",{children:L.meeting_number}),r.jsx("td",{children:L.title}),r.jsx("td",{children:((oe=Iv.find(Ve=>Ve.value===L.meeting_type))==null?void 0:oe.label)||L.meeting_type}),r.jsx("td",{children:L.meeting_date}),r.jsx("td",{children:L.location}),r.jsx("td",{children:((ve=Lv.find(Ve=>Ve.value===L.status))==null?void 0:ve.label)||L.status}),r.jsx("td",{children:C2(L.is_quorate)}),r.jsx("td",{children:L.agenda_item_count??0}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:Ve=>{Ve.stopPropagation(),ee(L)},disabled:le||L.status==="gestartet"||L.status==="beendet",children:"Starten"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:Ve=>{Ve.stopPropagation(),B(L.id)},disabled:L.status!=="gestartet",children:"Stoppen"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:Ve=>{Ve.stopPropagation(),me(L)},disabled:Q,children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:Ve=>{Ve.stopPropagation(),U(L)},disabled:N&&((Me=j==null?void 0:j.meeting)==null?void 0:Me.id)===L.id,children:"TOPs"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:Ve=>{Ve.stopPropagation(),Us(L.id,L)},disabled:Q,children:"Löschen"})]})]},L.id)}),e.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:10,children:"Keine Sitzungen gefunden"})})]})]})}),fe.total_pages>1?r.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginTop:14},children:[r.jsxs("div",{children:["Seite ",fe.page," von ",fe.total_pages," · ",fe.total," Einträge"]}),r.jsxs("div",{style:{display:"flex",gap:8},children:[r.jsx("button",{className:"btn secondary",type:"button",disabled:fe.page<=1,onClick:()=>ae(L=>Math.max(1,L-1)),children:"Zurück"}),r.jsx("button",{className:"btn secondary",type:"button",disabled:fe.page>=fe.total_pages,onClick:()=>ae(L=>Math.min(fe.total_pages,L+1)),children:"Weiter"})]})]}):null]})}),j?r.jsx(B2,{meeting:j.meeting,data:j,onAssignExisting:qs,onCreateAndAssign:Vs,onUnassign:ce,onCreateFollowUp:I,onOpenReports:G,onOpenComments:ye,onClose:()=>k(null),loading:N}):null,r.jsx(E2,{open:!!$,meeting:$==null?void 0:$.meeting,agendaItem:$==null?void 0:$.agendaItem,loading:N,onClose:()=>Z(null),onSubmit:se}),r.jsx(T2,{open:!!S,data:S,onClose:ne,onView:Ls,onDownload:Ms,onPrint:Fs,onPrintSummary:$s}),r.jsx(P2,{open:!!T,data:T,loading:z,onClose:Ci,onCreate:Rs,onUpdate:Os,onDelete:Is}),r.jsx(R2,{meeting:pe,loading:le,onAnswer:L=>E(pe.id,L),onClose:()=>{le||ee(null)}}),r.jsx(A2,{open:J,selected:o,form:c,setForm:u,committees:n,committeeUsers:q,handleCommitteeChange:Xe,setLookup:y,onClose:Ne,onSubmit:Ke,error:w,lookupOpen:!!v}),v?r.jsx("div",{style:{position:"fixed",inset:0,zIndex:1100},children:r.jsx(nx,{title:"Benutzer auswählen",rows:q,columns:[{key:"id",label:"ID"},{key:"full_name",label:"Name"},{key:"email",label:"E-Mail"},{key:"role",label:"Rolle"}],onPick:L=>Hs(v,L),onClose:()=>y(null)})}):null]})}function I2(e){return{offen:"Offen",in_sitzung:"In Sitzung",rueckfrage:"Rückfrage",klaerung:"Klärung",erledigt:"Erledigt"}[e]||e||"Offen"}function L2(e){return e==="klaerung"?"Klärung":e==="bemerkung"?"Bemerkung / Wiedervorlage":"Rückfrage"}function Jl(){return{title:"",description:"",duration_minutes:"",case_id:""}}function M2({form:e,setForm:t,cases:n}){return r.jsxs(r.Fragment,{children:[r.jsxs("label",{children:[r.jsx("span",{children:"Titel"}),r.jsx("input",{className:"input",required:!0,value:e.title,onChange:i=>t({...e,title:i.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Dauer in Minuten"}),r.jsx("input",{className:"input",type:"number",min:"0",max:"600",value:e.duration_minutes,onChange:i=>t({...e,duration_minutes:i.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Vorgang"}),r.jsxs("select",{className:"input",value:e.case_id,onChange:i=>t({...e,case_id:i.target.value}),children:[r.jsx("option",{value:"",children:"Kein Vorgang"}),n.map(i=>r.jsxs("option",{value:i.id,children:[i.case_number," – ",i.title]},i.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"input textarea",value:e.description,onChange:i=>t({...e,description:i.target.value})})]})]})}function F2(){const[e,t]=f.useState([]),[n,i]=f.useState([]),[s,a]=f.useState(null),[o,l]=f.useState(Jl()),[c,u]=f.useState(""),[m,d]=f.useState(""),[h,x]=f.useState(""),[g,p]=f.useState(!1);async function w(){x("");try{const[_,S]=await Promise.all([O.get("/agenda-items"),O.get("/cases")]);t(Array.isArray(_)?_:[]),i(Array.isArray(S)?S:[])}catch(_){x(_.message)}}f.useEffect(()=>{w()},[]),f.useEffect(()=>{if(!g)return;const _=document.body.style.overflow;document.body.style.overflow="hidden";function S(P){P.key==="Escape"&&j()}return window.addEventListener("keydown",S),()=>{document.body.style.overflow=_,window.removeEventListener("keydown",S)}},[g]);const b=f.useMemo(()=>{const _=c.trim().toLowerCase();return _?e.filter(S=>JSON.stringify(S).toLowerCase().includes(_)):e},[e,c]);function v(){a(null),l(Jl()),d(""),x(""),p(!0)}function y(_){a(_),l({title:_.title??"",description:_.description??"",duration_minutes:_.duration_minutes!=null?String(_.duration_minutes):"",case_id:_.case_id?String(_.case_id):""}),d(""),x(""),p(!0)}function j(){p(!1),a(null),l(Jl()),x("")}async function k(_){_.preventDefault(),x(""),d("");const S={title:o.title.trim(),description:o.description.trim()||null,duration_minutes:o.duration_minutes?Number(o.duration_minutes):null,case_id:o.case_id?Number(o.case_id):null};try{s!=null&&s.id?(await O.put(`/agenda-items/${s.id}`,S),d("TOP-Bestandseintrag gespeichert")):(await O.post("/agenda-items",S),d("TOP-Bestandseintrag erstellt")),j(),await w()}catch(P){x(P.message)}}async function N(_){x(""),d("");try{await O.delete(`/agenda-items/${_}`),(s==null?void 0:s.id)===_&&j(),await w(),d("TOP-Bestandseintrag gelöscht")}catch(S){x(S.message)}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .agenda-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.62); + backdrop-filter: blur(2px); + } + .agenda-modal-panel { + width: min(760px, 96vw); + max-height: min(92vh, 920px); + overflow: hidden; + display: flex; + flex-direction: column; + border-radius: 22px; + background: #ffffff; + color: #0f172a; + border: 1px solid rgba(15, 23, 42, 0.12); + box-shadow: 0 30px 90px rgba(15, 23, 42, 0.34), 0 8px 24px rgba(15, 23, 42, 0.18); + } + .agenda-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 22px 24px 18px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border-bottom: 1px solid rgba(148, 164, 184, 0.28); + } + .agenda-modal-header h2 { margin: 0; color: #0f172a; font-size: 1.35rem; } + .agenda-modal-subtitle { margin: 6px 0 0; color: #64748b; line-height: 1.45; } + .agenda-modal-close { + flex: 0 0 auto; + width: 36px; + height: 36px; + border: 1px solid rgba(148, 164, 184, 0.35); + border-radius: 999px; + background: #ffffff; + color: #0f172a; + cursor: pointer; + font-size: 1.3rem; + line-height: 1; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + } + .agenda-modal-close:hover { background: #f1f5f9; } + .agenda-modal-body { padding: 22px 24px; overflow: auto; background: #ffffff; } + .agenda-modal-footer { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; } + .agenda-followup-note { color: var(--muted); display: block; margin-top: 4px; } + @media (max-width: 800px) { + .agenda-modal-backdrop { padding: 12px; align-items: start; } + .agenda-modal-panel { width: 100%; max-height: calc(100vh - 24px); } + } + `}),r.jsx("div",{className:"page-grid",children:r.jsxs(de,{title:"TOP-Bestand",subtitle:"Hier werden nur TOPs ohne Sitzungszuordnung angezeigt. Aus Vorgängen erzeugte TOPs sind über die Vorgangsnummer erkennbar.",actions:r.jsxs("div",{style:{display:"flex",gap:12,alignItems:"center",flexWrap:"wrap"},children:[r.jsx("input",{className:"input search",placeholder:"Suchen...",value:c,onChange:_=>u(_.target.value)}),r.jsx("button",{className:"btn",type:"button",onClick:v,children:"Neuer TOP-Bestandseintrag"})]}),children:[h&&!g?r.jsx("div",{className:"alert error",children:h}):null,m?r.jsx("div",{className:"alert success",children:m}):null,r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Vorgang"}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Beschreibung"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Bemerkung"}),r.jsx("th",{children:"Minuten"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[b.map(_=>r.jsxs("tr",{onClick:()=>y(_),children:[r.jsx("td",{children:_.id}),r.jsx("td",{children:_.case_number||_.case_id||""}),r.jsxs("td",{children:[_.title,_.parent_agenda_item_id?r.jsxs(r.Fragment,{children:[r.jsx("br",{}),r.jsxs("small",{className:"agenda-followup-note",children:["Folge von TOP ",_.parent_agenda_number||_.parent_agenda_item_id,_.follow_up_source_meeting_number?` · Sitzung ${_.follow_up_source_meeting_number}`:""]})]}):null]}),r.jsx("td",{children:_.description}),r.jsx("td",{children:I2(_.status)}),r.jsx("td",{children:_.follow_up_note||_.last_return_remark?r.jsxs("small",{children:[L2(_.follow_up_type||_.last_return_type),": ",_.follow_up_note||_.last_return_remark]}):""}),r.jsx("td",{children:_.duration_minutes}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:S=>{S.stopPropagation(),y(_)},children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:S=>{S.stopPropagation(),N(_.id)},children:"Löschen"})]})]},_.id)),b.length===0?r.jsx("tr",{children:r.jsx("td",{colSpan:8,children:"Keine TOP-Bestandseinträge gefunden."})}):null]})]})})]})}),g?r.jsx("div",{className:"agenda-modal-backdrop",onMouseDown:j,children:r.jsxs("div",{className:"agenda-modal-panel",role:"dialog","aria-modal":"true","aria-labelledby":"agenda-modal-title",onMouseDown:_=>_.stopPropagation(),children:[r.jsxs("div",{className:"agenda-modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{id:"agenda-modal-title",children:s?"TOP-Bestand bearbeiten":"Neuen TOP-Bestandseintrag anlegen"}),r.jsx("p",{className:"agenda-modal-subtitle",children:s!=null&&s.parent_agenda_item_id?`Folge-TOP zu TOP ${s.parent_agenda_number||s.parent_agenda_item_id}${s.follow_up_source_meeting_number?` aus Sitzung ${s.follow_up_source_meeting_number}`:""}.`:"Der Eintrag bleibt zunächst ohne Sitzungszuordnung und kann später einer Sitzung hinzugefügt werden."})]}),r.jsx("button",{className:"agenda-modal-close",type:"button",onClick:j,"aria-label":"Schließen",children:"×"})]}),r.jsxs("div",{className:"agenda-modal-body",children:[h?r.jsx("div",{className:"alert error",children:h}):null,r.jsxs("form",{className:"form-grid",onSubmit:k,children:[r.jsx(M2,{form:o,setForm:l,cases:n}),r.jsxs("div",{className:"agenda-modal-footer",children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:j,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",children:s?"Speichern":"Erstellen"})]})]})]})]})}):null]})}const Nu=[{value:"in_bearbeitung",label:"In Bearbeitung"},{value:"rueckfrage",label:"Rückfrage"},{value:"abgeschlossen",label:"Abgeschlossen"}];function ec(){return{agenda_item_id:"",meeting_id:"",case_id:"",title:"",decision_text:"",status:"in_bearbeitung",votes_yes:"0",votes_no:"0",votes_abstain:"0",responsible_user_id:"",due_date:""}}function $2(e){return Nu.some(t=>t.value===e)?e:"in_bearbeitung"}function fp(e){return{agenda_item_id:e.agenda_item_id?String(e.agenda_item_id):"",meeting_id:e.meeting_id?String(e.meeting_id):"",case_id:e.case_id?String(e.case_id):"",title:e.title??"",decision_text:e.decision_text??"",status:$2(e.status),votes_yes:String(e.votes_yes??0),votes_no:String(e.votes_no??0),votes_abstain:String(e.votes_abstain??0),responsible_user_id:e.responsible_user_id?String(e.responsible_user_id):"",due_date:e.due_date?String(e.due_date).slice(0,16):""}}function q2(e){return{id:e.id,title:e.title||e.file_name||e.original_filename||"Datei",file_name:e.file_name||e.original_filename||e.title||"Datei",size_bytes:e.size_bytes||0,mime_type:e.mime_type||"",uploaded_at:e.uploaded_at||e.created_at||null,uploaded_by_name:e.uploaded_by_name||"",uploaded_by_email:e.uploaded_by_email||""}}function V2(e){const t=String((e==null?void 0:e.mime_type)||"").toLowerCase();return t.startsWith("image/")||t==="application/pdf"||t.startsWith("text/")}function U2(e){const t=Number(e||0);return t?t<1024?`${t} B`:t<1024*1024?`${Math.round(t/1024)} KB`:`${(t/(1024*1024)).toFixed(1)} MB`:"0 KB"}function mp(e){if(!e)return"";const t=new Date(e);return Number.isNaN(t.getTime())?"":t.toLocaleString("de-DE")}function H2(){var G,ne,ye,Pn,Ci,Rs,Os,Is,Ls,Ms,Fs,$s,qs,Vs;const[e,t]=f.useState([]),[n,i]=f.useState({agendaItems:[],users:[]}),[s,a]=f.useState(null),[o,l]=f.useState(ec()),[c,u]=f.useState(""),[m,d]=f.useState(""),[h,x]=f.useState(""),[g,p]=f.useState(!1),[w,b]=f.useState(!1),[v,y]=f.useState(!1),[j,k]=f.useState(""),[N,_]=f.useState(null),[S,P]=f.useState([]),[D,F]=f.useState(!1),[T,M]=f.useState(!1),[z,A]=f.useState(null),[$,Z]=f.useState(!1),[R,K]=f.useState(!1),[X,ae]=f.useState(""),J=Jt();f.useEffect(()=>{const I=new URLSearchParams(J.search).get("open");if(!I||!e.length)return;const se=e.find(ce=>String(ce.id)===String(I));se&&ge(se)},[J.search,e]);async function ue(){x("");try{const[I,se]=await Promise.all([O.get("/decisions"),O.get("/decisions/meta")]);t(Array.isArray(I)?I:[]),i(se||{agendaItems:[],users:[]})}catch(I){x(I.message||"Daten konnten nicht geladen werden.")}}f.useEffect(()=>{ue()},[]);const pe=f.useMemo(()=>{const I=e.filter(ce=>ce.status!=="abgeschlossen"),se=c.trim().toLowerCase();return se?I.filter(ce=>JSON.stringify(ce).toLowerCase().includes(se)):I},[e,c]);async function ee(I){if(!I){_(null),P([]);return}F(!0);try{const[se,ce]=await Promise.all([O.get(`/cases/${I}`),O.get(`/cases/${I}/attachments`)]);_(se||null);const Ke=Array.isArray(ce)?ce:Array.isArray(ce==null?void 0:ce.items)?ce.items:[];P(Ke.map(q2))}catch(se){_(null),P([]),x(se.message||"Vorgangsdateien konnten nicht geladen werden.")}finally{F(!1)}}f.useEffect(()=>{if(g){if(!o.case_id){_(null),P([]);return}ee(o.case_id)}},[g,o.case_id]),f.useEffect(()=>{var ce;if(!T)return;const I=(s==null?void 0:s.id)||((ce=z==null?void 0:z.decision)==null?void 0:ce.id);if(!I)return;const se=window.setInterval(()=>{V(I,{silent:!0})},2500);return()=>window.clearInterval(se)},[T,s==null?void 0:s.id,(G=z==null?void 0:z.decision)==null?void 0:G.id]);function le(){a(null),l(ec()),_(null),P([]),A(null),ae(""),k(""),y(!1),x(""),d(""),p(!0)}function ge(I){a(I),k(""),y(!1),l(fp(I)),_(I.case_id?{id:I.case_id,case_number:I.case_number,title:I.title}:null),P([]),A(null),ae(""),x(""),d(""),p(!0)}function Y(){p(!1),a(null),l(ec()),_(null),P([]),F(!1),M(!1),A(null),ae(""),k(""),y(!1)}async function fe(){if(!j){Y();return}y(!0),x(""),ae("");try{await O.delete("/decisions/"+j),await ue(),Y(),d("Vorläufig angelegter Beschluss wurde verworfen.")}catch(I){x(I.message||"Vorläufig angelegter Beschluss konnte nicht gelöscht werden."),y(!1)}}function Se(I){const se=n.agendaItems.find(ce=>String(ce.id)===String(I));l(ce=>({...ce,agenda_item_id:I,meeting_id:se!=null&&se.meeting_id?String(se.meeting_id):"",case_id:se!=null&&se.case_id?String(se.case_id):"",title:ce.title||(se==null?void 0:se.title)||""}))}async function he(I){try{await _i(`/documents/${I.id}/view`)}catch(se){x(se.message||"Datei konnte nicht angezeigt werden.")}}async function q(I){try{await ji(`/documents/${I.id}/download`,I.file_name||I.title||`datei-${I.id}`)}catch(se){x(se.message||"Datei konnte nicht heruntergeladen werden.")}}function re(){const I={agenda_item_id:o.agenda_item_id?Number(o.agenda_item_id):null,meeting_id:o.meeting_id?Number(o.meeting_id):null,case_id:o.case_id?Number(o.case_id):null,title:o.title.trim(),decision_text:o.decision_text.trim(),status:o.status,votes_yes:Number(o.votes_yes||0),votes_no:Number(o.votes_no||0),votes_abstain:Number(o.votes_abstain||0),responsible_user_id:o.responsible_user_id?Number(o.responsible_user_id):null,due_date:o.due_date||null};if(!I.title)throw new Error("Bitte einen Titel eingeben.");if(!I.decision_text)throw new Error("Bitte einen Beschlusstext eingeben.");return I}function me(I){var Us,Hs,L,Q,oe,ve,Me;if(!I)return;A(I);const se=String(((Us=I.counts)==null?void 0:Us.yes)??((Hs=I.decision)==null?void 0:Hs.votes_yes)??0),ce=String(((L=I.counts)==null?void 0:L.no)??((Q=I.decision)==null?void 0:Q.votes_no)??0),Ke=String(((oe=I.counts)==null?void 0:oe.abstain)??((ve=I.decision)==null?void 0:ve.votes_abstain)??0);l(Ve=>({...Ve,votes_yes:se,votes_no:ce,votes_abstain:Ke})),(Me=I.decision)!=null&&Me.id&&t(Ve=>Ve.map(zr=>String(zr.id)===String(I.decision.id)?{...zr,votes_yes:Number(se),votes_no:Number(ce),votes_abstain:Number(Ke),voting_open:I.decision.voting_open}:zr))}async function V(I,{silent:se=!1}={}){if(!I)return null;se||(Z(!0),ae(""));try{const ce=await O.get(`/decisions/${I}/voting`);return me(ce),ce}catch(ce){return se||ae(ce.message||"Abstimmung konnte nicht geladen werden."),null}finally{se||Z(!1)}}async function te({keepModalOpen:I=!1}={}){x(""),d("");const se=re();b(!0);try{let ce;return s!=null&&s.id?(ce=await O.put(`/decisions/${s.id}`,se),d("Beschluss gespeichert")):(ce=await O.post("/decisions",se),d("Beschluss erstellt")),a(ce),l(fp(ce)),await ue(),I||Y(),ce}catch(ce){throw x(ce.message||"Speichern fehlgeschlagen."),ce}finally{b(!1)}}async function Ne(I){I.preventDefault();try{await te({keepModalOpen:!1})}catch{}}async function Xe(){var I;x(""),ae("");try{let se=s;!(se!=null&&se.id)&&(se=await te({keepModalOpen:!0}),k(String(se.id))),M(!0);let Ke=await V(se.id);Ke!=null&&Ke.can_manage&&!((I=Ke==null?void 0:Ke.decision)!=null&&I.voting_open)&&(Ke=await O.post(`/decisions/${se.id}/voting/start`,{}),me(Ke))}catch(se){ae(se.message||"Abstimmung konnte nicht geöffnet werden.")}}async function E(){var se;const I=(s==null?void 0:s.id)||((se=z==null?void 0:z.decision)==null?void 0:se.id);if(I){K(!0),ae("");try{const ce=await O.post(`/decisions/${I}/voting/start`,{});me(ce)}catch(ce){ae(ce.message||"Abstimmung konnte nicht gestartet werden.")}finally{K(!1)}}}async function B(){var se;const I=(s==null?void 0:s.id)||((se=z==null?void 0:z.decision)==null?void 0:se.id);if(I){K(!0),ae("");try{const ce=await O.post(`/decisions/${I}/voting/close`,{});me(ce)}catch(ce){ae(ce.message||"Abstimmung konnte nicht geschlossen werden.")}finally{K(!1)}}}async function U(I){var ce;const se=(s==null?void 0:s.id)||((ce=z==null?void 0:z.decision)==null?void 0:ce.id);if(se){K(!0),ae("");try{const Ke=await O.put(`/decisions/${se}/vote`,{vote_value:I});me(Ke)}catch(Ke){ae(Ke.message||"Stimme konnte nicht gespeichert werden.")}finally{K(!1)}}}async function C(I){x(""),d("");try{await O.delete(`/decisions/${I}`),(s==null?void 0:s.id)===I&&Y(),await ue(),d("Beschluss gelöscht")}catch(se){x(se.message||"Löschen fehlgeschlagen.")}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .decisions-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + } + + .row-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; + } + + .modal-panel { + width: min(1100px, 100%); + max-height: 92vh; + overflow: auto; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + padding: 18px; + } + + .modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .modal-subtitle { + margin: 6px 0 0; + opacity: 0.75; + font-size: 0.95rem; + } + + .modal-close { + border: 0; + background: transparent; + font-size: 28px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + } + + .form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + } + + .form-grid > label.full, + .form-grid > div.full { + grid-column: 1 / -1; + } + + .helper-text { + margin-top: 6px; + font-size: 0.9rem; + opacity: 0.75; + } + + .attachments-section { + display: grid; + gap: 10px; + margin-top: 6px; + } + + .attachments-title { + font-weight: 600; + } + + .upload-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; + } + + .upload-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + background: rgba(148, 163, 184, 0.08); + } + + .upload-item-main { + min-width: 0; + } + + .upload-item-name { + font-weight: 600; + word-break: break-word; + } + + .upload-item-meta { + margin-top: 3px; + font-size: 0.85rem; + opacity: 0.75; + } + + + .vote-summary-card { + grid-column: 1 / -1; + border: 1px solid rgba(44, 107, 237, 0.18); + border-radius: 16px; + padding: 14px; + background: rgba(44, 107, 237, 0.06); + display: grid; + gap: 12px; + } + + .vote-summary-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + } + + .vote-summary-grid, + .vote-count-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + } + + .vote-summary-item { + border: 1px solid rgba(148, 163, 184, 0.32); + border-radius: 14px; + background: rgba(255, 255, 255, 0.72); + padding: 10px; + } + + .vote-summary-item span { + display: block; + font-size: 0.82rem; + color: var(--muted); + margin-bottom: 4px; + } + + .vote-summary-item strong { + font-size: 1.2rem; + } + + .vote-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1100; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15, 23, 42, 0.62); + } + + .vote-modal-panel { + width: min(760px, 96vw); + max-height: min(92vh, 920px); + overflow: auto; + border-radius: 22px; + background: #ffffff; + color: #0f172a; + border: 1px solid rgba(15, 23, 42, 0.12); + box-shadow: 0 30px 90px rgba(15, 23, 42, 0.34), 0 8px 24px rgba(15, 23, 42, 0.18); + padding: 22px; + } + + .vote-modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + margin-bottom: 14px; + } + + .vote-status { + display: inline-block; + border-radius: 999px; + padding: 4px 10px; + font-weight: 700; + font-size: 0.84rem; + background: rgba(148, 163, 184, 0.16); + } + + .vote-status.open { + color: #047857; + background: rgba(16, 185, 129, 0.12); + } + + .vote-status.closed { + color: #b45309; + background: rgba(245, 158, 11, 0.14); + } + + .vote-self-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin: 14px 0; + } + + .vote-choice { + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 999px; + background: #ffffff; + cursor: pointer; + padding: 9px 14px; + font-weight: 700; + } + + .vote-choice.active { + border-color: rgba(44, 107, 237, 0.65); + background: rgba(44, 107, 237, 0.12); + color: #1d4ed8; + } + + .vote-list { + display: grid; + gap: 8px; + margin-top: 12px; + } + + .vote-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: #f8fafc; + } + + .vote-user-meta { + color: #64748b; + font-size: 0.86rem; + margin-top: 2px; + } + + .vote-pill { + border-radius: 999px; + padding: 4px 9px; + background: rgba(148, 163, 184, 0.16); + font-weight: 700; + white-space: nowrap; + } + + @media (max-width: 860px) { + .form-grid, + .vote-summary-grid, + .vote-count-row { + grid-template-columns: 1fr; + } + + .modal-panel { + padding: 14px; + } + } + `}),r.jsx("div",{className:"page-grid",children:r.jsxs(de,{title:"Beschlüsse",subtitle:"TOPs aus gestarteten Sitzungen stehen als Dropdown zur Verfügung. Sitzung und Vorgang werden daraus automatisch übernommen. Abgeschlossene Beschlüsse sind in dieser Liste ausgeblendet.",actions:r.jsxs("div",{className:"decisions-toolbar",children:[r.jsx("input",{className:"input search",placeholder:"Suchen...",value:c,onChange:I=>u(I.target.value)}),r.jsx("button",{className:"btn",type:"button",onClick:le,children:"Neuer Beschluss"})]}),children:[h?r.jsx("div",{className:"alert error",children:h}):null,m?r.jsx("div",{className:"alert success",children:m}):null,r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Beschluss"}),r.jsx("th",{children:"TOP"}),r.jsx("th",{children:"Sitzung"}),r.jsx("th",{children:"Vorgang"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[pe.map(I=>{var se;return r.jsxs("tr",{onClick:()=>ge(I),children:[r.jsx("td",{children:I.id}),r.jsx("td",{children:I.decision_number}),r.jsx("td",{children:I.agenda_number?`${I.agenda_number} – ${I.agenda_title||""}`:I.agenda_item_id||""}),r.jsx("td",{children:I.meeting_number||I.meeting_id||""}),r.jsx("td",{children:I.case_number||I.case_id||""}),r.jsx("td",{children:((se=Nu.find(ce=>ce.value===I.status))==null?void 0:se.label)||I.status}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:ce=>{ce.stopPropagation(),ge(I)},children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:ce=>{ce.stopPropagation(),C(I.id)},children:"Löschen"})]})]},I.id)}),pe.length===0?r.jsx("tr",{children:r.jsx("td",{colSpan:"7",children:"Keine Beschlüsse gefunden."})}):null]})]})})]})}),g?r.jsx("div",{className:"modal-backdrop",onClick:fe,children:r.jsxs("div",{className:"modal-panel",onClick:I=>I.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:s?"Beschluss bearbeiten":"Neuen Beschluss anlegen"}),r.jsx("p",{className:"modal-subtitle",children:"Beschlussnummer wird automatisch vergeben. Wenn der gewählte TOP zu einem Vorgang gehört, wird dieser automatisch übernommen."})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:fe,"aria-label":"Schließen",disabled:v,children:"×"})]}),h?r.jsx("div",{className:"alert error",children:h}):null,r.jsxs("form",{className:"form-grid",onSubmit:Ne,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Beschlussnummer"}),r.jsx("input",{className:"input",value:(s==null?void 0:s.decision_number)||"wird automatisch vergeben",disabled:!0})]}),r.jsxs("label",{children:[r.jsx("span",{children:"TOP aus gestarteter Sitzung"}),r.jsxs("select",{className:"input",value:o.agenda_item_id,onChange:I=>Se(I.target.value),children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),n.agendaItems.map(I=>r.jsxs("option",{value:I.id,children:[I.meeting_number," · TOP ",I.agenda_number," · ",I.case_number?`${I.case_number} · `:"",I.title]},I.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Sitzungs-ID"}),r.jsx("input",{className:"input",value:o.meeting_id,readOnly:!0})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Vorgangs-ID"}),r.jsx("input",{className:"input",value:o.case_id,readOnly:!0}),r.jsx("div",{className:"helper-text",children:N!=null&&N.case_number?`Verknüpfter Vorgang: ${N.case_number}`:"Kein Vorgang aus dem TOP übernommen."})]}),r.jsxs("label",{className:"full",children:[r.jsx("span",{children:"Titel"}),r.jsx("input",{className:"input",value:o.title,onChange:I=>l({...o,title:I.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Status"}),r.jsx("select",{className:"input",value:o.status,onChange:I=>l({...o,status:I.target.value}),children:Nu.map(I=>r.jsx("option",{value:I.value,children:I.label},I.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Verantwortliche Person"}),r.jsxs("select",{className:"input",value:o.responsible_user_id,onChange:I=>l({...o,responsible_user_id:I.target.value}),children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),n.users.map(I=>r.jsxs("option",{value:I.id,children:[I.full_name," (",I.email,")"]},I.id))]})]}),r.jsxs("div",{className:"full vote-summary-card",children:[r.jsxs("div",{className:"vote-summary-head",children:[r.jsxs("div",{children:[r.jsx("strong",{children:"Abstimmung"}),r.jsx("div",{className:"helper-text",children:"Mitglieder des Gremiums der ausgewählten Sitzung stimmen selbst ab. Die Summen werden vom Server gezählt und hier automatisch übernommen."})]}),r.jsx("button",{className:"btn secondary",type:"button",onClick:Xe,disabled:w||v,children:s!=null&&s.id?"Abstimmung öffnen":"Speichern & Abstimmung"})]}),r.jsxs("div",{className:"vote-summary-grid",children:[r.jsxs("div",{className:"vote-summary-item",children:[r.jsx("span",{children:"Ja"}),r.jsx("strong",{children:o.votes_yes||0})]}),r.jsxs("div",{className:"vote-summary-item",children:[r.jsx("span",{children:"Nein"}),r.jsx("strong",{children:o.votes_no||0})]}),r.jsxs("div",{className:"vote-summary-item",children:[r.jsx("span",{children:"Enthaltung"}),r.jsx("strong",{children:o.votes_abstain||0})]}),r.jsxs("div",{className:"vote-summary-item",children:[r.jsx("span",{children:"Gesamt"}),r.jsx("strong",{children:Number(o.votes_yes||0)+Number(o.votes_no||0)+Number(o.votes_abstain||0)})]})]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Ja-Stimmen"}),r.jsx("input",{className:"input",type:"number",min:"0",value:o.votes_yes,onChange:I=>l({...o,votes_yes:I.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Nein-Stimmen"}),r.jsx("input",{className:"input",type:"number",min:"0",value:o.votes_no,onChange:I=>l({...o,votes_no:I.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Enthaltungen"}),r.jsx("input",{className:"input",type:"number",min:"0",value:o.votes_abstain,onChange:I=>l({...o,votes_abstain:I.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Umsetzungsfrist"}),r.jsx("input",{className:"input",type:"datetime-local",value:o.due_date,onChange:I=>l({...o,due_date:I.target.value})})]}),r.jsxs("label",{className:"full",children:[r.jsx("span",{children:"Beschlusstext"}),r.jsx("textarea",{className:"input textarea",value:o.decision_text,onChange:I=>l({...o,decision_text:I.target.value})})]}),r.jsxs("div",{className:"full",children:[r.jsx("span",{children:"Vorgangsdateien"}),r.jsxs("div",{className:"attachments-section",children:[o.case_id?null:r.jsx("div",{className:"helper-text",children:"Kein verknüpfter Vorgang vorhanden."}),o.case_id&&D?r.jsx("div",{className:"helper-text",children:"Vorgangsdateien werden geladen..."}):null,o.case_id&&!D&&(N!=null&&N.title)?r.jsxs("div",{className:"helper-text",children:[N.case_number?`${N.case_number} – `:"",N.title]}):null,o.case_id&&!D&&S.length===0?r.jsx("div",{className:"helper-text",children:"Zu diesem Vorgang sind keine Dateien vorhanden."}):null,S.length>0?r.jsx("ul",{className:"upload-list",children:S.map(I=>r.jsxs("li",{className:"upload-item",children:[r.jsxs("div",{className:"upload-item-main",children:[r.jsx("div",{className:"upload-item-name",children:I.file_name}),r.jsx("div",{className:"upload-item-meta",children:U2(I.size_bytes)}),I.uploaded_at?r.jsxs("div",{className:"upload-item-meta",children:["Hochgeladen: ",mp(I.uploaded_at)]}):null,I.uploaded_by_name?r.jsxs("div",{className:"upload-item-meta",children:["Von: ",I.uploaded_by_name]}):null]}),r.jsxs("div",{className:"row-actions",children:[V2(I)?r.jsx("button",{type:"button",className:"mini-btn",onClick:()=>he(I),children:"Anzeigen"}):null,r.jsx("button",{type:"button",className:"mini-btn",onClick:()=>q(I),children:"Download"})]})]},I.id))}):null]})]}),r.jsxs("div",{className:"full",style:{display:"flex",gap:10,flexWrap:"wrap"},children:[r.jsx("button",{className:"btn",type:"submit",disabled:w||v,children:w?"Speichern...":s?"Speichern":"Erstellen"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:fe,disabled:w||v,children:v?"Verwerfe...":"Abbrechen"}),j?r.jsx("div",{className:"helper-text",style:{flexBasis:"100%"},children:"Dieser Beschluss wurde für die Abstimmung vorläufig gespeichert. Beim Abbrechen wird er wieder gelöscht."}):null]})]})]})}):null,T?r.jsx("div",{className:"vote-modal-backdrop",onClick:()=>M(!1),children:r.jsxs("div",{className:"vote-modal-panel",role:"dialog","aria-modal":"true",onClick:I=>I.stopPropagation(),children:[r.jsxs("div",{className:"vote-modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:"Abstimmung"}),r.jsxs("p",{className:"modal-subtitle",children:[(ne=z==null?void 0:z.decision)!=null&&ne.decision_number?`${z.decision.decision_number} – `:"",((ye=z==null?void 0:z.decision)==null?void 0:ye.title)||o.title]}),r.jsxs("p",{className:"modal-subtitle",children:[(Pn=z==null?void 0:z.decision)!=null&&Pn.committee_name?`Gremium: ${z.decision.committee_name}`:"Kein Gremium zugeordnet",(Ci=z==null?void 0:z.decision)!=null&&Ci.meeting_number?` · Sitzung: ${z.decision.meeting_number}`:""]})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:()=>M(!1),"aria-label":"Schließen",children:"×"})]}),X?r.jsx("div",{className:"alert error",children:X}):null,$?r.jsx("div",{className:"helper-text",children:"Abstimmung wird geladen..."}):null,r.jsxs("div",{style:{display:"flex",justifyContent:"space-between",gap:12,flexWrap:"wrap",marginBottom:14},children:[r.jsx("span",{className:`vote-status ${(Rs=z==null?void 0:z.decision)!=null&&Rs.voting_open?"open":"closed"}`,children:(Os=z==null?void 0:z.decision)!=null&&Os.voting_open?"Abstimmung geöffnet":"Abstimmung geschlossen"}),r.jsxs("span",{className:"helper-text",children:["Stimmberechtigte: ",(z==null?void 0:z.eligible_voter_count)??0," · Abgegeben: ",(z==null?void 0:z.total_votes)??0]})]}),r.jsxs("div",{className:"vote-count-row",children:[r.jsxs("div",{className:"vote-summary-item",children:[r.jsx("span",{children:"Ja"}),r.jsx("strong",{children:((Is=z==null?void 0:z.counts)==null?void 0:Is.yes)??o.votes_yes??0})]}),r.jsxs("div",{className:"vote-summary-item",children:[r.jsx("span",{children:"Nein"}),r.jsx("strong",{children:((Ls=z==null?void 0:z.counts)==null?void 0:Ls.no)??o.votes_no??0})]}),r.jsxs("div",{className:"vote-summary-item",children:[r.jsx("span",{children:"Enthaltung"}),r.jsx("strong",{children:((Ms=z==null?void 0:z.counts)==null?void 0:Ms.abstain)??o.votes_abstain??0})]}),r.jsxs("div",{className:"vote-summary-item",children:[r.jsx("span",{children:"Gesamt"}),r.jsx("strong",{children:(z==null?void 0:z.total_votes)??0})]})]}),r.jsxs("div",{className:"vote-self-actions",children:[r.jsx("button",{className:`vote-choice ${(z==null?void 0:z.current_user_vote)==="yes"?"active":""}`,type:"button",disabled:R||!((Fs=z==null?void 0:z.decision)!=null&&Fs.voting_open)||!(z!=null&&z.current_user_can_vote),onClick:()=>U("yes"),children:"Ja"}),r.jsx("button",{className:`vote-choice ${(z==null?void 0:z.current_user_vote)==="no"?"active":""}`,type:"button",disabled:R||!(($s=z==null?void 0:z.decision)!=null&&$s.voting_open)||!(z!=null&&z.current_user_can_vote),onClick:()=>U("no"),children:"Nein"}),r.jsx("button",{className:`vote-choice ${(z==null?void 0:z.current_user_vote)==="abstain"?"active":""}`,type:"button",disabled:R||!((qs=z==null?void 0:z.decision)!=null&&qs.voting_open)||!(z!=null&&z.current_user_can_vote),onClick:()=>U("abstain"),children:"Enthaltung"})]}),z!=null&&z.current_user_can_vote?null:r.jsx("div",{className:"helper-text",children:"Du bist für dieses Gremium nicht stimmberechtigt oder die Sitzung ist nicht gestartet."}),z!=null&&z.can_manage?r.jsxs("div",{className:"row-actions",style:{margin:"14px 0"},children:[(Vs=z==null?void 0:z.decision)!=null&&Vs.voting_open?r.jsx("button",{className:"btn secondary",type:"button",onClick:B,disabled:R,children:"Abstimmung schließen"}):r.jsx("button",{className:"btn",type:"button",onClick:E,disabled:R,children:"Abstimmung starten"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>{var I;return V((s==null?void 0:s.id)||((I=z==null?void 0:z.decision)==null?void 0:I.id))},disabled:$||R,children:"Aktualisieren"})]}):null,r.jsxs("div",{className:"vote-list",children:[((z==null?void 0:z.eligible_voters)||[]).map(I=>r.jsxs("div",{className:"vote-row",children:[r.jsxs("div",{children:[r.jsx("strong",{children:I.full_name||I.email||`Benutzer ${I.id}`}),r.jsxs("div",{className:"vote-user-meta",children:[I.email||"ohne E-Mail",I.voted_at?` · ${mp(I.voted_at)}`:""]})]}),r.jsx("span",{className:"vote-pill",children:I.vote_value==="yes"?"Ja":I.vote_value==="no"?"Nein":I.vote_value==="abstain"?"Enthaltung":"offen"})]},I.id)),z&&!(z.eligible_voters||[]).length?r.jsx("div",{className:"helper-text",children:"Keine stimmberechtigten Mitglieder für dieses Gremium gefunden."}):null]})]})}):null]})}const Fv=[{value:"offen",label:"Offen"},{value:"in_bearbeitung",label:"In Bearbeitung"},{value:"wartet",label:"Wartet"},{value:"erledigt",label:"Erledigt"},{value:"abgebrochen",label:"Abgebrochen"}],$v=[{value:"sehr_hoch",label:"Sehr hoch"},{value:"hoch",label:"Hoch"},{value:"mittel",label:"Mittel"},{value:"niedrig",label:"Niedrig"}],qv=[{value:"intern",label:"Intern"},{value:"vertraulich",label:"Vertraulich"},{value:"streng_vertraulich",label:"Streng vertraulich"}];function tc(){return{title:"",description:"",status:"offen",priority:"mittel",assigned_user_id:"",backup_user_id:"",committee_id:"",case_id:"",meeting_id:"",agenda_item_id:"",decision_id:"",due_date:"",confidentiality_level:"intern"}}function K2(e){if(!e)return"";const t=new Date(e);return Number.isNaN(t.getTime())?String(e):t.toLocaleString("de-DE")}function W2(e){return{title:e.title??"",description:e.description??"",status:e.status??"offen",priority:e.priority??"mittel",assigned_user_id:e.assigned_user_id?String(e.assigned_user_id):"",backup_user_id:e.backup_user_id?String(e.backup_user_id):"",committee_id:e.committee_id?String(e.committee_id):"",case_id:e.case_id?String(e.case_id):"",meeting_id:e.meeting_id?String(e.meeting_id):"",agenda_item_id:e.agenda_item_id?String(e.agenda_item_id):"",decision_id:e.decision_id?String(e.decision_id):"",due_date:e.due_date?String(e.due_date).slice(0,16):"",confidentiality_level:e.confidentiality_level??"intern"}}function G2({open:e,selected:t,form:n,setForm:i,users:s,committees:a,meetings:o,agendaItems:l,decisions:c,cases:u,startedMeeting:m,onClose:d,onSubmit:h,loading:x,error:g}){if(!e)return null;const p=m?l.filter(_=>String(_.meeting_id||"")===String(m.id)):[],w=n.meeting_id?l.filter(_=>String(_.meeting_id||"")===String(n.meeting_id)):[],b=c.filter(_=>n.meeting_id&&String(_.meeting_id||"")===String(n.meeting_id)||n.case_id&&String(_.case_id||"")===String(n.case_id)||n.decision_id&&String(_.id)===String(n.decision_id)?!0:!n.meeting_id&&!n.case_id);function v(){if(!m)return;const _=p[0]||null;i(S=>({...S,meeting_id:String(m.id),agenda_item_id:_!=null&&_.id?String(_.id):S.agenda_item_id,case_id:_!=null&&_.case_id?String(_.case_id):S.case_id,decision_id:_!=null&&_.decision_id?String(_.decision_id):S.decision_id}))}function y(_){const S=l.find(P=>String(P.meeting_id||"")===String(_))||null;i(P=>({...P,meeting_id:_,agenda_item_id:S!=null&&S.id?String(S.id):"",case_id:S!=null&&S.case_id?String(S.case_id):P.case_id,decision_id:S!=null&&S.decision_id?String(S.decision_id):P.decision_id}))}function j(_){const S=l.find(P=>String(P.id)===String(_));if(!S){i(P=>({...P,agenda_item_id:""}));return}i(P=>({...P,agenda_item_id:String(S.id),meeting_id:S.meeting_id?String(S.meeting_id):P.meeting_id,case_id:S.case_id?String(S.case_id):P.case_id,decision_id:S.decision_id?String(S.decision_id):P.decision_id}))}function k(_){const S=c.find(P=>String(P.id)===String(_));i(P=>({...P,decision_id:_,case_id:S!=null&&S.case_id?String(S.case_id):P.case_id,meeting_id:S!=null&&S.meeting_id?String(S.meeting_id):P.meeting_id}))}function N(_){const S=u.find(P=>String(P.id)===String(_));return S?`${S.case_number||S.id} – ${S.title||""}`:_||"Wird automatisch übernommen"}return r.jsx("div",{className:"modal-backdrop",onClick:d,children:r.jsxs("div",{className:"modal-panel",onClick:_=>_.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:t?"Aufgabe bearbeiten":"Neue Aufgabe anlegen"}),r.jsx("p",{className:"modal-subtitle",children:"Status, Priorität, Vertraulichkeit und Zuordnungen werden vollständig im Modal gepflegt."})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:d,"aria-label":"Schließen",children:"×"})]}),g?r.jsx("div",{className:"alert error",children:g}):null,r.jsxs("form",{className:"form-grid",onSubmit:h,children:[r.jsxs("div",{className:"row-actions",style:{gridColumn:"1 / -1",marginBottom:4},children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:v,disabled:!m,children:"Gestartete Sitzung übernehmen"}),m?r.jsxs("span",{className:"field-hint",children:["Aktiv: ",m.meeting_number||m.id,m.title?` – ${m.title}`:""]}):r.jsx("span",{className:"field-hint",children:"Keine gestartete Sitzung gefunden."})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Titel"}),r.jsx("input",{className:"input",value:n.title,onChange:_=>i(S=>({...S,title:_.target.value}))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Status"}),r.jsx("select",{className:"input",value:n.status,onChange:_=>i(S=>({...S,status:_.target.value})),children:Fv.map(_=>r.jsx("option",{value:_.value,children:_.label},_.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Priorität"}),r.jsx("select",{className:"input",value:n.priority,onChange:_=>i(S=>({...S,priority:_.target.value})),children:$v.map(_=>r.jsx("option",{value:_.value,children:_.label},_.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Vertraulichkeit"}),r.jsx("select",{className:"input",value:n.confidentiality_level,onChange:_=>i(S=>({...S,confidentiality_level:_.target.value})),children:qv.map(_=>r.jsx("option",{value:_.value,children:_.label},_.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Sitzung"}),r.jsxs("select",{className:"input",value:n.meeting_id,onChange:_=>y(_.target.value),children:[r.jsx("option",{value:"",children:"Keine Sitzung"}),o.map(_=>r.jsxs("option",{value:_.id,children:[_.meeting_number||_.id,_.title?` – ${_.title}`:"",_.status?` (${_.status})`:""]},_.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"TOP aus aktiver/gewählter Sitzung"}),r.jsxs("select",{className:"input",value:n.agenda_item_id,onChange:_=>j(_.target.value),children:[r.jsx("option",{value:"",children:"Kein TOP"}),w.map(_=>r.jsxs("option",{value:_.id,children:[_.title,_.case_number?` – ${_.case_number}`:_.case_id?` – Vorgang ${_.case_id}`:""]},_.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Beschluss"}),r.jsxs("select",{className:"input",value:n.decision_id,onChange:_=>k(_.target.value),children:[r.jsx("option",{value:"",children:"Kein Beschluss"}),b.map(_=>r.jsx("option",{value:_.id,children:_.title||`Beschluss ${_.id}`},_.id))]}),r.jsx("div",{className:"field-hint",children:"Beim Speichern wird die Aufgabe mit dem gewählten TOP und optionalem Beschluss verknüpft."})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Vorgang"}),r.jsx("input",{className:"input",value:N(n.case_id),readOnly:!0}),r.jsx("div",{className:"field-hint",children:"Wird aus TOP, Beschluss oder gestarteter Sitzung übernommen."})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Fällig am"}),r.jsx("input",{className:"input",type:"datetime-local",value:n.due_date,onChange:_=>i(S=>({...S,due_date:_.target.value}))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Primär zugewiesen an"}),r.jsxs("select",{className:"input",value:n.assigned_user_id,onChange:_=>i(S=>({...S,assigned_user_id:_.target.value})),children:[r.jsx("option",{value:"",children:"Kein Benutzer"}),s.map(_=>r.jsx("option",{value:_.id,children:_.full_name},_.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Stellvertretung"}),r.jsxs("select",{className:"input",value:n.backup_user_id,onChange:_=>i(S=>({...S,backup_user_id:_.target.value})),children:[r.jsx("option",{value:"",children:"Keine Stellvertretung"}),s.map(_=>r.jsx("option",{value:_.id,children:_.full_name},_.id))]}),r.jsx("div",{className:"field-hint",children:"Mehrfachzuweisung ist hier über primäre Zuweisung, Stellvertretung und optionales Gremium abgebildet."})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Gremium"}),r.jsxs("select",{className:"input",value:n.committee_id,onChange:_=>i(S=>({...S,committee_id:_.target.value})),children:[r.jsx("option",{value:"",children:"Kein Gremium"}),a.map(_=>r.jsx("option",{value:_.id,children:_.name},_.id))]})]}),r.jsxs("label",{style:{gridColumn:"1 / -1"},children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"input textarea",style:{minHeight:200},value:n.description,onChange:_=>i(S=>({...S,description:_.target.value}))})]}),r.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:10,gridColumn:"1 / -1"},children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:d,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",disabled:x,children:x?"Speichern...":t?"Speichern":"Erstellen"})]})]})]})})}function X2(){const[e,t]=f.useState([]),[n,i]=f.useState([]),[s,a]=f.useState([]),[o,l]=f.useState([]),[c,u]=f.useState([]),[m,d]=f.useState([]),[h,x]=f.useState([]),[g,p]=f.useState(null),[w,b]=f.useState(tc()),[v,y]=f.useState(""),[j,k]=f.useState(""),[N,_]=f.useState(""),[S,P]=f.useState(""),[D,F]=f.useState(!1),[T,M]=f.useState(null),[z,A]=f.useState(!1),$=f.useMemo(()=>o.find(Y=>String(Y.status||"").toLowerCase()==="gestartet")||null,[o]),Z=f.useMemo(()=>Object.fromEntries(n.map(Y=>[String(Y.id),Y])),[n]),R=f.useMemo(()=>Object.fromEntries(s.map(Y=>[String(Y.id),Y])),[s]),K=f.useMemo(()=>Object.fromEntries(o.map(Y=>[String(Y.id),Y])),[o]),X=f.useMemo(()=>Object.fromEntries(h.map(Y=>[String(Y.id),Y])),[h]),ae=f.useMemo(()=>{const Y=v.trim().toLowerCase();return Y?e.filter(fe=>JSON.stringify(fe).toLowerCase().includes(Y)):e},[e,v]);async function J(){var V;_("");const[Y,fe,Se,he,q,re,me]=await Promise.allSettled([O.get("/tasks"),O.get("/users"),O.get("/committees"),O.get("/meetings"),O.get("/agenda-items?include_assigned=true"),O.get("/decisions"),O.get("/cases")]);Y.status==="fulfilled"?t(Array.isArray(Y.value)?Y.value:[]):_(((V=Y.reason)==null?void 0:V.message)||"Aufgaben konnten nicht geladen werden."),i(fe.status==="fulfilled"&&Array.isArray(fe.value)?fe.value.filter(te=>te.is_active!==!1):[]),a(Se.status==="fulfilled"&&Array.isArray(Se.value)?Se.value:[]),l(he.status==="fulfilled"&&Array.isArray(he.value)?he.value:[]),u(q.status==="fulfilled"&&Array.isArray(q.value)?q.value:[]),d(re.status==="fulfilled"&&Array.isArray(re.value)?re.value:[]),x(me.status==="fulfilled"&&Array.isArray(me.value)?me.value:[])}f.useEffect(()=>{J()},[]);function ue(){const Y=tc();if($){Y.meeting_id=String($.id);const fe=c.find(Se=>String(Se.meeting_id||"")===String($.id))||null;fe&&(Y.agenda_item_id=fe.id?String(fe.id):"",Y.case_id=fe.case_id?String(fe.case_id):"",Y.decision_id=fe.decision_id?String(fe.decision_id):"")}p(null),b(Y),P(""),k(""),_(""),A(!0)}function pe(Y){p(Y),b(W2(Y)),P(""),k(""),_(""),A(!0)}function ee(){p(null),b(tc()),P(""),A(!1)}async function le(Y){if(Y.preventDefault(),P(""),k(""),!w.title.trim()){P("Bitte einen Titel eingeben.");return}F(!0);try{const fe={title:w.title.trim(),description:w.description.trim()||null,status:w.status,priority:w.priority,assigned_user_id:w.assigned_user_id?Number(w.assigned_user_id):null,backup_user_id:w.backup_user_id?Number(w.backup_user_id):null,case_id:w.case_id?Number(w.case_id):null,meeting_id:w.meeting_id?Number(w.meeting_id):null,agenda_item_id:w.agenda_item_id?Number(w.agenda_item_id):null,decision_id:w.decision_id?Number(w.decision_id):null,due_date:w.due_date||null,confidentiality_level:w.confidentiality_level,committee_id:w.committee_id?Number(w.committee_id):null};g!=null&&g.id?(await O.put(`/tasks/${g.id}`,fe),k("Aufgabe gespeichert")):(await O.post("/tasks",fe),k("Aufgabe erstellt")),await J(),ee()}catch(fe){P(fe.message||"Aufgabe konnte nicht gespeichert werden.")}finally{F(!1)}}async function ge(Y){_(""),k(""),M(Y.id);try{await O.delete(`/tasks/${Y.id}`),(g==null?void 0:g.id)===Y.id&&ee(),await J(),k("Aufgabe gelöscht")}catch(fe){_(fe.message||"Aufgabe konnte nicht gelöscht werden.")}finally{M(null)}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; + } + + .modal-panel { + width: min(1040px, 100%); + max-height: 92vh; + overflow: auto; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + padding: 18px; + } + + .modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .modal-subtitle { + margin: 6px 0 0; + opacity: 0.75; + font-size: 0.95rem; + } + + .modal-close { + border: 0; + background: transparent; + font-size: 28px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + } + `}),r.jsxs(de,{title:"Aufgaben",subtitle:"Aufgaben werden im Modal angelegt und können direkt mit gestarteter Sitzung, TOP, Beschluss, Benutzern und Gremium verknüpft werden.",actions:r.jsxs("div",{style:{display:"flex",gap:12,alignItems:"center",flexWrap:"wrap"},children:[r.jsx("input",{className:"input search",placeholder:"Suchen...",value:v,onChange:Y=>y(Y.target.value)}),r.jsx("button",{className:"btn",type:"button",onClick:ue,children:"Neue Aufgabe"})]}),children:[N?r.jsx("div",{className:"alert error",children:N}):null,j?r.jsx("div",{className:"alert success",children:j}):null,D&&!e.length?r.jsx("div",{className:"field-hint",children:"Aufgaben werden geladen…"}):null,r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Priorität"}),r.jsx("th",{children:"Fällig"}),r.jsx("th",{children:"Sitzung"}),r.jsx("th",{children:"Vorgang"}),r.jsx("th",{children:"Benutzer/Gremium"}),r.jsx("th",{children:"Vertraulichkeit"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[ae.map(Y=>{var me,V,te;const fe=Z[String(Y.assigned_user_id||"")],Se=Z[String(Y.backup_user_id||"")],he=R[String(Y.committee_id||"")],q=K[String(Y.meeting_id||"")],re=X[String(Y.case_id||"")];return r.jsxs("tr",{children:[r.jsx("td",{children:Y.id}),r.jsx("td",{children:Y.title}),r.jsx("td",{children:((me=Fv.find(Ne=>Ne.value===Y.status))==null?void 0:me.label)||Y.status}),r.jsx("td",{children:((V=$v.find(Ne=>Ne.value===Y.priority))==null?void 0:V.label)||Y.priority}),r.jsx("td",{children:K2(Y.due_date)}),r.jsx("td",{children:q?`${q.meeting_number||q.id}${q.title?` – ${q.title}`:""}`:Y.meeting_id||""}),r.jsx("td",{children:re?`${re.case_number||re.id}${re.title?` – ${re.title}`:""}`:Y.case_number||Y.case_id||""}),r.jsxs("td",{children:[(fe==null?void 0:fe.full_name)||"",Se!=null&&Se.full_name?` / Stv. ${Se.full_name}`:"",he!=null&&he.name?` / ${he.name}`:""]}),r.jsx("td",{children:((te=qv.find(Ne=>Ne.value===Y.confidentiality_level))==null?void 0:te.label)||Y.confidentiality_level}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>pe(Y),children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn danger",type:"button",disabled:T===Y.id,onClick:()=>ge(Y),children:"Löschen"})]})]},Y.id)}),ae.length===0?r.jsx("tr",{children:r.jsx("td",{colSpan:10,children:"Keine Aufgaben gefunden."})}):null]})]})})]}),r.jsx(G2,{open:z,selected:g,form:w,setForm:b,users:n,committees:s,meetings:o,agendaItems:c,decisions:m,cases:h,startedMeeting:$,onClose:ee,onSubmit:le,loading:D,error:S})]})}const Q2=[{value:"oeffentlich",label:"Öffentlich"},{value:"intern",label:"Intern"},{value:"vertraulich",label:"Vertraulich"},{value:"streng_vertraulich",label:"Streng vertraulich"}];function ya(e=""){return{file:null,document_number:"",title:"",document_type:"intern",description:"",confidentiality_level:"intern",document_category_id:"",meeting_id:e?String(e):"",enable_versioning:!1,version_of_id:"",team_role_ids:[]}}function Z2(e){return{file:null,document_number:e.document_number??"",title:e.title??"",document_type:e.document_type??"intern",description:e.description??"",confidentiality_level:e.confidentiality_level??"intern",document_category_id:e.document_category_id?String(e.document_category_id):"",meeting_id:e.meeting_id?String(e.meeting_id):"",enable_versioning:!1,version_of_id:"",team_role_ids:Array.isArray(e.team_role_ids)?e.team_role_ids.map(String):[]}}function Vv(e){if(!e)return"";const t=new Date(e);return Number.isNaN(t.getTime())?"":t.toLocaleString("de-DE")}function Y2({open:e,selected:t,form:n,setForm:i,loading:s,activeMeeting:a,error:o,versionTargets:l,teamRoles:c,categories:u,onClose:m,onSubmit:d}){if(!e)return null;const h=l.find(g=>String(g.id)===String(n.version_of_id)),x=t?t.document_number||"":n.enable_versioning?(h==null?void 0:h.document_number)||"Bitte Basisdokument wählen":"wird automatisch vergeben";return r.jsx("div",{className:"modal-backdrop",onClick:m,children:r.jsxs("div",{className:"modal-panel",onClick:g=>g.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:t?"Dokument bearbeiten":"Dokument anlegen"}),r.jsx("p",{className:"modal-subtitle",children:t?"Titel, Beschreibung und Vertraulichkeit der ausgewählten Version bearbeiten.":"Dokumentennummer wird automatisch vergeben. Auf Wunsch kann eine neue Version zu einem bestehenden Dokument angelegt werden."})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:m,"aria-label":"Schließen",children:"×"})]}),o?r.jsx("div",{className:"alert error",children:o}):null,r.jsxs("form",{className:"form-grid documents-form-grid",onSubmit:d,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Dokumentennummer"}),r.jsx("input",{className:"input",value:x,disabled:!0}),!t&&n.enable_versioning&&h?r.jsxs("div",{className:"helper-text",children:["Neue Version für ",h.title]}):null]}),r.jsxs("label",{children:[r.jsx("span",{children:"Titel"}),r.jsx("input",{className:"input",value:n.title,onChange:g=>i(p=>({...p,title:g.target.value}))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Kategorie"}),r.jsxs("select",{className:"input",value:n.document_category_id,onChange:g=>i(p=>({...p,document_category_id:g.target.value})),children:[r.jsx("option",{value:"",children:"Keine Kategorie"}),(u||[]).map(g=>r.jsx("option",{value:g.id,children:g.label},g.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Vertraulichkeit"}),r.jsx("select",{className:"input",value:n.confidentiality_level,onChange:g=>i(p=>({...p,confidentiality_level:g.target.value})),children:Q2.map(g=>r.jsx("option",{value:g.value,children:g.label},g.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Sitzungs-ID"}),r.jsx("input",{className:"input",value:n.meeting_id,onChange:g=>i(p=>({...p,meeting_id:g.target.value.replace(/[^\d]/g,"")})),placeholder:a?String(a.id):"leer"}),r.jsx("div",{className:"helper-text",children:a?`Aktive Sitzung: ${a.meeting_number||a.id} – ${a.title||""}`:"Keine aktive Sitzung vorhanden."})]}),t?null:r.jsxs("label",{className:"full checkbox-card",children:[r.jsxs("span",{className:"checkbox-row",children:[r.jsx("input",{type:"checkbox",checked:n.enable_versioning,onChange:g=>i(p=>({...p,enable_versioning:g.target.checked,version_of_id:g.target.checked?p.version_of_id:""}))}),r.jsx("span",{children:"Versionierung einschalten"})]}),r.jsx("div",{className:"helper-text",children:"Wenn aktiviert, wird eine neue Version zu einem vorhandenen Dokument angelegt."})]}),!t&&n.enable_versioning?r.jsxs("label",{className:"full",children:[r.jsx("span",{children:"Basisdokument"}),r.jsxs("select",{className:"input",value:n.version_of_id,onChange:g=>i(p=>({...p,version_of_id:g.target.value})),children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),l.map(g=>r.jsxs("option",{value:g.id,children:[g.document_number," · ",g.title," · aktuell v",g.version_number]},g.id))]})]}):null,r.jsxs("label",{children:[r.jsx("span",{children:t?"Neue Datei als neue Version":"Datei auswählen"}),r.jsx("input",{className:"input",type:"file",onChange:g=>i(p=>{var w,b,v;return{...p,file:((w=g.target.files)==null?void 0:w[0])||null,title:!t&&!p.title?((v=(b=g.target.files)==null?void 0:b[0])==null?void 0:v.name)||"":p.title}})}),t?r.jsx("div",{className:"helper-text",children:"Wenn hier eine Datei gewählt wird, wird beim Speichern automatisch eine neue Version angelegt."}):null]}),t?r.jsxs("div",{className:"full file-info-block",children:[r.jsx("span",{children:"Aktuelle Datei"}),r.jsx("div",{className:"helper-text",children:t.file_name||t.title||"Datei"}),r.jsxs("div",{className:"helper-text",children:["Version: v",t.version_number||1]}),t.uploaded_at?r.jsxs("div",{className:"helper-text",children:["Hochgeladen: ",Vv(t.uploaded_at)]}):null,t.uploaded_by_name?r.jsxs("div",{className:"helper-text",children:["Von: ",t.uploaded_by_name]}):null,n.file?r.jsxs("div",{className:"helper-text",children:["Neue Datei gewählt: ",n.file.name]}):null]}):null,r.jsxs("label",{children:[r.jsx("span",{children:"Dokumentart"}),r.jsx("input",{className:"input",value:n.document_type,onChange:g=>i(p=>({...p,document_type:g.target.value}))})]}),r.jsxs("div",{className:"full",children:[r.jsx("span",{children:"Teamrollen mit Dokument-Zugriff"}),r.jsx("div",{className:"permission-list",children:(c||[]).map(g=>{const p=String(g.id),w=n.team_role_ids.includes(p);return r.jsxs("label",{className:"permission-check",children:[r.jsx("input",{type:"checkbox",checked:w,onChange:()=>{const b=new Set(n.team_role_ids);b.has(p)?b.delete(p):b.add(p),i(v=>({...v,team_role_ids:Array.from(b)}))}}),r.jsx("span",{children:g.name})]},g.id)})}),r.jsx("div",{className:"helper-text",children:"Diese Teamrollen erhalten Lesezugriff auf dieses Dokument. Chair und Upload-Benutzer sehen das Dokument immer."})]}),r.jsxs("label",{className:"full",children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"input textarea",value:n.description,onChange:g=>i(p=>({...p,description:g.target.value}))})]}),r.jsxs("div",{className:"full documents-modal-actions",children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:m,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",disabled:s,children:t?n.file?"Neue Version hochladen":"Speichern":n.enable_versioning?"Neue Version erstellen":"Erstellen"})]})]})]})})}function J2({open:e,row:t,items:n,loading:i,error:s,onClose:a,onView:o,onDownload:l}){return e?r.jsx("div",{className:"modal-backdrop",onClick:a,children:r.jsxs("div",{className:"modal-panel",onClick:c=>c.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:"Versionen anzeigen"}),r.jsx("p",{className:"modal-subtitle",children:t?`${t.document_number} · ${t.title}`:"Dokumentversionen"})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:a,"aria-label":"Schließen",children:"×"})]}),s?r.jsx("div",{className:"alert error",children:s}):null,i?r.jsx("div",{className:"helper-text",children:"Versionen werden geladen …"}):null,i?null:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Version"}),r.jsx("th",{children:"Datei"}),r.jsx("th",{children:"Hochgeladen"}),r.jsx("th",{children:"Von"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[n.map(c=>r.jsxs("tr",{children:[r.jsxs("td",{children:["v",c.version_number||1]}),r.jsx("td",{children:c.file_name||c.title||""}),r.jsx("td",{children:Vv(c.uploaded_at)}),r.jsx("td",{children:c.uploaded_by_name||""}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>o(c),children:"Anzeigen"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>l(c),children:"Download"})]})]},c.id)),n.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:5,children:"Keine Versionen gefunden"})})]})]})})]})}):null}function eC(){const[e,t]=f.useState([]),[n,i]=f.useState(null),[s,a]=f.useState(null),[o,l]=f.useState(ya()),[c,u]=f.useState(""),[m,d]=f.useState(""),[h,x]=f.useState(""),[g,p]=f.useState(""),[w,b]=f.useState(!1),[v,y]=f.useState(null),[j,k]=f.useState(!1),[N,_]=f.useState(!1),[S,P]=f.useState(null),[D,F]=f.useState([]),[T,M]=f.useState(!1),[z,A]=f.useState(""),[$,Z]=f.useState([]),[R,K]=f.useState([]),[X,ae]=f.useState("");async function J(){x("");try{const[V,te]=await Promise.all([O.get("/documents"),O.get("/documents/meta")]);t(Array.isArray(V)?V:[]);const Ne=(te==null?void 0:te.activeMeeting)||null;i(Ne),Z(Array.isArray(te==null?void 0:te.teamRoles)?te.teamRoles:[]),K(Array.isArray(te==null?void 0:te.categories)?te.categories:[]),l(Xe=>s!=null&&s.id||j?Xe:{...Xe,meeting_id:Ne!=null&&Ne.id?String(Ne.id):""})}catch(V){x(V.message||"Dokumente konnten nicht geladen werden.")}}f.useEffect(()=>{J()},[]);const ue=f.useMemo(()=>{const V=c.trim().toLowerCase();return e.filter(te=>X&&String(te.document_category_id||"")!==String(X)?!1:V?JSON.stringify(te).toLowerCase().includes(V):!0)},[e,c,X]),pe=f.useMemo(()=>[...e].sort((V,te)=>{const Ne=`${V.document_number||""} ${V.title||""}`.toLowerCase(),Xe=`${te.document_number||""} ${te.title||""}`.toLowerCase();return Ne.localeCompare(Xe,"de")}),[e]);function ee(){a(null),l(ya(n!=null&&n.id?String(n.id):"")),p(""),x(""),d(""),k(!0)}function le(V){a(V),l(Z2(V)),p(""),x(""),d(""),k(!0)}function ge(V){a(null),l({...ya(V.meeting_id?String(V.meeting_id):n!=null&&n.id?String(n.id):""),title:V.title??"",document_type:V.document_type??"intern",description:V.description??"",confidentiality_level:V.confidentiality_level??"intern",document_category_id:V.document_category_id?String(V.document_category_id):"",enable_versioning:!0,version_of_id:String(V.id),team_role_ids:Array.isArray(V.team_role_ids)?V.team_role_ids.map(String):[]}),p(""),x(""),d(""),k(!0)}function Y(){k(!1),a(null),l(ya(n!=null&&n.id?String(n.id):"")),p("")}async function fe(V){P(V),F([]),A(""),M(!0),_(!0);try{const te=await O.get(`/documents/${V.id}/versions`);F(Array.isArray(te==null?void 0:te.items)?te.items:[])}catch(te){A(te.message||"Versionen konnten nicht geladen werden.")}finally{M(!1)}}function Se(){_(!1),P(null),F([]),A("")}async function he(V){if(V.preventDefault(),p(""),d(""),!o.title.trim()){p("Bitte einen Titel eingeben.");return}if(!s&&o.enable_versioning&&!o.version_of_id){p("Bitte ein Basisdokument für die Versionierung auswählen.");return}b(!0);try{if(s!=null&&s.id&&o.file){const te=new FormData;te.append("file",o.file),te.append("title",o.title.trim()),te.append("document_type",o.document_type||"intern"),te.append("description",o.description.trim()),te.append("confidentiality_level",o.confidentiality_level),o.document_category_id&&te.append("document_category_id",o.document_category_id),o.meeting_id&&te.append("meeting_id",o.meeting_id),te.append("version_of_id",String(s.id)),te.append("team_role_ids",JSON.stringify(o.team_role_ids.map(Number))),await O.post("/documents/upload",te),d("Neue Dokumentversion erstellt")}else if(s!=null&&s.id){const te={title:o.title.trim(),document_type:o.document_type||"intern",description:o.description.trim()||null,confidentiality_level:o.confidentiality_level,document_category_id:o.document_category_id?Number(o.document_category_id):null,meeting_id:o.meeting_id?Number(o.meeting_id):null,team_role_ids:o.team_role_ids.map(Number)};await O.put(`/documents/${s.id}`,te),d("Dokument gespeichert")}else{if(!o.file){p("Bitte eine Datei auswählen."),b(!1);return}const te=new FormData;te.append("file",o.file),te.append("title",o.title.trim()),te.append("document_type",o.document_type||"intern"),te.append("description",o.description.trim()),te.append("confidentiality_level",o.confidentiality_level),o.document_category_id&&te.append("document_category_id",o.document_category_id),o.meeting_id&&te.append("meeting_id",o.meeting_id),o.enable_versioning&&o.version_of_id&&te.append("version_of_id",o.version_of_id),te.append("team_role_ids",JSON.stringify(o.team_role_ids.map(Number))),await O.post("/documents/upload",te),d(o.enable_versioning?"Neue Dokumentversion erstellt":"Dokument erstellt")}await J(),Y()}catch(te){p(te.message||"Dokument konnte nicht gespeichert werden.")}finally{b(!1)}}async function q(V){x(""),d(""),y(V.id);try{await O.delete(`/documents/${V.id}`),(s==null?void 0:s.id)===V.id&&Y(),await J(),d("Dokument gelöscht")}catch(te){x(te.message||"Dokument konnte nicht gelöscht werden.")}finally{y(null)}}async function re(V){x("");try{await _i(`/documents/${V.id}/view`)}catch(te){x(te.message||"Dokument konnte nicht angezeigt werden.")}}async function me(V){x("");try{await ji(`/documents/${V.id}/download`,V.file_name||V.title||`dokument-${V.id}`)}catch(te){x(te.message||"Dokument konnte nicht heruntergeladen werden.")}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .documents-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; + } + + .modal-panel { + width: min(920px, 100%); + max-height: 92vh; + overflow: auto; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + padding: 18px; + } + + .modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .modal-subtitle { + margin: 6px 0 0; + opacity: 0.75; + font-size: 0.95rem; + } + + .modal-close { + border: 0; + background: transparent; + font-size: 28px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + } + + .documents-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + } + + .documents-form-grid > label.full, + .documents-form-grid > div.full { + grid-column: 1 / -1; + } + + .helper-text { + margin-top: 6px; + font-size: 0.9rem; + opacity: 0.75; + } + + .documents-modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 4px; + } + + .permission-list { + display: grid; + gap: 8px; + } + + .permission-check { + display: flex; + gap: 8px; + align-items: center; + padding: 8px 10px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + } + + .checkbox-card { + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + } + + .checkbox-row { + display: flex; + gap: 10px; + align-items: center; + } + + .file-info-block { + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 12px; + } + + .version-button { + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + padding: 0; + text-decoration: underline; + } + + .version-meta { + display: block; + font-size: 0.85rem; + opacity: 0.7; + } + + @media (max-width: 860px) { + .documents-form-grid { + grid-template-columns: 1fr; + } + + .modal-panel { + padding: 14px; + } + } + `}),r.jsxs(de,{title:"Dokumente",subtitle:"Hier werden alle Dokumente angezeigt. Berichte sind zusätzlich auf der separaten Seite /reports gebündelt.",actions:r.jsxs("div",{className:"documents-toolbar",children:[r.jsx("input",{className:"input search",placeholder:"Suchen...",value:c,onChange:V=>u(V.target.value)}),r.jsxs("select",{className:"input",value:X,onChange:V=>ae(V.target.value),children:[r.jsx("option",{value:"",children:"Alle Kategorien"}),R.map(V=>r.jsx("option",{value:V.id,children:V.label},V.id))]}),r.jsx("button",{className:"btn",type:"button",onClick:ee,children:"Dokument anlegen"})]}),children:[h?r.jsx("div",{className:"alert error",children:h}):null,m?r.jsx("div",{className:"alert success",children:m}):null,r.jsxs("div",{style:{marginBottom:12,opacity:.8},children:["Aktive Sitzung: ",n?`${n.meeting_number||n.id} – ${n.title||""}`:"keine aktive Sitzung"]}),r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Dokument-Nr."}),r.jsx("th",{children:"Version"}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Kategorie"}),r.jsx("th",{children:"Vertraulichkeit"}),r.jsx("th",{children:"Sitzung"}),r.jsx("th",{children:"Datei"}),r.jsx("th",{children:"Scan"}),r.jsx("th",{children:"Hochgeladen"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[ue.map(V=>r.jsxs("tr",{children:[r.jsx("td",{children:V.id}),r.jsx("td",{children:V.document_number}),r.jsxs("td",{children:[V.version_count>1?r.jsxs("button",{className:"version-button",type:"button",onClick:()=>fe(V),children:["v",V.version_number||1]}):r.jsxs("span",{children:["v",V.version_number||1]}),V.version_count>1?r.jsxs("span",{className:"version-meta",children:[V.version_count," Versionen"]}):null]}),r.jsx("td",{children:V.title}),r.jsx("td",{children:V.category_label||"—"}),r.jsx("td",{children:V.confidentiality_level}),r.jsx("td",{children:V.meeting_number||V.meeting_id||""}),r.jsx("td",{children:V.file_name||""}),r.jsx("td",{children:V.scan_status}),r.jsx("td",{children:V.uploaded_at?new Date(V.uploaded_at).toLocaleString("de-DE"):""}),r.jsxs("td",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>le(V),children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>ge(V),children:"Neue Version"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>re(V),children:"Anzeigen"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>me(V),children:"Download"}),r.jsx("button",{className:"mini-btn danger",type:"button",disabled:v===V.id,onClick:()=>q(V),children:"Löschen"})]})]},V.id)),ue.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:11,children:"Keine Dokumente vorhanden"})})]})]})})]}),r.jsx(Y2,{open:j,selected:s,form:o,setForm:l,loading:w,activeMeeting:n,error:g,versionTargets:pe,teamRoles:$,categories:R,onClose:Y,onSubmit:he}),r.jsx(J2,{open:N,row:S,items:D,loading:T,error:z,onClose:Se,onView:re,onDownload:me})]})}function nc(){return{entity_type:"agenda_item",entity_id:"",comment_text:"",visibility:"intern"}}function tC(e){return e?new Intl.DateTimeFormat("de-DE",{dateStyle:"short",timeStyle:"short"}).format(new Date(e)):""}function nC(e){return e?new Intl.DateTimeFormat("de-DE",{dateStyle:"medium"}).format(new Date(e)):""}function rC(e){return e==="vertraulich"?"Vertraulich":"Intern"}function iC(e){return{geplant:"Geplant",ungeplant:"Ungeplant",gestartet:"Gestartet",beendet:"Beendet",in_bearbeitung:"In Bearbeitung",rueckfrage:"Rückfrage",klaerung:"Klärung",abgeschlossen:"Abgeschlossen"}[e]||e||"Offen"}function pp(e){if(!e)return"";const t=[];return e.meeting_number&&t.push(`Sitzung ${e.meeting_number}`),e.agenda_number&&t.push(`TOP ${e.agenda_number}`),e.case_number&&t.push(e.case_number),`${t.length?`${t.join(" · ")} · `:""}${e.title||"Ohne Titel"}`}function sC(){const[e,t]=f.useState([]),[n,i]=f.useState([]),[s,a]=f.useState(nc()),[o,l]=f.useState(null),[c,u]=f.useState(!1),[m,d]=f.useState(""),[h,x]=f.useState(""),[g,p]=f.useState(""),[w,b]=f.useState(""),[v,y]=f.useState(!1);async function j(){y(!0),b("");try{const[M,z]=await Promise.all([O.get("/comments"),O.get("/comments/open-agenda-items")]);t(Array.isArray(M)?M:[]),i(Array.isArray(z)?z:[])}catch(M){b(M.message||"Kommentare konnten nicht geladen werden.")}finally{y(!1)}}f.useEffect(()=>{j()},[]),f.useEffect(()=>{if(!c)return;const M=document.body.style.overflow;document.body.style.overflow="hidden";function z(A){A.key==="Escape"&&D()}return window.addEventListener("keydown",z),()=>{document.body.style.overflow=M,window.removeEventListener("keydown",z)}},[c]);const k=f.useMemo(()=>{const M=[...n];return(o==null?void 0:o.entity_type)==="agenda_item"&&o.entity_id&&!M.some(z=>String(z.id)===String(o.entity_id))&&M.unshift({id:o.entity_id,title:o.agenda_item_title||`TOP ${o.entity_id}`,agenda_number:o.agenda_number,meeting_number:o.meeting_number,meeting_date:o.meeting_date,meeting_status:o.meeting_status,case_number:o.case_number,decision_number:o.decision_number,decision_status:o.decision_status}),M},[n,o]),N=f.useMemo(()=>{const M=m.trim().toLowerCase();return e.filter(z=>h&&String(z.entity_id)!==String(h)?!1:M?JSON.stringify(z).toLowerCase().includes(M):!0)},[e,m,h]);function _(M,z){a(A=>({...A,[M]:z}))}function S(){l(null),a({...nc(),entity_id:h||""}),p(""),b(""),u(!0)}function P(M){l(M),a({entity_type:M.entity_type||"agenda_item",entity_id:M.entity_id?String(M.entity_id):"",comment_text:M.comment_text||"",visibility:M.visibility||"intern"}),p(""),b(""),u(!0)}function D(){u(!1),l(null),a(nc()),b("")}async function F(M){if(M.preventDefault(),p(""),b(""),!s.entity_id){b("Bitte einen offenen TOP auswählen.");return}const z={entity_type:"agenda_item",entity_id:Number(s.entity_id),comment_text:s.comment_text,visibility:s.visibility||"intern"};try{o!=null&&o.id?(await O.put(`/comments/${o.id}`,z),p("Kommentar gespeichert.")):(await O.post("/comments",z),p("Kommentar angelegt.")),D(),await j()}catch(A){b(A.message||"Kommentar konnte nicht gespeichert werden.")}}async function T(M){if(window.confirm("Kommentar löschen?")){p(""),b("");try{await O.delete(`/comments/${M.id}`),p("Kommentar gelöscht."),(o==null?void 0:o.id)===M.id&&D(),await j()}catch(z){b(z.message||"Kommentar konnte nicht gelöscht werden.")}}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .comments-grid { display: grid; gap: 18px; } + .comments-toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: end; margin-bottom: 14px; } + .comments-toolbar label { min-width: 220px; } + .comments-badge { display: inline-flex; align-items: center; border-radius: 999px; padding: 4px 9px; font-size: .82rem; font-weight: 800; background: rgba(37,99,235,.1); color: #1d4ed8; } + .comments-badge.confidential { background: #fff7ed; color: #9a3412; } + .comments-muted { color: var(--muted); } + .comments-text { white-space: pre-wrap; line-height: 1.5; } + .comments-form-note { margin: 0; color: var(--muted); line-height: 1.45; } + .comments-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.62); + backdrop-filter: blur(2px); + } + .comments-modal-panel { + width: min(760px, 96vw); + max-height: min(92vh, 920px); + overflow: hidden; + display: flex; + flex-direction: column; + border-radius: 22px; + background: #ffffff; + color: #0f172a; + border: 1px solid rgba(15, 23, 42, 0.12); + box-shadow: 0 30px 90px rgba(15, 23, 42, 0.34), 0 8px 24px rgba(15, 23, 42, 0.18); + } + .comments-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 22px 24px 18px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border-bottom: 1px solid rgba(148, 164, 184, 0.28); + } + .comments-modal-header h2 { margin: 0; color: #0f172a; font-size: 1.35rem; } + .comments-modal-subtitle { margin: 6px 0 0; color: #64748b; line-height: 1.45; } + .comments-modal-close { + flex: 0 0 auto; + width: 36px; + height: 36px; + border: 1px solid rgba(148, 164, 184, 0.35); + border-radius: 999px; + background: #ffffff; + color: #0f172a; + cursor: pointer; + font-size: 1.3rem; + line-height: 1; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + } + .comments-modal-close:hover { background: #f1f5f9; } + .comments-modal-body { padding: 22px 24px; overflow: auto; background: #ffffff; } + .comments-modal-footer { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; } + @media (max-width: 800px) { + .comments-modal-backdrop { padding: 12px; align-items: start; } + .comments-modal-panel { width: 100%; max-height: calc(100vh - 24px); } + } + `}),r.jsxs("div",{className:"comments-grid",children:[w&&!c?r.jsx("div",{className:"alert error",children:w}):null,g?r.jsx("div",{className:"alert success",children:g}):null,r.jsx(de,{title:"Kommentare",subtitle:"Kommentare können mit offenen TOPs aus dem TOP-Bestand verknüpft werden. Bereits einer Sitzung zugeordnete oder abgeschlossene TOPs werden in der Auswahl nicht angeboten.",actions:r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"btn",type:"button",onClick:S,children:"Kommentar anlegen"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:j,disabled:v,children:v?"Lade…":"Aktualisieren"})]}),children:r.jsxs("div",{className:"comments-toolbar",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Suchen"}),r.jsx("input",{className:"input",value:m,onChange:M=>d(M.target.value),placeholder:"Kommentar, TOP, Sitzung …"})]}),r.jsxs("label",{children:[r.jsx("span",{children:"TOP filtern"}),r.jsxs("select",{className:"input",value:h,onChange:M=>x(M.target.value),children:[r.jsx("option",{value:"",children:"Alle offenen TOPs im TOP-Bestand"}),n.map(M=>r.jsx("option",{value:M.id,children:pp(M)},M.id))]})]})]})}),r.jsx(de,{title:"Kommentarliste",subtitle:"Anzeige der vorhandenen Kommentare mit TOP-Bezug.",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"TOP"}),r.jsx("th",{children:"Kommentar"}),r.jsx("th",{children:"Sichtbarkeit"}),r.jsx("th",{children:"Kommentar von"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[N.map(M=>r.jsxs("tr",{onClick:()=>P(M),children:[r.jsxs("td",{children:[r.jsx("strong",{children:M.agenda_item_title||`${M.entity_type} #${M.entity_id}`}),r.jsx("br",{}),r.jsxs("small",{className:"comments-muted",children:[M.meeting_number?`Sitzung ${M.meeting_number}`:"TOP-Bestand",M.agenda_number?` · TOP ${M.agenda_number}`:"",M.meeting_date?` · ${nC(M.meeting_date)}`:"",M.decision_status?` · ${iC(M.decision_status)}`:""]})]}),r.jsx("td",{className:"comments-text",children:M.comment_text}),r.jsx("td",{children:r.jsx("span",{className:`comments-badge ${M.visibility==="vertraulich"?"confidential":""}`,children:rC(M.visibility)})}),r.jsxs("td",{children:[r.jsxs("strong",{children:["Kommentar von: ",M.author_name||"—"]}),r.jsx("br",{}),r.jsx("small",{children:tC(M.created_at)})]}),r.jsx("td",{children:r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:z=>{z.stopPropagation(),P(M)},children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:z=>{z.stopPropagation(),T(M)},children:"Löschen"})]})})]},M.id)),N.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:"5",children:"Noch keine Kommentare vorhanden."})})]})]})})})]}),c?r.jsx("div",{className:"comments-modal-backdrop",onMouseDown:D,children:r.jsxs("div",{className:"comments-modal-panel",role:"dialog","aria-modal":"true","aria-labelledby":"comments-modal-title",onMouseDown:M=>M.stopPropagation(),children:[r.jsxs("div",{className:"comments-modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{id:"comments-modal-title",children:o?"Kommentar bearbeiten":"Kommentar anlegen"}),r.jsx("p",{className:"comments-modal-subtitle",children:"Wähle einen offenen TOP aus dem TOP-Bestand aus und erfasse die Bemerkung als Kommentar."})]}),r.jsx("button",{className:"comments-modal-close",type:"button",onClick:D,"aria-label":"Schließen",children:"×"})]}),r.jsxs("div",{className:"comments-modal-body",children:[w?r.jsx("div",{className:"alert error",children:w}):null,r.jsxs("form",{className:"form-grid",onSubmit:F,children:[r.jsxs("label",{children:[r.jsx("span",{children:"TOP"}),r.jsxs("select",{className:"input",required:!0,value:s.entity_id,onChange:M=>_("entity_id",M.target.value),children:[r.jsx("option",{value:"",children:"Offenen TOP aus TOP-Bestand auswählen"}),k.map(M=>r.jsx("option",{value:M.id,children:pp(M)},M.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Sichtbarkeit"}),r.jsxs("select",{className:"input",value:s.visibility,onChange:M=>_("visibility",M.target.value),children:[r.jsx("option",{value:"intern",children:"Intern"}),r.jsx("option",{value:"vertraulich",children:"Vertraulich"})]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Kommentar"}),r.jsx("textarea",{className:"input textarea",required:!0,value:s.comment_text,onChange:M=>_("comment_text",M.target.value)})]}),r.jsx("p",{className:"comments-form-note",children:"Hinweis: Die Auswahl entspricht dem TOP-Bestand aus „TOPs“ und enthält nur nicht erledigte TOPs, die noch keiner Sitzung zugeordnet sind. Bereits vorhandene Kommentare bleiben sichtbar, sofern deine Rolle darauf zugreifen darf."}),r.jsxs("div",{className:"comments-modal-footer",children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:D,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",children:o?"Kommentar speichern":"Kommentar anlegen"})]})]})]})]})}):null]})}const Cu={title:"",category:"meeting_protocol",description:"",content_format:"html",content:`

Betriebsrat {{committee_name}}

+

{{template_logo}}

+

Protokoll der BR-Sitzung am {{meeting_date}}

+

{{meeting_title_block}}

+

Organisatorisches und Feststellungen

+

Sitzungsnummer: {{meeting_number}}
Sitzungsart: {{meeting_type}}
Sitzungsbeginn: {{start_time}}
Sitzungsort: {{location}}
Sitzungsleitung: {{chair_name}}
Schriftführung: {{secretary_name}}

+

{{quorum_statement}}

+

{{attendees_block}}

+

Protokollnotizen

+

{{protocol_notes}}

+

Tagesordnung und Beratung

+

{{agenda_sections}}

+

Sitzungsende

+

Schließung der Sitzung um {{end_time}} Uhr.

+

{{signature_place}}

`,version_label:"v1",is_active:!0,logo_asset_id:""},aC={committee_name:"Betriebsrat Beispiel GmbH",meeting_number:"BR-2026-04",meeting_date:"12.05.2026",meeting_title_block:"Titel: Ordentliche BR-Sitzung",meeting_type:"Ordentlich",start_time:"09:00",end_time:"11:30",location:"Besprechungsraum 1",chair_name:"Max Mustermann",secretary_name:"Erika Beispiel",quorum_statement:"Die Sitzung ist Beschlussfähig.",attendees_block:`Anwesende Betriebsratsmitglieder +- Max Mustermann (Vorsitz) +- Erika Beispiel (Schriftführung)`,protocol_notes:"Keine zusätzlichen Protokollnotizen hinterlegt.",agenda_sections:`TOP 1: Beispiel-TOP +Beschreibung und Beratung. +Beschluss: Beispielbeschluss.`,signature_place:"Musterstadt, den 12.05.2026"};function hp(e){return{...Cu,...e,description:(e==null?void 0:e.description)||"",content_format:(e==null?void 0:e.content_format)||"markdown",logo_asset_id:e!=null&&e.logo_asset_id?String(e.logo_asset_id):"",is_active:(e==null?void 0:e.is_active)!==!1}}function oC(e){return String(e||"").replace(/<\s*script[^>]*>[\s\S]*?<\s*\/\s*script\s*>/gi,"").replace(/<\s*style[^>]*>[\s\S]*?<\s*\/\s*style\s*>/gi,"").replace(/\son[a-z]+\s*=\s*"[^"]*"/gi,"").replace(/\son[a-z]+\s*=\s*'[^']*'/gi,"").replace(/javascript:/gi,"")}function Er(e){return String(e??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function lC(e){return String(e||"").replace(/\r\n/g,` +`).split(` +`).map(n=>{const i=n.trim();return i?i.startsWith("### ")?`

${Er(i.slice(4))}

`:i.startsWith("## ")?`

${Er(i.slice(3))}

`:i.startsWith("# ")?`

${Er(i.slice(2))}

`:i.startsWith("- ")?`

• ${Er(i.slice(2))}

`:`

${Er(n)}

`:"
"}).join(` +`)}function cC(e,t,n){const i=n?``:'{{template_logo}}';let s=t==="html"?oC(e):lC(e);return s=s.replace(/\{\{\s*template_logo\s*\}\}/g,i),Object.entries(aC).forEach(([a,o])=>{const l=Er(o).replace(/\n/g,"
");s=s.replace(new RegExp(`\\{\\{\\s*${a}\\s*\\}\\}`,"g"),l)}),s.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g,'{{$1}}')}function uC(){const[e,t]=f.useState([]),[n,i]=f.useState([]),[s,a]=f.useState([]),[o,l]=f.useState(""),[c,u]=f.useState(Cu),[m,d]=f.useState("editor"),[h,x]=f.useState(null),[g,p]=f.useState(""),[w,b]=f.useState(""),[v,y]=f.useState(""),[j,k]=f.useState(""),[N,_]=f.useState(!1),S=f.useRef(null),P=f.useRef(null),D=f.useRef(null);async function F(){const q=await O.get("/templates");t(q||[])}async function T(){const q=await O.get("/templates/assets");i(q||[])}async function M(){const q=await O.get("/templates/placeholders");a(q||[])}async function z(){_(!0),k("");try{await Promise.all([F(),T(),M()])}catch(q){k(q.message||"Vorlagen konnten nicht geladen werden.")}finally{_(!1)}}f.useEffect(()=>{z()},[]),f.useEffect(()=>{c.content_format==="html"&&m==="editor"&&S.current&&S.current.innerHTML!==c.content&&(S.current.innerHTML=c.content||"")},[c.content_format,c.content,m,o]),f.useEffect(()=>{let q=!1,re="";async function me(){if(!c.logo_asset_id){b("");return}try{const te=await(await Uo(`/templates/assets/${c.logo_asset_id}/view`)).blob();re=URL.createObjectURL(te),q||b(re)}catch{q||b("")}}return me(),()=>{q=!0,re&&URL.revokeObjectURL(re)}},[c.logo_asset_id]);const A=f.useMemo(()=>n.find(q=>String(q.id)===String(c.logo_asset_id)),[n,c.logo_asset_id]),$=f.useMemo(()=>cC(c.content,c.content_format,w),[c.content,c.content_format,w]);function Z(q,re){u(me=>({...me,[q]:re}))}function R(){D.current=null,l(""),u(Cu),d("editor"),y(""),k("")}async function K(q){D.current=null,y(""),k(""),l(String(q));try{const re=await O.get(`/templates/${q}`);u(hp(re)),d((re==null?void 0:re.content_format)==="html"?"editor":"source")}catch(re){k(re.message||"Vorlage konnte nicht geladen werden.")}}function X(q){q.preventDefault()}function ae(q){return!q||!S.current?!1:S.current.contains(q.commonAncestorContainer)}function J(){var me;if(c.content_format!=="html"||m!=="editor")return;const q=(me=window.getSelection)==null?void 0:me.call(window);if(!(q!=null&&q.rangeCount))return;const re=q.getRangeAt(0);ae(re)&&(D.current=re.cloneRange())}function ue(){var me;const q=(me=window.getSelection)==null?void 0:me.call(window),re=D.current;!q||!ae(re)||(q.removeAllRanges(),q.addRange(re))}function pe(){c.content_format==="html"&&m==="editor"&&S.current&&Z("content",S.current.innerHTML)}function ee(q,re=null){var me;c.content_format==="html"&&((me=S.current)==null||me.focus(),ue(),document.execCommand(q,!1,re),pe(),J())}function le(q){var Xe;if(c.content_format==="html"&&m==="editor"){(Xe=S.current)==null||Xe.focus(),ue(),document.execCommand("insertHTML",!1,q),pe(),J();return}const re=P.current,me=q.replace(/<[^>]+>/g,"");if(!re){Z("content",`${c.content||""}${me}`);return}const V=re.selectionStart??c.content.length,te=re.selectionEnd??c.content.length,Ne=`${c.content.slice(0,V)}${me}${c.content.slice(te)}`;Z("content",Ne),window.requestAnimationFrame(()=>{re.focus(),re.setSelectionRange(V+me.length,V+me.length)})}function ge(q){le(`${q}`)}function Y(){ge("{{template_logo}}")}async function fe(q){q.preventDefault(),y(""),k("");let re=c.content;c.content_format==="html"&&m==="editor"&&S.current&&(re=S.current.innerHTML);const me={title:c.title,category:c.category,description:c.description||null,content:re,content_format:c.content_format,logo_asset_id:c.logo_asset_id?Number(c.logo_asset_id):null,version_label:c.version_label||"v1",is_active:!!c.is_active};try{let V;o?(V=await O.put(`/templates/${o}`,me),y("Vorlage gespeichert.")):(V=await O.post("/templates",me),l(String(V.id)),y("Vorlage angelegt.")),u(hp(V)),await F()}catch(V){k(V.message||"Vorlage konnte nicht gespeichert werden.")}}async function Se(q){if(window.confirm(`Vorlage „${q.title}“ wirklich löschen?`)){y(""),k("");try{await O.delete(`/templates/${q.id}`),y("Vorlage gelöscht."),String(o)===String(q.id)&&R(),await F()}catch(re){k(re.message||"Vorlage konnte nicht gelöscht werden.")}}}async function he(q){if(q.preventDefault(),y(""),k(""),!h){k("Bitte zuerst eine PNG- oder JPEG-Datei auswählen.");return}try{const re=new FormData;re.append("file",h),re.append("title",g||h.name);const me=await O.post("/templates/assets/upload",re);x(null),p(""),Z("logo_asset_id",String(me.id)),await T(),y("Logo/Bild wurde hochgeladen und der Vorlage zugeordnet.")}catch(re){k(re.message||"Logo/Bild konnte nicht hochgeladen werden.")}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .templates-layout { display: grid; grid-template-columns: 1fr; gap: 18px; align-items: start; } + .templates-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; } + .template-list-item { width: 100%; text-align: left; border: 1px solid rgba(148,164,184,.28); background: #fff; border-radius: 16px; padding: 12px; cursor: pointer; display: grid; gap: 4px; } + .template-list-item.active { border-color: rgba(37,99,235,.6); background: rgba(37,99,235,.06); } + .template-list-item strong { color: #0f172a; } + .template-list-item small { color: var(--muted); } + .template-editor-shell { display: grid; gap: 16px; } + .template-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .template-toolbar { display: flex; gap: 6px; flex-wrap: wrap; padding: 10px; border: 1px solid rgba(148,164,184,.28); border-radius: 16px; background: #f8fafc; } + .template-toolbar button, .template-mode-btn { border: 1px solid rgba(148,164,184,.36); background: #fff; color: #0f172a; border-radius: 10px; min-height: 34px; padding: 6px 10px; cursor: pointer; font-weight: 700; } + .template-toolbar button:hover, .template-mode-btn:hover { background: #f1f5f9; } + .template-mode-btn.active { color: #1d4ed8; border-color: rgba(37,99,235,.55); background: rgba(37,99,235,.08); } + .template-editor { min-height: 520px; border: 1px solid rgba(148,164,184,.36); border-radius: 18px; padding: 18px; background: #fff; color: #0f172a; line-height: 1.55; overflow: auto; } + .template-editor:focus { outline: 3px solid rgba(37,99,235,.16); border-color: rgba(37,99,235,.5); } + .template-editor h1, .template-preview h1 { font-size: 1.7rem; margin: .3rem 0 .8rem; } + .template-editor h2, .template-preview h2 { font-size: 1.25rem; margin: 1.2rem 0 .5rem; } + .template-editor h3, .template-preview h3 { font-size: 1.05rem; margin: 1rem 0 .4rem; } + .template-source { min-height: 520px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre; } + .template-preview { min-height: 520px; border: 1px solid rgba(148,164,184,.28); border-radius: 18px; padding: 22px; background: #fff; color: #0f172a; line-height: 1.55; } + .template-preview-logo { text-align: center; margin: 0 0 18px; } + .template-preview-logo img { max-width: 260px; max-height: 96px; object-fit: contain; } + .template-token { display: inline-block; padding: 1px 6px; border-radius: 999px; background: rgba(37,99,235,.10); color: #1d4ed8; font-weight: 700; } + .template-placeholder-missing { display: inline-block; padding: 4px 8px; border-radius: 10px; background: #fff7ed; color: #c2410c; font-weight: 700; } + .template-placeholder-panel { border: 1px solid rgba(148,164,184,.28); background: #fff; border-radius: 16px; padding: 12px; display: grid; gap: 8px; max-height: 240px; overflow: auto; } + .template-placeholder-panel-top { margin-top: 12px; } + .template-placeholder-grid { display: flex; gap: 6px; flex-wrap: wrap; } + .template-placeholder-grid button { border: 1px solid rgba(37,99,235,.26); background: rgba(37,99,235,.06); color: #1d4ed8; border-radius: 999px; padding: 5px 9px; cursor: pointer; font-weight: 700; } + .template-logo-card { border: 1px solid rgba(148,164,184,.28); background: #fff; border-radius: 16px; padding: 14px; display: grid; gap: 12px; } + .template-logo-preview { min-height: 104px; border: 1px dashed rgba(148,164,184,.55); border-radius: 14px; display: grid; place-items: center; background: #f8fafc; padding: 12px; } + .template-logo-preview img { max-width: 260px; max-height: 90px; object-fit: contain; } + .template-mode-tabs { display: flex; gap: 8px; flex-wrap: wrap; } + .template-delete-btn { margin-left: auto; } + @media (max-width: 1150px) { .template-editor-grid { grid-template-columns: 1fr; } } + `}),j?r.jsx("div",{className:"alert error",children:j}):null,v?r.jsx("div",{className:"alert success",children:v}):null,r.jsxs("div",{className:"templates-layout",children:[r.jsxs(de,{title:"Vorlagen",subtitle:"HTML5/WYSIWYG-Editor für Protokoll- und Berichtsvorlagen. Platzhalter bleiben als {{platzhalter}} erhalten.",actions:r.jsx("button",{className:"btn",type:"button",onClick:R,children:"Neue Vorlage"}),children:[r.jsxs("div",{className:"templates-list",children:[e.map(q=>r.jsxs("button",{className:`template-list-item ${String(o)===String(q.id)?"active":""}`,type:"button",onClick:()=>K(q.id),children:[r.jsx("strong",{children:q.title}),r.jsxs("small",{children:[q.category," · ",q.version_label||"v1"," · ",q.content_format||"markdown"]}),r.jsxs("small",{children:[q.is_active?"Aktiv":"Inaktiv",q.logo_title?` · Logo: ${q.logo_title}`:""]})]},q.id)),e.length?null:r.jsx("p",{children:"Noch keine Vorlagen vorhanden."})]}),r.jsxs("div",{className:"template-placeholder-panel template-placeholder-panel-top",children:[r.jsx("strong",{children:"Platzhalter einfügen"}),r.jsx("small",{children:"Per Klick an der aktuellen Cursorposition in „Vorlage bearbeiten“ einfügen."}),r.jsx("div",{className:"template-placeholder-grid",children:s.map(q=>r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>ge(q.token),title:q.label,children:q.token},q.token))})]})]}),r.jsxs("form",{onSubmit:fe,className:"template-editor-shell",children:[r.jsx(de,{title:o?"Vorlage bearbeiten":"Vorlage anlegen",subtitle:"Für Sitzungsprotokolle wird die aktive Vorlage mit Kategorie meeting_protocol verwendet. Bilder werden über {{template_logo}} eingefügt.",actions:r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:z,disabled:N,children:N?"Lade…":"Aktualisieren"}),r.jsx("button",{className:"btn",type:"submit",children:o?"Speichern":"Anlegen"})]}),children:r.jsxs("div",{className:"form-grid",children:[r.jsxs("div",{className:"template-editor-grid",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Titel"}),r.jsx("input",{className:"input",required:!0,value:c.title,onChange:q=>Z("title",q.target.value)})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Kategorie"}),r.jsx("input",{className:"input",required:!0,value:c.category,onChange:q=>Z("category",q.target.value),placeholder:"meeting_protocol"})]})]}),r.jsxs("div",{className:"template-editor-grid",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Format"}),r.jsxs("select",{className:"input",value:c.content_format,onChange:q=>{Z("content_format",q.target.value),d(q.target.value==="html"?"editor":"source")},children:[r.jsx("option",{value:"html",children:"HTML5/WYSIWYG"}),r.jsx("option",{value:"markdown",children:"Text/Markdown-kompatibel"})]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Version"}),r.jsx("input",{className:"input",required:!0,value:c.version_label,onChange:q=>Z("version_label",q.target.value)})]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"textarea",value:c.description,onChange:q=>Z("description",q.target.value)})]}),r.jsxs("label",{className:"check-row",children:[r.jsx("input",{type:"checkbox",checked:c.is_active,onChange:q=>Z("is_active",q.target.checked)}),r.jsx("span",{children:"Vorlage aktiv"})]})]})}),r.jsx(de,{title:"Logo / Bild",subtitle:"PNG oder JPEG hochladen, auswählen und über {{template_logo}} in die Vorlage einfügen.",children:r.jsxs("div",{className:"template-logo-card",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Vorhandenes Logo/Bild"}),r.jsxs("select",{className:"input",value:c.logo_asset_id,onChange:q=>Z("logo_asset_id",q.target.value),children:[r.jsx("option",{value:"",children:"Kein Logo/Bild"}),n.map(q=>r.jsxs("option",{value:q.id,children:[q.title," · ",q.file_name]},q.id))]})]}),r.jsx("div",{className:"template-logo-preview",children:w?r.jsx("img",{src:w,alt:"Template-Logo"}):r.jsx("span",{children:"Kein Logo/Bild ausgewählt"})}),A?r.jsxs("small",{children:["Ausgewählt: ",A.title," · ",A.mime_type]}):null,r.jsxs("button",{className:"btn secondary",type:"button",onMouseDown:X,onClick:Y,children:["{{template_logo}}"," einfügen"]}),r.jsxs("div",{className:"form-grid",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Neues Logo/Bild hochladen"}),r.jsx("input",{className:"input",type:"file",accept:"image/png,image/jpeg",onChange:q=>{var re;return x(((re=q.target.files)==null?void 0:re[0])||null)}})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Titel des Bildes"}),r.jsx("input",{className:"input",value:g,onChange:q=>p(q.target.value),placeholder:"z. B. BR-Logo"})]}),r.jsx("button",{className:"btn secondary",type:"button",onClick:he,children:"Logo/Bild hochladen"})]})]})}),r.jsxs(de,{title:"Inhalt",subtitle:"Editor, Quelltext und Vorschau können jederzeit gewechselt werden. Gespeichert wird der HTML-/Textinhalt mit unveränderten Platzhaltern.",actions:r.jsxs("div",{className:"template-mode-tabs",children:[c.content_format==="html"?r.jsx("button",{className:`template-mode-btn ${m==="editor"?"active":""}`,type:"button",onClick:()=>d("editor"),children:"Editor"}):null,r.jsx("button",{className:`template-mode-btn ${m==="source"?"active":""}`,type:"button",onClick:()=>{pe(),d("source")},children:"HTML/Text"}),r.jsx("button",{className:`template-mode-btn ${m==="preview"?"active":""}`,type:"button",onClick:()=>{pe(),d("preview")},children:"Vorschau"})]}),children:[c.content_format==="html"&&m==="editor"?r.jsxs(r.Fragment,{children:[r.jsxs("div",{className:"template-toolbar",children:[r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>ee("formatBlock","H1"),children:"H1"}),r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>ee("formatBlock","H2"),children:"H2"}),r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>ee("formatBlock","H3"),children:"H3"}),r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>ee("bold"),children:"Fett"}),r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>ee("italic"),children:"Kursiv"}),r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>ee("insertUnorderedList"),children:"Liste"}),r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>ee("insertOrderedList"),children:"Nummernliste"}),r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>ee("insertHorizontalRule"),children:"Trennlinie"}),r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>le('
Spalte 1Spalte 2
'),children:"Tabelle"}),r.jsx("button",{type:"button",onMouseDown:X,onClick:()=>ee("removeFormat"),children:"Format löschen"})]}),r.jsx("div",{ref:S,className:"template-editor",contentEditable:!0,suppressContentEditableWarning:!0,onInput:()=>{var q;Z("content",((q=S.current)==null?void 0:q.innerHTML)||""),J()},onKeyUp:J,onMouseUp:J,onFocus:J})]}):null,m==="source"?r.jsx("textarea",{ref:P,className:"textarea template-source",value:c.content,onChange:q=>Z("content",q.target.value)}):null,m==="preview"?r.jsx("div",{className:"template-preview",dangerouslySetInnerHTML:{__html:$}}):null,r.jsxs("div",{className:"row-actions",style:{marginTop:14},children:[r.jsx("button",{className:"btn",type:"submit",children:o?"Vorlage speichern":"Vorlage anlegen"}),o?r.jsx("button",{className:"btn danger template-delete-btn",type:"button",onClick:()=>Se({id:o,title:c.title}),children:"Vorlage löschen"}):null]})]})]})]})]})}function dC(e){if(!e)return"";const t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString("de-DE")}function gp(e){const t=String(e.entity_type||"").toLowerCase(),n=e.entity_id;switch(t){case"case":case"br_case":case"vorgang":return{label:n?`Vorgang ${n}`:"Vorgang",path:n?`/cases?open=${n}`:"/cases"};case"decision":case"beschluss":return{label:n?`Beschluss ${n}`:"Beschluss",path:n?`/decisions?open=${n}`:"/decisions"};case"meeting":case"sitzung":return{label:n?`Sitzung ${n}`:"Sitzung",path:n?`/meetings?open=${n}`:"/meetings"};case"document":case"dokument":return{label:n?`Dokument ${n}`:"Dokument",path:n?`/documents?open=${n}`:"/documents"};default:return{label:e.entity_type&&n?`${e.entity_type} ${n}`:"—",path:null}}}function fC(){const e=$o(),[t,n]=f.useState([]),[i,s]=f.useState(""),[a,o]=f.useState(!0),[l,c]=f.useState(!0),[u,m]=f.useState(!1),[d,h]=f.useState(null),[x,g]=f.useState(""),[p,w]=f.useState("");async function b({nextQuery:N=i,nextOnlyMine:_=a,nextUnreadFirst:S=l}={}){w(""),m(!0);try{const P=new URLSearchParams({only_mine:_?"true":"false",unread_first:S?"true":"false"});String(N||"").trim()&&P.set("q",N.trim());const D=await O.get(`/notifications?${P.toString()}`);n(Array.isArray(D)?D:[])}catch(P){w(P.message||"Benachrichtigungen konnten nicht geladen werden.")}finally{m(!1)}}f.useEffect(()=>{b()},[a,l]),f.useEffect(()=>{const N=window.setTimeout(()=>{b({nextQuery:i})},250);return()=>window.clearTimeout(N)},[i]);async function v(N){h(N),w(""),g("");try{await O.post(`/notifications/${N}/read`,{}),await b(),g("Benachrichtigung als gelesen markiert")}catch(_){w(_.message||"Benachrichtigung konnte nicht aktualisiert werden.")}finally{h(null)}}async function y(){h("all"),w(""),g("");try{const N=await O.post("/notifications/mark-all-read",{});await b(),g(`${(N==null?void 0:N.count)??0} Benachrichtigungen als gelesen markiert`)}catch(N){w(N.message||"Benachrichtigungen konnten nicht aktualisiert werden.")}finally{h(null)}}async function j(N){const _=gp(N);if(!N.is_read)try{await O.post(`/notifications/${N.id}/read`,{})}catch{}await b(),_.path&&e(_.path)}async function k(N){h(N),w(""),g("");try{await O.delete(`/notifications/${N}`),await b(),g("Benachrichtigung gelöscht")}catch(_){w(_.message||"Benachrichtigung konnte nicht gelöscht werden.")}finally{h(null)}}return r.jsxs(de,{title:"Benachrichtigungen",subtitle:"Filterbar nach eigenen Benachrichtigungen. Ungelesene können bevorzugt oben angezeigt und direkt geöffnet werden.",actions:r.jsxs("div",{style:{display:"flex",gap:12,alignItems:"center",flexWrap:"wrap"},children:[r.jsxs("label",{style:{display:"flex",gap:8,alignItems:"center"},children:[r.jsx("input",{type:"checkbox",checked:a,onChange:N=>o(N.target.checked)}),r.jsx("span",{children:"Nur meine Benachrichtigungen"})]}),r.jsxs("label",{style:{display:"flex",gap:8,alignItems:"center"},children:[r.jsx("input",{type:"checkbox",checked:l,onChange:N=>c(N.target.checked)}),r.jsx("span",{children:"Ungelesen zuerst"})]}),r.jsx("input",{className:"input search",placeholder:"Suchen...",value:i,onChange:N=>s(N.target.value)}),r.jsx("button",{className:"btn secondary",type:"button",onClick:y,disabled:d==="all",children:"Alle als gelesen"})]}),children:[p?r.jsx("div",{className:"alert error",children:p}):null,x?r.jsx("div",{className:"alert success",children:x}):null,r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Typ"}),r.jsx("th",{children:"Ziel"}),r.jsx("th",{children:"Benutzer"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Zeit"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[t.map(N=>{const _=gp(N);return r.jsxs("tr",{style:{fontWeight:N.is_read?"normal":600,opacity:N.is_read?.8:1},children:[r.jsx("td",{children:N.id}),r.jsx("td",{children:N.title}),r.jsx("td",{children:N.notification_type}),r.jsx("td",{children:_.path?r.jsx("button",{type:"button",className:"mini-btn",onClick:()=>j(N),children:_.label}):_.label}),r.jsx("td",{children:N.user_name||N.user_id||""}),r.jsx("td",{children:N.is_read?"gelesen":"ungelesen"}),r.jsx("td",{children:dC(N.created_at)}),r.jsxs("td",{className:"row-actions",children:[N.is_read?null:r.jsx("button",{className:"mini-btn",type:"button",disabled:d===N.id,onClick:()=>v(N.id),children:"Als gelesen"}),_.path?r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>j(N),children:"Öffnen"}):null,r.jsx("button",{className:"mini-btn danger",type:"button",disabled:d===N.id,onClick:()=>k(N.id),children:"Löschen"})]})]},N.id)}),!u&&!t.length?r.jsx("tr",{children:r.jsx("td",{colSpan:8,children:"Keine Benachrichtigungen gefunden"})}):null]})]})})]})}function Mi(e){const t=new Uint8Array(e);let n="";for(const s of t)n+=String.fromCharCode(s);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function zu(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),n=(4-t.length%4)%4,i=t.padEnd(t.length+n,"="),s=atob(i),a=new ArrayBuffer(s.length),o=new Uint8Array(a);for(let l=0;le};function hC(e){const{id:t}=e;return{...e,id:zu(t),transports:e.transports}}function gC(e){return e==="localhost"||/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(e)}class Bt extends Error{constructor({message:t,code:n,cause:i,name:s}){super(t,{cause:i}),Object.defineProperty(this,"code",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),this.name=s??i.name,this.code=n}}function xC({error:e,options:t}){var i,s,a;const{publicKey:n}=t;if(!n)throw Error("options was missing required publicKey property");if(e.name==="AbortError"){if(t.signal instanceof AbortSignal)return new Bt({message:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if(e.name==="ConstraintError"){if(((i=n.authenticatorSelection)==null?void 0:i.requireResidentKey)===!0)return new Bt({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if(t.mediation==="conditional"&&((s=n.authenticatorSelection)==null?void 0:s.userVerification)==="required")return new Bt({message:"User verification was required during automatic registration but it could not be performed",code:"ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE",cause:e});if(((a=n.authenticatorSelection)==null?void 0:a.userVerification)==="required")return new Bt({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if(e.name==="InvalidStateError")return new Bt({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if(e.name==="NotAllowedError")return new Bt({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if(e.name==="NotSupportedError")return n.pubKeyCredParams.filter(l=>l.type==="public-key").length===0?new Bt({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new Bt({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if(e.name==="SecurityError"){const o=globalThis.location.hostname;if(gC(o)){if(n.rp.id!==o)return new Bt({message:`The RP ID "${n.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else return new Bt({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e})}else if(e.name==="TypeError"){if(n.user.id.byteLength<1||n.user.id.byteLength>64)return new Bt({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if(e.name==="UnknownError")return new Bt({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}class vC{constructor(){Object.defineProperty(this,"controller",{enumerable:!0,configurable:!0,writable:!0,value:void 0})}createNewAbortSignal(){if(this.controller){const n=new Error("Cancelling existing WebAuthn API call for new one");n.name="AbortError",this.controller.abort(n)}const t=new AbortController;return this.controller=t,t.signal}cancelCeremony(){if(this.controller){const t=new Error("Manually cancelling existing WebAuthn API call");t.name="AbortError",this.controller.abort(t),this.controller=void 0}}}const bC=new vC,yC=["cross-platform","platform"];function jC(e){if(e&&!(yC.indexOf(e)<0))return e}async function _C(e){var g;!e.optionsJSON&&e.challenge&&(console.warn("startRegistration() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:t,useAutoRegister:n=!1}=e;if(!mC())throw new Error("WebAuthn is not supported in this browser");const i={...t,challenge:zu(t.challenge),user:{...t.user,id:zu(t.user.id)},excludeCredentials:(g=t.excludeCredentials)==null?void 0:g.map(hC)},s={};n&&(s.mediation="conditional"),s.publicKey=i,s.signal=bC.createNewAbortSignal();let a;try{a=await navigator.credentials.create(s)}catch(p){throw xC({error:p,options:s})}if(!a)throw new Error("Registration was not completed");const{id:o,rawId:l,response:c,type:u}=a;let m;typeof c.getTransports=="function"&&(m=c.getTransports());let d;if(typeof c.getPublicKeyAlgorithm=="function")try{d=c.getPublicKeyAlgorithm()}catch(p){rc("getPublicKeyAlgorithm()",p)}let h;if(typeof c.getPublicKey=="function")try{const p=c.getPublicKey();p!==null&&(h=Mi(p))}catch(p){rc("getPublicKey()",p)}let x;if(typeof c.getAuthenticatorData=="function")try{x=Mi(c.getAuthenticatorData())}catch(p){rc("getAuthenticatorData()",p)}return{id:o,rawId:Mi(l),response:{attestationObject:Mi(c.attestationObject),clientDataJSON:Mi(c.clientDataJSON),transports:m,publicKeyAlgorithm:d,publicKey:h,authenticatorData:x},type:u,clientExtensionResults:a.getClientExtensionResults(),authenticatorAttachment:jC(a.authenticatorAttachment)}}function rc(e,t){console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${e}. You should report this error to them. +`,t)}async function wC(){return O.post("/auth/mfa/totp/setup",{})}async function kC(e){return O.post("/auth/mfa/totp/verify",{token:e})}async function SC(){const e=await O.post("/auth/mfa/webauthn/register/options",{}),t=await _C(e);return O.post("/auth/mfa/webauthn/register/verify",{credential:t})}function NC(){Vo(""),localStorage.removeItem("br_user"),localStorage.removeItem("br_access_token"),window.__br_access_token=""}function CC(){const[e,t]=f.useState(""),[n,i]=f.useState(""),[s,a]=f.useState(""),[o,l]=f.useState(""),[c,u]=f.useState("");async function m(){u(""),l("");try{const x=await wC();t(x.qrCodeDataUrl),i(x.secretBase32),l("TOTP-Enrollment gestartet")}catch(x){u(x.message)}}async function d(x){x.preventDefault(),u(""),l("");try{await kC(s),l("TOTP erfolgreich aktiviert")}catch(g){u(g.message)}}async function h(){u(""),l("");try{await SC(),l("Passkey erfolgreich registriert")}catch(x){u(x.message)}}return r.jsx("div",{className:"stack",children:r.jsxs(de,{title:"MFA & Passkeys",subtitle:"WebAuthn/Passkey und TOTP vollständig am Backend verdrahtet",children:[c?r.jsx("div",{className:"alert error",children:c}):null,o?r.jsx("div",{className:"alert success",children:o}):null,r.jsxs("div",{className:"security-grid",children:[r.jsxs("div",{className:"glass pane",children:[r.jsx("h3",{children:"TOTP einrichten"}),r.jsx("p",{children:"QR-Code mit Authenticator-App scannen und Einmalcode verifizieren."}),r.jsx("button",{className:"btn",onClick:m,children:"TOTP-Setup starten"}),e?r.jsx("img",{src:e,alt:"TOTP QR",className:"qr"}):null,n?r.jsx("code",{children:n}):null,r.jsxs("form",{className:"form-grid",onSubmit:d,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Code"}),r.jsx("input",{className:"input",value:s,onChange:x=>a(x.target.value)})]}),r.jsx("button",{className:"btn",type:"submit",children:"TOTP bestätigen"})]})]}),r.jsxs("div",{className:"glass pane",children:[r.jsx("h3",{children:"Passkey registrieren"}),r.jsx("p",{children:"Plattform-Passkey oder Sicherheitsschlüssel als starken zweiten Faktor hinzufügen."}),r.jsx("button",{className:"btn",onClick:h,children:"Passkey registrieren"}),r.jsxs("ul",{className:"bullet-list",children:[r.jsx("li",{children:"Login über WebAuthn-Challenge"}),r.jsx("li",{children:"Zählerprüfung gegen geklonte Authenticatoren"}),r.jsx("li",{children:"Serverseitige Challenge-Ablaufsteuerung"})]})]})]})]})})}function zC(){const[e,t]=f.useState([]);return f.useEffect(()=>{O.get("/audit").then(t).catch(()=>t([]))},[]),r.jsx(de,{title:"Audit",subtitle:"Unveränderbarer Prüfpfad für sicherheitsrelevante Aktionen",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Aktion"}),r.jsx("th",{children:"Objekt"}),r.jsx("th",{children:"Objekt-ID"}),r.jsx("th",{children:"Zeitpunkt"})]})}),r.jsx("tbody",{children:e.map(n=>r.jsxs("tr",{children:[r.jsx("td",{children:String(n.id??"")}),r.jsx("td",{children:String(n.action??"")}),r.jsx("td",{children:String(n.entity_type??"")}),r.jsx("td",{children:String(n.entity_id??"")}),r.jsx("td",{children:String(n.created_at??"")})]},n.id))})]})})})}const BC="http://localhost:8080/api",TC=[{value:"sehr_hoch",label:"Sehr hoch"},{value:"hoch",label:"Hoch"},{value:"mittel",label:"Mittel"},{value:"niedrig",label:"Niedrig"}],Uv=[{value:"intern",label:"Intern"},{value:"vertraulich",label:"Vertraulich"},{value:"streng_vertraulich",label:"Streng vertraulich"}];function dr(e,t){const n=new Date(e);return n.setDate(n.getDate()+t),n}function Kr(e){const t=new Date(e),n=(t.getDay()+6)%7;return t.setDate(t.getDate()-n),t.setHours(0,0,0,0),t}function Bu(e){const t=dr(Kr(e),6);return t.setHours(23,59,59,999),t}function xp(e){return new Date(e.getFullYear(),e.getMonth(),1,0,0,0,0)}function PC(e){return new Date(e.getFullYear(),e.getMonth()+1,0,23,59,59,999)}function Tu(e,t){return e.getFullYear()===t.getFullYear()&&e.getMonth()===t.getMonth()&&e.getDate()===t.getDate()}function EC(e){return e.toISOString().slice(0,10)}function Dn(e){return EC(e)}function _o(e){if(!e)return"";const t=new Date(e);return Number.isNaN(t.getTime())?String(e).slice(0,16):new Date(t.getTime()-t.getTimezoneOffset()*6e4).toISOString().slice(0,16)}function vp(e,t,n=""){const i=new Date(e);i.setHours(9,0,0,0);const s=new Date(e);return s.setHours(10,0,0,0),{title:"",description:"",starts_at:_o(i.toISOString()),ends_at:_o(s.toISOString()),all_day:!1,location:"",status:"geplant",priority:"mittel",visibility:"intern",owner_user_id:t!=null&&t.id?String(t.id):"",committee_id:n?String(n):"",source_name:""}}function bp(e){return`calendar-badge ${e}`}function Pu(e){if(e.all_day)return"Ganztägig";const t=new Date(e.starts_at);return Number.isNaN(t.getTime())?"":t.toLocaleTimeString("de-DE",{hour:"2-digit",minute:"2-digit"})}function ic(e,t){return e.filter(n=>Tu(new Date(n.starts_at),t))}function DC(e,t,n){if(e==="month")return t.toLocaleDateString("de-DE",{month:"long",year:"numeric"});if(e==="week"){const i=Kr(t),s=Bu(t);return`${i.toLocaleDateString("de-DE")} – ${s.toLocaleDateString("de-DE")}`}return`${n.from||""} – ${n.to||""}`}function AC({open:e,selectedEvent:t,eventForm:n,setEventForm:i,meta:s,currentCommitteeUsers:a,error:o,onClose:l,onSubmit:c,onDelete:u}){if(!e)return null;const m=(t==null?void 0:t.event_type)==="calendar";return r.jsx("div",{className:"modal-backdrop",onClick:l,children:r.jsxs("div",{className:"modal-panel",onClick:d=>d.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:m?"Termin bearbeiten":"Neuen Termin anlegen"}),r.jsx("p",{className:"modal-subtitle",children:"Eigene Termine werden in den globalen Kalender übernommen und können nach Benutzer oder Ausschuss gefiltert werden."})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:l,"aria-label":"Schließen",children:"×"})]}),o?r.jsx("div",{className:"alert error",children:o}):null,r.jsxs("form",{className:"form-grid",onSubmit:c,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Titel"}),r.jsx("input",{className:"input",value:n.title,onChange:d=>i(h=>({...h,title:d.target.value}))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"input textarea",value:n.description,onChange:d=>i(h=>({...h,description:d.target.value}))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Beginn"}),r.jsx("input",{className:"input",type:"datetime-local",value:n.starts_at,onChange:d=>i(h=>({...h,starts_at:d.target.value}))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Ende"}),r.jsx("input",{className:"input",type:"datetime-local",value:n.ends_at,disabled:n.all_day,onChange:d=>i(h=>({...h,ends_at:d.target.value}))})]}),r.jsxs("label",{style:{display:"flex",gap:10,alignItems:"center",marginTop:26},children:[r.jsx("input",{type:"checkbox",checked:!!n.all_day,onChange:d=>i(h=>({...h,all_day:d.target.checked}))}),r.jsx("span",{children:"Ganztägig"})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Ort"}),r.jsx("input",{className:"input",value:n.location,onChange:d=>i(h=>({...h,location:d.target.value}))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Benutzer"}),r.jsxs("select",{className:"input",value:n.owner_user_id,onChange:d=>i(h=>({...h,owner_user_id:d.target.value})),children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),a.map(d=>r.jsx("option",{value:d.id,children:d.full_name},d.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Ausschuss"}),r.jsxs("select",{className:"input",value:n.committee_id,onChange:d=>i(h=>({...h,committee_id:d.target.value,owner_user_id:h.owner_user_id})),children:[r.jsx("option",{value:"",children:"Kein Ausschuss"}),s.committees.map(d=>r.jsx("option",{value:d.id,children:d.name},d.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Priorität"}),r.jsx("select",{className:"input",value:n.priority,onChange:d=>i(h=>({...h,priority:d.target.value})),children:TC.map(d=>r.jsx("option",{value:d.value,children:d.label},d.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Sichtbarkeit"}),r.jsx("select",{className:"input",value:n.visibility,onChange:d=>i(h=>({...h,visibility:d.target.value})),children:Uv.map(d=>r.jsx("option",{value:d.value,children:d.label},d.value))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Quelle/Label"}),r.jsx("input",{className:"input",value:n.source_name,onChange:d=>i(h=>({...h,source_name:d.target.value})),placeholder:"z. B. Persönlich, Schulung, Betriebsrat"})]}),r.jsxs("div",{style:{display:"flex",justifyContent:"flex-end",gap:10,gridColumn:"1 / -1"},children:[m?r.jsx("button",{className:"btn secondary",type:"button",onClick:u,children:"Löschen"}):null,r.jsx("button",{className:"btn secondary",type:"button",onClick:l,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",children:m?"Speichern":"Termin anlegen"})]})]})]})})}function RC({open:e,selectedDate:t,selectedEvent:n,selectedDateEvents:i,onClose:s,onPickEvent:a,onNewEvent:o,onEditEvent:l,onDeleteEvent:c}){if(!e)return null;const u=(n==null?void 0:n.event_type)==="calendar"?n:i.find(m=>m.event_type==="calendar")||null;return r.jsx("div",{className:"modal-backdrop",onClick:s,children:r.jsxs("div",{className:"modal-panel",onClick:m=>m.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:"Tagesdetails"}),r.jsx("p",{className:"modal-subtitle",children:t.toLocaleDateString("de-DE",{weekday:"long",day:"2-digit",month:"2-digit",year:"numeric"})})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:s,"aria-label":"Schließen",children:"×"})]}),r.jsxs("div",{style:{display:"flex",justifyContent:"space-between",gap:10,marginBottom:12,flexWrap:"wrap"},children:[r.jsx("button",{className:"btn",type:"button",onClick:o,children:"Neuer Termin"}),r.jsxs("div",{style:{display:"flex",gap:10,flexWrap:"wrap"},children:[r.jsx("button",{className:"btn secondary",type:"button",disabled:!u,onClick:()=>u&&l(u),children:"Bearbeiten"}),r.jsx("button",{className:"btn secondary",type:"button",disabled:!u,onClick:()=>u&&c(u),children:"Löschen"})]})]}),r.jsx("div",{className:"calendar-detail-list",children:i.length?i.map(m=>r.jsxs("button",{type:"button",className:`calendar-detail-item ${(n==null?void 0:n.id)===m.id&&(n==null?void 0:n.event_type)===m.event_type?"selected":""}`,onClick:()=>a(m),children:[r.jsxs("div",{children:[r.jsx("strong",{children:m.title}),r.jsxs("div",{className:"field-hint",children:[m.event_type," · ",Pu(m)||"Ohne Uhrzeit"]})]}),r.jsx("span",{children:m.committee_name||m.reference||""})]},`${m.event_type}-${m.id}`)):r.jsx("div",{className:"field-hint",children:"Für diesen Tag sind keine Einträge vorhanden."})}),n?r.jsxs("div",{className:"pane glass",style:{marginTop:14},children:[r.jsx("h3",{style:{marginTop:0},children:n.title}),r.jsxs("p",{className:"field-hint",children:[n.event_type," ·"," ",n.all_day?"Ganztägig":new Date(n.starts_at).toLocaleString("de-DE",{day:"2-digit",month:"2-digit",year:"numeric",hour:"2-digit",minute:"2-digit"})]}),r.jsx("p",{children:n.description||"Kein weiterer Beschreibungstext."})]}):null]})})}function OC({open:e,syncForm:t,setSyncForm:n,onClose:i,onExportIcs:s,onImportIcs:a,onSyncProvider:o}){return e?r.jsx("div",{className:"modal-backdrop",onClick:i,children:r.jsxs("div",{className:"modal-panel",onClick:l=>l.stopPropagation(),children:[r.jsxs("div",{className:"modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{style:{margin:0},children:"Externe Verbindung"}),r.jsx("p",{className:"modal-subtitle",children:"iCal-Dateien exportieren oder importieren. Outlook- und Google-Sync laufen über öffentliche ICS-Links."})]}),r.jsx("button",{className:"modal-close",type:"button",onClick:i,"aria-label":"Schließen",children:"×"})]}),r.jsxs("div",{className:"form-grid",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Import-/Sync-Name"}),r.jsx("input",{className:"input",value:t.source_name,onChange:l=>n(c=>({...c,source_name:l.target.value})),placeholder:"z. B. Teamkalender"})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Sichtbarkeit"}),r.jsx("select",{className:"input",value:t.visibility,onChange:l=>n(c=>({...c,visibility:l.target.value})),children:Uv.map(l=>r.jsx("option",{value:l.value,children:l.label},l.value))})]}),r.jsx("div",{className:"row-actions",style:{gridColumn:"1 / -1"},children:r.jsx("button",{className:"btn",type:"button",onClick:s,children:"iCal exportieren"})}),r.jsxs("label",{children:[r.jsx("span",{children:"iCal-Datei importieren"}),r.jsx("input",{className:"input",type:"file",accept:".ics,text/calendar",onChange:l=>n(c=>{var u;return{...c,file:((u=l.target.files)==null?void 0:u[0])||null}})})]}),r.jsx("button",{className:"btn secondary",type:"button",onClick:a,children:"iCal importieren"}),r.jsxs("label",{children:[r.jsx("span",{children:"Outlook ICS-URL"}),r.jsx("input",{className:"input",value:t.outlookUrl,onChange:l=>n(c=>({...c,outlookUrl:l.target.value})),placeholder:"https://outlook.live.com/.../calendar.ics"})]}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>o("outlook"),children:"Outlook synchronisieren"}),r.jsxs("label",{children:[r.jsx("span",{children:"Google ICS-URL"}),r.jsx("input",{className:"input",value:t.googleUrl,onChange:l=>n(c=>({...c,googleUrl:l.target.value})),placeholder:"https://calendar.google.com/calendar/ical/.../basic.ics"})]}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>o("google"),children:"Google synchronisieren"})]})]})}):null}function IC(){const[e,t]=f.useState({users:[],committees:[],currentUser:null}),[n,i]=f.useState({user_id:"",committee_id:""}),[s,a]=f.useState("month"),[o,l]=f.useState(new Date),[c,u]=f.useState({from:Dn(new Date),to:Dn(dr(new Date,31))}),[m,d]=f.useState(new Date),[h,x]=f.useState([]),[g,p]=f.useState(null),[w,b]=f.useState(vp(new Date,null)),[v,y]=f.useState({source_name:"",visibility:"intern",file:null,outlookUrl:"",googleUrl:""}),[j,k]=f.useState(""),[N,_]=f.useState(""),[S,P]=f.useState(""),[D,F]=f.useState(!1),[T,M]=f.useState(!1),[z,A]=f.useState(!1),[$,Z]=f.useState(!1),R=f.useMemo(()=>{if(s==="month"){const B=xp(o),U=PC(o);return{from:Dn(Kr(B)),to:Dn(Bu(U))}}return s==="week"?{from:Dn(Kr(o)),to:Dn(Bu(o))}:c},[s,o,c]),K=f.useMemo(()=>DC(s,o,c),[s,o,c]),X=f.useMemo(()=>{const B=Kr(xp(o));return Array.from({length:42},(U,C)=>dr(B,C))},[o]),ae=f.useMemo(()=>{const B=Kr(o);return Array.from({length:7},(U,C)=>dr(B,C))},[o]),J=f.useMemo(()=>ic(h,m),[h,m]);async function ue(){const B=await O.get("/calendar/meta");t(B||{users:[],committees:[],currentUser:null}),b(U=>{var C;return{...U,owner_user_id:U.owner_user_id||String(((C=B==null?void 0:B.currentUser)==null?void 0:C.id)||"")}})}async function pe(B=R,U=n){F(!0),_("");try{const C=new URLSearchParams;B.from&&C.set("from",B.from),B.to&&C.set("to",B.to),U.user_id&&C.set("user_id",U.user_id),U.committee_id&&C.set("committee_id",U.committee_id);const G=await O.get(`/calendar/events?${C.toString()}`);x(Array.isArray(G)?G:[])}catch(C){_(C.message)}finally{F(!1)}}f.useEffect(()=>{ue()},[]),f.useEffect(()=>{pe(R,n)},[R.from,R.to,n.user_id,n.committee_id]);function ee(B=m){p(null),b(vp(B,e.currentUser,n.committee_id))}function le(){M(!1),ee(m),P("")}function ge(B){d(B),(!g||g.event_type!=="calendar")&&ee(B)}function Y(B){var U;p(B),d(new Date(B.starts_at)),b({title:B.title||"",description:B.description||"",starts_at:_o(B.starts_at),ends_at:_o(B.ends_at),all_day:!!B.all_day,location:B.location||"",status:B.status||"geplant",priority:B.priority||"mittel",visibility:B.visibility||"intern",owner_user_id:B.owner_user_id?String(B.owner_user_id):String(((U=e.currentUser)==null?void 0:U.id)||""),committee_id:B.committee_id?String(B.committee_id):"",source_name:B.source_name||""})}function fe(B){p(B),d(new Date(B.starts_at))}function Se(B=m){ee(B),P(""),M(!0)}function he(B){!B||B.event_type!=="calendar"||(Y(B),P(""),M(!0))}async function q(B){var U;B.preventDefault(),P(""),k("");try{const C={title:w.title.trim(),description:w.description.trim()||null,starts_at:w.starts_at,ends_at:w.all_day?null:w.ends_at||null,all_day:!!w.all_day,location:w.location.trim()||null,status:w.status.trim()||"geplant",priority:w.priority,visibility:w.visibility,owner_user_id:w.owner_user_id?Number(w.owner_user_id):(U=e.currentUser)==null?void 0:U.id,committee_id:w.committee_id?Number(w.committee_id):null,source_name:w.source_name.trim()||null};(g==null?void 0:g.event_type)==="calendar"?(await O.put(`/calendar/events/${g.id}`,C),k("Termin gespeichert")):(await O.post("/calendar/events",C),k("Termin angelegt")),await pe(),le()}catch(C){P(C.message)}}async function re(B){if(!(!B||B.event_type!=="calendar")){P(""),_(""),k("");try{await O.delete(`/calendar/events/${B.id}`),await pe(),(g==null?void 0:g.id)===B.id&&(g==null?void 0:g.event_type)==="calendar"&&p(null),k("Termin gelöscht")}catch(U){P(U.message),_(U.message)}}}async function me(){!g||g.event_type!=="calendar"||(await re(g),le())}async function V(){_(""),k("");try{const B=new URLSearchParams;R.from&&B.set("from",R.from),R.to&&B.set("to",R.to),n.user_id&&B.set("user_id",n.user_id),n.committee_id&&B.set("committee_id",n.committee_id);const U=window.__br_access_token||localStorage.getItem("br_access_token")||"",C=await fetch(`${BC}/calendar/export.ics?${B.toString()}`,{headers:U?{Authorization:`Bearer ${U}`}:{},credentials:"include"});if(!C.ok){const Pn=await C.json().catch(()=>({}));throw new Error(Pn.error||"Export fehlgeschlagen")}const G=await C.blob(),ne=URL.createObjectURL(G),ye=document.createElement("a");ye.href=ne,ye.download="br-office-calendar.ics",document.body.appendChild(ye),ye.click(),ye.remove(),URL.revokeObjectURL(ne),k("iCal-Export heruntergeladen")}catch(B){_(B.message)}}async function te(){var B;_(""),k("");try{const U=new FormData;v.file&&U.append("file",v.file),U.append("owner_user_id",w.owner_user_id||String(((B=e.currentUser)==null?void 0:B.id)||"")),U.append("committee_id",w.committee_id||n.committee_id||""),U.append("source_name",v.source_name||"ICS-Import"),U.append("visibility",v.visibility);const C=await O.post("/calendar/import/ics",U);await pe(),k(`ICS importiert: ${C.imported} neu, ${C.updated} aktualisiert, ${C.skipped} übersprungen`),y(G=>({...G,file:null}))}catch(U){_(U.message)}}async function Ne(B){var U;_(""),k("");try{const C=B==="outlook"?v.outlookUrl:v.googleUrl,G=await O.post(`/calendar/sync/${B}`,{url:C,owner_user_id:w.owner_user_id?Number(w.owner_user_id):(U=e.currentUser)==null?void 0:U.id,committee_id:w.committee_id?Number(w.committee_id):n.committee_id?Number(n.committee_id):null,source_name:v.source_name||B,visibility:v.visibility});await pe(),k(`${B==="outlook"?"Outlook":"Google"}-Sync: ${G.imported} neu, ${G.updated} aktualisiert`)}catch(C){_(C.message)}}function Xe(B){s==="month"?l(U=>new Date(U.getFullYear(),U.getMonth()+B,1)):s==="week"?l(U=>dr(U,B*7)):u(U=>({from:Dn(dr(new Date(U.from),B*31)),to:Dn(dr(new Date(U.to),B*31))}))}const E=f.useMemo(()=>w.committee_id?e.users.filter(B=>String(B.committee_id||"")===String(w.committee_id)):e.users,[e.users,w.committee_id]);return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .calendar-page-single { + display: grid; + gap: 16px; + } + + .calendar-head-actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + } + + .calendar-range-label { + font-weight: 700; + font-size: 1rem; + margin-right: 8px; + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 1000; + } + + .modal-panel { + width: min(960px, 100%); + max-height: 92vh; + overflow: auto; + border-radius: 18px; + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.24); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + padding: 18px; + } + + .modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + } + + .modal-subtitle { + margin: 6px 0 0; + opacity: 0.75; + font-size: 0.95rem; + } + + .modal-close { + border: 0; + background: transparent; + font-size: 28px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + } + `}),r.jsx("div",{className:"calendar-page-single",children:r.jsxs(de,{title:"Kalender",subtitle:"Monats-, Wochen- und Agendaansicht wie in Outlook. Eigene Termine können direkt erfasst und zusammen mit Sitzungen, Fristen und Aufgaben angezeigt werden.",actions:r.jsxs("div",{className:"calendar-head-actions",children:[r.jsx("span",{className:"calendar-range-label",children:K}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>Xe(-1),children:"Zurück"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>l(new Date),children:"Heute"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>Xe(1),children:"Weiter"}),[["month","Monat"],["week","Woche"],["agenda","Agenda"]].map(([B,U])=>r.jsx("button",{type:"button",className:`mini-btn ${s===B?"active":""}`,onClick:()=>a(B),children:U},B)),r.jsx("button",{className:"btn",type:"button",onClick:()=>Se(m),children:"Neuer Termin"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>A(!0),children:"Tagesdetails"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:()=>Z(!0),children:"Externe Verbindung"})]}),children:[N?r.jsx("div",{className:"alert error",children:N}):null,j?r.jsx("div",{className:"alert success",children:j}):null,r.jsx("div",{className:"calendar-toolbar",children:r.jsxs("div",{className:"calendar-filter-grid",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Benutzer"}),r.jsxs("select",{className:"input",value:n.user_id,onChange:B=>i(U=>({...U,user_id:B.target.value})),children:[r.jsx("option",{value:"",children:"Alle"}),e.users.map(B=>r.jsx("option",{value:B.id,children:B.full_name},B.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Ausschuss"}),r.jsxs("select",{className:"input",value:n.committee_id,onChange:B=>i(U=>({...U,committee_id:B.target.value})),children:[r.jsx("option",{value:"",children:"Alle"}),e.committees.map(B=>r.jsx("option",{value:B.id,children:B.name},B.id))]})]}),s==="agenda"?r.jsxs(r.Fragment,{children:[r.jsxs("label",{children:[r.jsx("span",{children:"Von"}),r.jsx("input",{className:"input",type:"date",value:c.from,onChange:B=>u(U=>({...U,from:B.target.value}))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Bis"}),r.jsx("input",{className:"input",type:"date",value:c.to,onChange:B=>u(U=>({...U,to:B.target.value}))})]})]}):null]})}),D?r.jsx("div",{className:"field-hint",children:"Kalender wird geladen…"}):null,s==="month"?r.jsxs("div",{className:"calendar-grid month",children:[["Mo","Di","Mi","Do","Fr","Sa","So"].map(B=>r.jsx("div",{className:"calendar-grid-head",children:B},B)),X.map(B=>{const U=ic(h,B),C=B.getMonth()===o.getMonth();return r.jsxs("button",{type:"button",className:`calendar-day-cell ${C?"":"muted"} ${Tu(B,m)?"selected":""}`,onClick:()=>ge(B),children:[r.jsxs("div",{className:"calendar-day-header",children:[r.jsx("strong",{children:B.getDate()}),r.jsx("span",{children:U.length||""})]}),r.jsxs("div",{className:"calendar-event-stack",children:[U.slice(0,4).map(G=>r.jsxs("span",{className:bp(G.event_type),onClick:ne=>{ne.stopPropagation(),fe(G),A(!0)},children:[Pu(G)," ",G.title]},`${G.event_type}-${G.id}`)),U.length>4?r.jsxs("span",{className:"calendar-more",children:["+",U.length-4," weitere"]}):null]})]},B.toISOString())})]}):null,s==="week"?r.jsx("div",{className:"calendar-grid week",children:ae.map(B=>{const U=ic(h,B);return r.jsxs("button",{type:"button",className:`calendar-week-column ${Tu(B,m)?"selected":""}`,onClick:()=>ge(B),children:[r.jsxs("div",{className:"calendar-day-header large",children:[r.jsx("strong",{children:B.toLocaleDateString("de-DE",{weekday:"short",day:"2-digit",month:"2-digit"})}),r.jsxs("span",{children:[U.length," Einträge"]})]}),r.jsx("div",{className:"calendar-event-stack",children:U.map(C=>r.jsxs("span",{className:bp(C.event_type),onClick:G=>{G.stopPropagation(),fe(C),A(!0)},children:[Pu(C)," ",C.title]},`${C.event_type}-${C.id}`))})]},B.toISOString())})}):null,s==="agenda"?r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Datum"}),r.jsx("th",{children:"Typ"}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Zeit"}),r.jsx("th",{children:"Ausschuss"}),r.jsx("th",{children:"Verantwortlich"}),r.jsx("th",{children:"Status"})]})}),r.jsx("tbody",{children:h.map(B=>r.jsxs("tr",{onClick:()=>{fe(B),A(!0)},children:[r.jsx("td",{children:new Date(B.starts_at).toLocaleDateString("de-DE")}),r.jsx("td",{children:B.event_type}),r.jsx("td",{children:B.title}),r.jsx("td",{children:B.all_day?"Ganztägig":new Date(B.starts_at).toLocaleString("de-DE",{hour:"2-digit",minute:"2-digit",day:"2-digit",month:"2-digit"})}),r.jsx("td",{children:B.committee_name||""}),r.jsx("td",{children:B.primary_user_name||B.owner_name||""}),r.jsx("td",{children:B.status||""})]},`${B.event_type}-${B.id}`))})]})}):null]})}),r.jsx(AC,{open:T,selectedEvent:g,eventForm:w,setEventForm:b,meta:e,currentCommitteeUsers:E,error:S,onClose:le,onSubmit:q,onDelete:me}),r.jsx(RC,{open:z,selectedDate:m,selectedEvent:g,selectedDateEvents:J,onClose:()=>A(!1),onPickEvent:fe,onNewEvent:()=>{A(!1),Se(m)},onEditEvent:B=>{A(!1),he(B)},onDeleteEvent:async B=>{await re(B),A(!1)}}),r.jsx(OC,{open:$,syncForm:v,setSyncForm:y,onClose:()=>Z(!1),onExportIcs:V,onImportIcs:te,onSyncProvider:Ne})]})}function LC(e){if(!e)return"";const t=new Date(e);return Number.isNaN(t.getTime())?String(e):t.toLocaleString("de-DE")}function MC(){const[e,t]=f.useState([]),[n,i]=f.useState(""),[s,a]=f.useState(""),[o,l]=f.useState("");async function c(){a("");try{const d=await O.get("/documents"),h=(Array.isArray(d)?d:[]).filter(x=>{const g=String(x.document_type||"").toLowerCase();return g.includes("bericht")||g.includes("report")||g.includes("protokoll")});t(h)}catch(d){a(d.message||"Reports konnten nicht geladen werden.")}}f.useEffect(()=>{c()},[]);const u=f.useMemo(()=>{const d=n.trim().toLowerCase();return d?e.filter(h=>JSON.stringify(h).toLowerCase().includes(d)):e},[e,n]);async function m(d){a(""),l("");try{await O.delete(`/documents/${d}`),await c(),l("Report gelöscht")}catch(h){a(h.message||"Report konnte nicht gelöscht werden.")}}return r.jsxs(de,{title:"Reports",subtitle:"Berichte, Protokolle und reportbezogene Dokumente im Überblick.",actions:r.jsx("input",{className:"input search",placeholder:"Reports durchsuchen...",value:n,onChange:d=>i(d.target.value)}),children:[s?r.jsx("div",{className:"alert error",children:s}):null,o?r.jsx("div",{className:"alert success",children:o}):null,r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"ID"}),r.jsx("th",{children:"Dokument-Nr."}),r.jsx("th",{children:"Titel"}),r.jsx("th",{children:"Art"}),r.jsx("th",{children:"Vertraulichkeit"}),r.jsx("th",{children:"Sitzung"}),r.jsx("th",{children:"Hochgeladen"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsx("tbody",{children:u.length?u.map(d=>r.jsxs("tr",{children:[r.jsx("td",{children:d.id}),r.jsx("td",{children:d.document_number||""}),r.jsx("td",{children:d.title||""}),r.jsx("td",{children:d.document_type||""}),r.jsx("td",{children:d.confidentiality_level||""}),r.jsx("td",{children:d.meeting_id||""}),r.jsx("td",{children:LC(d.uploaded_at)}),r.jsx("td",{className:"row-actions",children:r.jsx("button",{className:"mini-btn danger",type:"button",onClick:()=>m(d.id),children:"Löschen"})})]},d.id)):r.jsx("tr",{children:r.jsx("td",{colSpan:8,children:"Keine Reports gefunden"})})})]})})]})}const Wr=new Date().getFullYear();function sc(e=Wr){return{project_code:"",name:"",description:"",status:"aktiv",calculation_mode:"sum_only",committee_id:"",project_budget_amount:"0.00",annual_budget_amount:"0.00",annual_budget_year:String(e)}}function Eu(){return{description:"",tax_code_id:"",tax_code_key:"",tax_rate_percent:"0",net_amount:"0.00"}}function yp(e=""){return{project_id:e,expense_date:new Date().toISOString().slice(0,10),title:"",vendor:"",currency:"EUR",payment_status:"offen",notes:"",lines:[Eu()]}}function Pt(e){const t=Number(e||0);return Number.isFinite(t)?t:0}function Ye(e,t="EUR"){return new Intl.NumberFormat("de-DE",{style:"currency",currency:t}).format(Pt(e))}function ac(e){return`${new Intl.NumberFormat("de-DE",{maximumFractionDigits:3}).format(Pt(e))} %`}function Hv(e){return e?String(e).slice(0,10):""}function FC(e){return e==="project_budget"?"Projektbudget":e==="annual_budget"?"Jahresbudget":"Nur Summe"}function jp(e){return{aktiv:"Aktiv",pausiert:"Pausiert",abgeschlossen:"Abgeschlossen",archiviert:"Archiviert",geplant:"Geplant",offen:"Offen",bezahlt:"Bezahlt",storniert:"Storniert"}[e]||e}function $C(e){return new RegExp("^FIN-\\d{4}-\\d+$").test(String(e||"").trim())}function qC(e,t){return{project_code:e.project_code||"",name:e.name||"",description:e.description||"",status:e.status||"aktiv",calculation_mode:e.calculation_mode||"sum_only",committee_id:e.committee_id||"",project_budget_amount:String(e.project_budget_amount||"0.00"),annual_budget_amount:String(e.annual_budget_amount||"0.00"),annual_budget_year:String(e.annual_budget_year||t||Wr)}}function VC(e){return{description:e.cost_category||e.title||"Rechnungsposition",tax_code_id:"",tax_code_key:e.cost_category||"",tax_rate_percent:String(e.tax_rate_percent||"0"),net_amount:String(e.net_amount||"0.00")}}function UC(e){const t=Array.isArray(e.lines)&&e.lines.length?e.lines.map(n=>({id:n.id||void 0,description:n.description||"",tax_code_id:n.tax_code_id||"",tax_code_key:n.tax_code_key||"",tax_rate_percent:String(n.tax_rate_percent||"0"),net_amount:String(n.net_amount||"0.00")})):[VC(e)];return{project_id:e.project_id||"",expense_date:Hv(e.expense_date),title:e.title||"",vendor:e.vendor||"",currency:e.currency||"EUR",payment_status:e.payment_status||"offen",notes:e.notes||"",lines:t}}function HC(e,t){return(t||[]).find(n=>String(n.id)===String(e.tax_code_id))}function Kv(e,t){const n=HC(e,t),i=Pt((n==null?void 0:n.tax_rate_percent)??e.tax_rate_percent),s=Pt(e.net_amount),a=Math.round(s*i/100*100)/100,o=Math.round((s+a)*100)/100;return{net:s,rate:i,tax:a,gross:o}}function KC(e,t){return(e||[]).reduce((n,i)=>{const s=Kv(i,t);return n.net+=s.net,n.tax+=s.tax,n.gross+=s.gross,n},{net:0,tax:0,gross:0})}function WC(){const[e,t]=f.useState({tax_rates:[0,7,19],tax_codes:[],payment_statuses:[],calculation_modes:[],committees:[],years:[]}),[n,i]=f.useState([]),[s,a]=f.useState([]),[o,l]=f.useState(Wr),[c,u]=f.useState(""),[m,d]=f.useState(()=>sc(Wr)),[h,x]=f.useState(()=>yp("")),[g,p]=f.useState(""),[w,b]=f.useState(!1),[v,y]=f.useState(!1),[j,k]=f.useState(""),[N,_]=f.useState(null),[S,P]=f.useState(""),[D,F]=f.useState(""),[T,M]=f.useState(!1),z=e.tax_codes||[];async function A(){try{const E=await O.get("/finance/meta");t(E||{tax_rates:[0,7,19],tax_codes:[],payment_statuses:[],calculation_modes:[],committees:[],years:[]})}catch(E){F(E.message||"Finanz-Stammdaten konnten nicht geladen werden.")}}async function $(){M(!0),F("");try{const E=await O.get(`/finance/projects?year=${encodeURIComponent(o)}`),B=new URLSearchParams;o&&B.set("year",String(o)),c&&B.set("project_id",String(c));const U=await O.get(`/finance/expenses?${B.toString()}`);i(E||[]),a(U||[])}catch(E){F(E.message||"Finanzdaten konnten nicht geladen werden.")}finally{M(!1)}}f.useEffect(()=>{A()},[]),f.useEffect(()=>{$()},[o,c]);const Z=f.useMemo(()=>{const E=new Set([Wr,Number(o),...(e.years||[]).map(Number)]);return Array.from(E).filter(Boolean).sort((B,U)=>U-B)},[e.years,o]),R=f.useMemo(()=>n.find(E=>String(E.id)===String(c)),[n,c]),K=f.useMemo(()=>s.reduce((E,B)=>(B.payment_status==="storniert"||(E.net+=Pt(B.net_amount),E.tax+=Pt(B.tax_amount),E.gross+=Pt(B.gross_amount)),E),{net:0,tax:0,gross:0}),[s]),X=f.useMemo(()=>n.reduce((E,B)=>(B.calculation_mode==="sum_only"||(E.budget+=Pt(B.active_budget_amount),E.spent+=Pt(B.active_spent_amount),E.remaining+=Pt(B.active_remaining_amount)),E),{budget:0,spent:0,remaining:0}),[n]),ae=f.useMemo(()=>KC(h.lines,z),[h.lines,z]);function J(E,B){d(U=>({...U,[E]:B}))}function ue(E,B){x(U=>({...U,[E]:B}))}function pe(E,B,U){x(C=>{const G=[...C.lines||[]];if(G[E]={...G[E],[B]:U},B==="tax_code_id"){const ne=z.find(ye=>String(ye.id)===String(U));G[E].tax_code_key=(ne==null?void 0:ne.code)||"",G[E].tax_rate_percent=String((ne==null?void 0:ne.tax_rate_percent)||0),!G[E].description&&(ne!=null&&ne.label)&&(G[E].description=ne.label)}return{...C,lines:G}})}function ee(){x(E=>({...E,lines:[...E.lines||[],Eu()]}))}function le(E){x(B=>{const U=(B.lines||[]).filter((C,G)=>G!==E);return{...B,lines:U.length?U:[Eu()]}})}async function ge(){g||m.project_code&&!$C(m.project_code)||await Y(m.annual_budget_year||o)}async function Y(E=o){y(!0);try{const B=await O.get(`/finance/projects/next-code?year=${encodeURIComponent(E||Wr)}`),U=(B==null?void 0:B.project_code)||"";return U&&d(C=>({...C,project_code:U})),U}catch(B){return F(B.message||"Projekt-Nr. konnte nicht automatisch erzeugt werden."),""}finally{y(!1)}}async function fe(){p(""),d(sc(o)),b(!0),P(""),F(""),await Y(o)}function Se(E){p(String(E.id)),d(qC(E,o)),b(!0),P(""),F("")}function he(){b(!1),p(""),d(sc(o))}function q(E){k(String(E.id)),x(UC(E)),_(null),P(""),F("")}function re(){var E;k(""),x(yp(c||((E=n[0])==null?void 0:E.id)||"")),_(null)}async function me(E){E.preventDefault(),P(""),F("");try{const B={...m,committee_id:m.committee_id||null,project_budget_amount:m.project_budget_amount||0,annual_budget_amount:m.annual_budget_amount||0,annual_budget_year:Number(m.annual_budget_year||o)};g?(await O.put(`/finance/projects/${g}`,B),P("Finanzprojekt gespeichert.")):(await O.post("/finance/projects",B),P("Finanzprojekt angelegt.")),he(),await $(),await A()}catch(B){F(B.message||"Finanzprojekt konnte nicht gespeichert werden.")}}async function V(E){if(window.confirm(`Finanzprojekt „${E.name}“ archivieren?`)){P(""),F("");try{await O.delete(`/finance/projects/${E.id}`),P("Finanzprojekt archiviert."),String(c)===String(E.id)&&u(""),await $()}catch(B){F(B.message||"Finanzprojekt konnte nicht archiviert werden.")}}}async function te(E){if(!N)return;const B=new FormData;B.append("file",N),B.append("title",`Beleg: ${h.title}`),await O.post(`/finance/expenses/${E}/documents/upload`,B)}async function Ne(E){E.preventDefault(),P(""),F("");const B=(h.lines||[]).filter(U=>Pt(U.net_amount)>0||U.tax_code_id||U.description);if(!B.length||B.some(U=>!U.tax_code_id)){F("Bitte für jede Rechnungsposition eine MwSt.-/Kostenart auswählen.");return}try{const U={project_id:Number(h.project_id),expense_date:h.expense_date,title:h.title,vendor:h.vendor,currency:(h.currency||"EUR").toUpperCase(),payment_status:h.payment_status,notes:h.notes,lines:B.map(G=>({description:G.description,tax_code_id:Number(G.tax_code_id),tax_code_key:G.tax_code_key,net_amount:Number(G.net_amount||0)}))};let C;j?(C=await O.put(`/finance/expenses/${j}`,U),P(N?"Rechnung und Beleg gespeichert.":"Rechnung gespeichert.")):(C=await O.post("/finance/expenses",U),P(N?"Rechnung mit Beleg angelegt.":"Rechnung angelegt.")),await te(C.id||j),re(),await $(),await A()}catch(U){F(U.message||"Rechnung konnte nicht gespeichert werden.")}}async function Xe(E){if(window.confirm(`Rechnung „${E.title}“ löschen?`)){P(""),F("");try{await O.delete(`/finance/expenses/${E.id}`),P("Rechnung gelöscht."),await $()}catch(B){F(B.message||"Rechnung konnte nicht gelöscht werden.")}}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .finance-grid { display: grid; gap: 18px; } + .finance-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } + .finance-kpi { padding: 16px; border-radius: 20px; background: rgba(255,255,255,.78); border: 1px solid rgba(148,164,186,.22); } + .finance-kpi span { color: var(--muted); display: block; margin-bottom: 6px; } + .finance-kpi strong { font-size: 1.35rem; } + .finance-toolbar { display: flex; gap: 10px; align-items: end; flex-wrap: wrap; margin-bottom: 14px; } + .finance-toolbar label { min-width: 180px; } + .finance-inline { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .finance-mode { display: inline-block; padding: 4px 9px; border-radius: 999px; background: rgba(44,107,237,.10); color: #1d4ed8; font-size: .84rem; font-weight: 700; } + .finance-danger { color: var(--danger); font-weight: 700; } + .finance-ok { color: var(--success); font-weight: 700; } + .receipt-list { display: grid; gap: 6px; } + .receipt-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; } + .finance-code-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; } + .finance-budget-box { border: 1px solid rgba(44,107,237,.18); background: #f8fbff; border-radius: 18px; padding: 14px; display: grid; gap: 12px; } + .finance-budget-box h3 { margin: 0; font-size: 1rem; color: #0f172a; } + .finance-help { margin: 0; color: var(--muted); font-size: .9rem; line-height: 1.45; } + .invoice-lines { display: grid; gap: 12px; } + .invoice-line { border: 1px solid rgba(148,164,186,.26); background: #fff; border-radius: 18px; padding: 14px; display: grid; gap: 12px; } + .invoice-line-header { display: flex; justify-content: space-between; gap: 12px; align-items: center; } + .invoice-line-grid { display: grid; grid-template-columns: 1.2fr 1.4fr .7fr; gap: 12px; } + .invoice-line-sums { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } + .invoice-total-box { padding: 14px; border-radius: 18px; background: #f8fafc; border: 1px solid rgba(148,164,186,.28); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } + .invoice-total-box span, .invoice-line-sums span { color: var(--muted); display: block; font-size: .86rem; } + .invoice-total-box strong { font-size: 1.15rem; } + .expense-lines-list { margin: 6px 0 0; padding-left: 18px; color: var(--muted); } + .finance-modal-backdrop { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgba(15, 23, 42, 0.62); backdrop-filter: blur(2px); } + .finance-modal-panel { width: min(820px, 96vw); max-height: min(92vh, 920px); overflow: hidden; display: flex; flex-direction: column; border-radius: 22px; background: #ffffff; color: #0f172a; border: 1px solid rgba(15, 23, 42, 0.12); box-shadow: 0 30px 90px rgba(15, 23, 42, 0.34), 0 8px 24px rgba(15, 23, 42, 0.18); } + .finance-modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 24px 18px; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); border-bottom: 1px solid rgba(148, 164, 184, 0.28); } + .finance-modal-header h2 { margin: 0; color: #0f172a; font-size: 1.35rem; } + .finance-modal-subtitle { margin: 6px 0 0; color: #64748b; line-height: 1.45; } + .finance-modal-close { flex: 0 0 auto; width: 36px; height: 36px; border: 1px solid rgba(148, 164, 184, 0.35); border-radius: 999px; background: #ffffff; color: #0f172a; cursor: pointer; font-size: 1.3rem; line-height: 1; box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); } + .finance-modal-close:hover { background: #f1f5f9; } + .finance-modal-body { padding: 22px 24px; overflow: auto; background: #ffffff; } + .finance-modal-footer { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; padding-top: 4px; } + @media (max-width: 1100px) { .finance-kpis, .finance-inline, .invoice-line-grid, .invoice-line-sums, .invoice-total-box { grid-template-columns: 1fr; } .finance-modal-backdrop { padding: 12px; align-items: start; } .finance-modal-panel { width: 100%; max-height: calc(100vh - 24px); } } + `}),r.jsxs("div",{className:"finance-grid",children:[D?r.jsx("div",{className:"alert error",children:D}):null,S?r.jsx("div",{className:"alert success",children:S}):null,r.jsxs(de,{title:"Finanzen",subtitle:"Rechnungen mit mehreren Positionen erfassen, unterschiedliche MwSt.-Sätze verwenden und Kosten auf Projektebene überwachen.",actions:r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"btn",type:"button",onClick:fe,children:"Finanzprojekt anlegen"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:$,disabled:T,children:T?"Lade…":"Aktualisieren"})]}),children:[r.jsxs("div",{className:"finance-toolbar",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Auswertungsjahr"}),r.jsx("select",{className:"input",value:o,onChange:E=>l(Number(E.target.value)),children:Z.map(E=>r.jsx("option",{value:E,children:E},E))})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Projektfilter"}),r.jsxs("select",{className:"input",value:c,onChange:E=>u(E.target.value),children:[r.jsx("option",{value:"",children:"Alle Projekte"}),n.map(E=>r.jsxs("option",{value:E.id,children:[E.project_code?`${E.project_code} · `:"",E.name]},E.id))]})]})]}),r.jsxs("div",{className:"finance-kpis",children:[r.jsxs("div",{className:"finance-kpi",children:[r.jsx("span",{children:"Rechnungen netto"}),r.jsx("strong",{children:Ye(K.net)})]}),r.jsxs("div",{className:"finance-kpi",children:[r.jsx("span",{children:"Steueranteil"}),r.jsx("strong",{children:Ye(K.tax)})]}),r.jsxs("div",{className:"finance-kpi",children:[r.jsx("span",{children:"Rechnungen brutto"}),r.jsx("strong",{children:Ye(K.gross)})]}),r.jsxs("div",{className:"finance-kpi",children:[r.jsx("span",{children:"Budget Rest"}),r.jsx("strong",{className:X.remaining<0?"finance-danger":"finance-ok",children:Ye(X.remaining)})]})]})]}),r.jsx(de,{title:j?"Rechnung bearbeiten":"Rechnung erfassen",subtitle:"Eine Rechnung kann mehrere Positionen mit unterschiedlichen MwSt.-Sätzen enthalten. Die Sätze werden im Adminbereich gepflegt.",children:r.jsxs("form",{className:"form-grid",onSubmit:Ne,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Projekt"}),r.jsxs("select",{required:!0,className:"input",value:h.project_id,onChange:E=>ue("project_id",E.target.value),children:[r.jsx("option",{value:"",children:"Projekt wählen"}),n.map(E=>r.jsx("option",{value:E.id,children:E.name},E.id))]})]}),r.jsxs("div",{className:"finance-inline",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Rechnungsdatum"}),r.jsx("input",{className:"input",type:"date",required:!0,value:h.expense_date,onChange:E=>ue("expense_date",E.target.value)})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Status"}),r.jsxs("select",{className:"input",value:h.payment_status,onChange:E=>ue("payment_status",E.target.value),children:[r.jsx("option",{value:"geplant",children:"Geplant"}),r.jsx("option",{value:"offen",children:"Offen"}),r.jsx("option",{value:"bezahlt",children:"Bezahlt"}),r.jsx("option",{value:"storniert",children:"Storniert"})]})]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Rechnungstitel"}),r.jsx("input",{className:"input",required:!0,value:h.title,onChange:E=>ue("title",E.target.value)})]}),r.jsxs("div",{className:"finance-inline",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Lieferant / Empfänger"}),r.jsx("input",{className:"input",value:h.vendor,onChange:E=>ue("vendor",E.target.value)})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Währung"}),r.jsx("input",{className:"input",maxLength:"3",value:h.currency,onChange:E=>ue("currency",E.target.value.toUpperCase())})]})]}),r.jsxs("div",{className:"invoice-lines",children:[r.jsxs("div",{className:"invoice-line-header",children:[r.jsx("strong",{children:"Rechnungspositionen"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:ee,children:"Position hinzufügen"})]}),(h.lines||[]).map((E,B)=>{const U=Kv(E,z);return r.jsxs("div",{className:"invoice-line",children:[r.jsxs("div",{className:"invoice-line-header",children:[r.jsxs("strong",{children:["Position ",B+1]}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:()=>le(B),disabled:(h.lines||[]).length<=1,children:"Entfernen"})]}),r.jsxs("div",{className:"invoice-line-grid",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Bezeichnung"}),r.jsx("input",{className:"input",value:E.description,onChange:C=>pe(B,"description",C.target.value),placeholder:"z. B. Bürobedarf"})]}),r.jsxs("label",{children:[r.jsx("span",{children:"MwSt.-/Kostenart"}),r.jsxs("select",{className:"input",required:!0,value:E.tax_code_id,onChange:C=>pe(B,"tax_code_id",C.target.value),children:[r.jsx("option",{value:"",children:"Auswählen"}),z.map(C=>r.jsxs("option",{value:C.id,children:[C.label," · ",ac(C.tax_rate_percent)]},C.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Netto"}),r.jsx("input",{className:"input",type:"number",min:"0",step:"0.01",required:!0,value:E.net_amount,onChange:C=>pe(B,"net_amount",C.target.value)})]})]}),r.jsxs("div",{className:"invoice-line-sums",children:[r.jsxs("div",{children:[r.jsx("span",{children:"MwSt."}),r.jsx("strong",{children:ac(U.rate)})]}),r.jsxs("div",{children:[r.jsx("span",{children:"Steuer"}),r.jsx("strong",{children:Ye(U.tax,h.currency||"EUR")})]}),r.jsxs("div",{children:[r.jsx("span",{children:"Brutto"}),r.jsx("strong",{children:Ye(U.gross,h.currency||"EUR")})]})]})]},B)})]}),r.jsxs("div",{className:"invoice-total-box",children:[r.jsxs("div",{children:[r.jsx("span",{children:"Netto gesamt"}),r.jsx("strong",{children:Ye(ae.net,h.currency||"EUR")})]}),r.jsxs("div",{children:[r.jsx("span",{children:"Steuer gesamt"}),r.jsx("strong",{children:Ye(ae.tax,h.currency||"EUR")})]}),r.jsxs("div",{children:[r.jsx("span",{children:"Brutto gesamt"}),r.jsx("strong",{children:Ye(ae.gross,h.currency||"EUR")})]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Beleg hochladen"}),r.jsx("input",{className:"input",type:"file",onChange:E=>{var B;return _(((B=E.target.files)==null?void 0:B[0])||null)}})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Notizen"}),r.jsx("textarea",{className:"textarea",value:h.notes,onChange:E=>ue("notes",E.target.value)})]}),r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"btn",type:"submit",children:j?"Rechnung speichern":"Rechnung anlegen"}),j?r.jsx("button",{className:"btn secondary",type:"button",onClick:re,children:"Abbrechen"}):null]})]})}),r.jsx(de,{title:"Projektübersicht",subtitle:`Budgetbetrachtung für ${o}. Bei Projektbudget wird gegen alle Rechnungen gerechnet, bei Jahresbudget nur gegen das ausgewählte Jahr.`,children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Projekt"}),r.jsx("th",{children:"Berechnung"}),r.jsx("th",{children:"Budget"}),r.jsx("th",{children:"Rechnungen"}),r.jsx("th",{children:"Rest"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[n.map(E=>{const B=E.active_remaining_amount;return r.jsxs("tr",{children:[r.jsxs("td",{children:[r.jsx("strong",{children:E.name}),r.jsx("br",{}),r.jsxs("small",{children:[E.project_code||"ohne Nummer"," · ",jp(E.status),E.committee_name?` · ${E.committee_name}`:""]})]}),r.jsxs("td",{children:[r.jsx("span",{className:"finance-mode",children:FC(E.calculation_mode)}),r.jsx("br",{}),r.jsxs("small",{children:[E.expense_count," Rechnungen"]})]}),r.jsxs("td",{children:[E.active_budget_amount===null?"—":Ye(E.active_budget_amount),E.calculation_mode==="annual_budget"?r.jsxs(r.Fragment,{children:[r.jsx("br",{}),r.jsxs("small",{children:["Jahr: ",E.annual_budget_year]})]}):null]}),r.jsxs("td",{children:[Ye(E.active_spent_amount),r.jsx("br",{}),r.jsxs("small",{children:["gesamt: ",Ye(E.gross_total)]})]}),r.jsx("td",{children:B===null?"—":r.jsx("span",{className:Pt(B)<0?"finance-danger":"finance-ok",children:Ye(B)})}),r.jsx("td",{children:r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>{u(String(E.id)),x(U=>({...U,project_id:String(E.id)}))},children:"Filtern"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>Se(E),children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:()=>V(E),children:"Archivieren"})]})})]},E.id)}),n.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:"6",children:"Noch keine Finanzprojekte vorhanden."})})]})]})})}),r.jsx(de,{title:"Rechnungen",subtitle:R?`Gefiltert auf ${R.name}`:"Alle Rechnungen im ausgewählten Jahr",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Datum"}),r.jsx("th",{children:"Rechnung"}),r.jsx("th",{children:"Projekt"}),r.jsx("th",{children:"Summen"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Belege"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[s.map(E=>r.jsxs("tr",{children:[r.jsx("td",{children:Hv(E.expense_date)}),r.jsxs("td",{children:[r.jsx("strong",{children:E.title}),r.jsx("br",{}),r.jsx("small",{children:E.vendor||"kein Lieferant"}),Array.isArray(E.lines)&&E.lines.length?r.jsx("ul",{className:"expense-lines-list",children:E.lines.map(B=>r.jsxs("li",{children:[B.description," · ",ac(B.tax_rate_percent)," · ",Ye(B.gross_amount,E.currency)]},B.id||B.line_no))}):null]}),r.jsx("td",{children:E.project_name}),r.jsxs("td",{children:[Ye(E.net_amount,E.currency)," netto",r.jsx("br",{}),r.jsxs("small",{children:["Steuer ",Ye(E.tax_amount,E.currency),r.jsx("br",{}),"brutto ",Ye(E.gross_amount,E.currency)]})]}),r.jsx("td",{children:jp(E.payment_status)}),r.jsx("td",{children:r.jsxs("div",{className:"receipt-list",children:[(E.documents||[]).map(B=>r.jsxs("div",{children:[r.jsx("strong",{children:B.title}),r.jsx("br",{}),r.jsxs("small",{children:[B.file_name," · ",B.scan_status]}),r.jsxs("div",{className:"receipt-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>_i(`/finance/documents/${B.id}/view`),children:"Öffnen"}),r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>ji(`/finance/documents/${B.id}/download`,B.file_name||"beleg"),children:"Download"})]})]},B.id)),(E.documents||[]).length?null:r.jsx("small",{children:"Kein Beleg"})]})}),r.jsx("td",{children:r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>q(E),children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:()=>Xe(E),children:"Löschen"})]})})]},E.id)),s.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:"7",children:"Keine Rechnungen für die aktuelle Auswahl vorhanden."})})]})]})})})]}),w?r.jsx("div",{className:"finance-modal-backdrop",onMouseDown:he,children:r.jsxs("div",{className:"finance-modal-panel",role:"dialog","aria-modal":"true","aria-labelledby":"finance-project-modal-title",onMouseDown:E=>E.stopPropagation(),children:[r.jsxs("div",{className:"finance-modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{id:"finance-project-modal-title",children:g?"Finanzprojekt bearbeiten":"Finanzprojekt anlegen"}),r.jsx("p",{className:"finance-modal-subtitle",children:"Hier werden Projektbudget und Jahresbudget gepflegt. Das Jahresbudget gilt genau für das unten angegebene Jahr."})]}),r.jsx("button",{className:"finance-modal-close",type:"button",onClick:he,"aria-label":"Schließen",children:"×"})]}),r.jsx("div",{className:"finance-modal-body",children:r.jsxs("form",{className:"form-grid",onSubmit:me,children:[r.jsxs("div",{className:"finance-inline",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Projekt-Nr."}),r.jsxs("div",{className:"finance-code-row",children:[r.jsx("input",{className:"input",value:m.project_code,onChange:E=>J("project_code",E.target.value),placeholder:"wird automatisch generiert"}),g?null:r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>Y(m.annual_budget_year||o),disabled:v,children:v?"…":"Neu"})]}),r.jsxs("small",{children:["Format: FIN-",m.annual_budget_year||o,"-0001. Die Nummer kann bei Bedarf überschrieben werden."]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Status"}),r.jsxs("select",{className:"input",value:m.status,onChange:E=>J("status",E.target.value),children:[r.jsx("option",{value:"aktiv",children:"Aktiv"}),r.jsx("option",{value:"pausiert",children:"Pausiert"}),r.jsx("option",{value:"abgeschlossen",children:"Abgeschlossen"}),r.jsx("option",{value:"archiviert",children:"Archiviert"})]})]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Name"}),r.jsx("input",{className:"input",required:!0,value:m.name,onChange:E=>J("name",E.target.value)})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Berechnungsart"}),r.jsx("select",{className:"input",value:m.calculation_mode,onChange:E=>J("calculation_mode",E.target.value),children:(e.calculation_modes||[]).map(E=>r.jsx("option",{value:E.value,children:E.label},E.value))})]}),r.jsxs("div",{className:"finance-budget-box",children:[r.jsx("h3",{children:"Budgetsteuerung"}),r.jsx("p",{className:"finance-help",children:"Das Projektbudget ist ein Gesamtbudget für das ganze Projekt. Das Jahresbudget wird separat mit Jahr gespeichert und in der Projektübersicht für das ausgewählte Auswertungsjahr verwendet."}),r.jsxs("div",{className:"finance-inline",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Projektbudget gesamt"}),r.jsx("input",{className:"input",type:"number",step:"0.01",min:"0",value:m.project_budget_amount,onChange:E=>J("project_budget_amount",E.target.value)})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Jahr für Jahresbudget"}),r.jsx("input",{className:"input",type:"number",min:"2000",max:"2100",value:m.annual_budget_year,onChange:E=>J("annual_budget_year",E.target.value),onBlur:ge})]})]}),r.jsxs("label",{children:[r.jsxs("span",{children:["Jahresbudget für ",m.annual_budget_year]}),r.jsx("input",{className:"input",type:"number",step:"0.01",min:"0",value:m.annual_budget_amount,onChange:E=>J("annual_budget_amount",E.target.value)})]})]}),r.jsx("div",{className:"finance-inline",children:r.jsxs("label",{children:[r.jsx("span",{children:"Ausschuss"}),r.jsxs("select",{className:"input",value:m.committee_id,onChange:E=>J("committee_id",E.target.value),children:[r.jsx("option",{value:"",children:"Kein Ausschuss"}),(e.committees||[]).map(E=>r.jsx("option",{value:E.id,children:E.name},E.id))]})]})}),r.jsxs("label",{children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"textarea",value:m.description,onChange:E=>J("description",E.target.value)})]}),r.jsxs("div",{className:"finance-modal-footer",children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:he,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",children:g?"Projekt speichern":"Projekt anlegen"})]})]})})]})}):null]})}function _p(){return{code:"",label:"",tax_rate_percent:"19",is_active:!0,sort_order:"1000"}}function GC(e){const t=Number(e||0);return Number.isFinite(t)?t:0}function XC(e){return`${new Intl.NumberFormat("de-DE",{maximumFractionDigits:3}).format(GC(e))} %`}function wp(e){return String(e||"").trim().replace(/\s+/g,"_").replace(/[^a-zA-Z0-9_()%./%-]/g,"_")}function QC(){const[e,t]=f.useState([]),[n,i]=f.useState(()=>_p()),[s,a]=f.useState(""),[o,l]=f.useState(!0),[c,u]=f.useState(""),[m,d]=f.useState(""),[h,x]=f.useState(!1);async function g(){x(!0),d("");try{const k=await O.get(`/finance/tax-codes${o?"":"?active=true"}`);t(k||[])}catch(k){d(k.message||"MwSt.-Sätze konnten nicht geladen werden.")}finally{x(!1)}}f.useEffect(()=>{g()},[o]);const p=f.useMemo(()=>e.filter(k=>k.is_active).length,[e]);function w(k,N){i(_=>({..._,[k]:N}))}function b(){a(""),i(_p())}function v(k){a(String(k.id)),i({code:k.code||"",label:k.label||"",tax_rate_percent:String(k.tax_rate_percent??"0"),is_active:!!k.is_active,sort_order:String(k.sort_order??"1000")}),u(""),d("")}async function y(k){k.preventDefault(),u(""),d("");const N={...n,code:wp(n.code),label:n.label.trim(),tax_rate_percent:Number(n.tax_rate_percent||0),sort_order:Number(n.sort_order||1e3),is_active:!!n.is_active};if(!N.code||!N.label){d("Code und Bezeichnung sind Pflichtfelder.");return}try{s?(await O.put(`/finance/tax-codes/${s}`,N),u("MwSt.-/Kostenart gespeichert.")):(await O.post("/finance/tax-codes",N),u("MwSt.-/Kostenart angelegt.")),b(),await g()}catch(_){d(_.message||"MwSt.-/Kostenart konnte nicht gespeichert werden.")}}async function j(k){if(window.confirm(`MwSt.-/Kostenart „${k.label}“ deaktivieren?`)){u(""),d("");try{await O.delete(`/finance/tax-codes/${k.id}`),u("MwSt.-/Kostenart deaktiviert."),await g()}catch(N){d(N.message||"MwSt.-/Kostenart konnte nicht deaktiviert werden.")}}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .tax-code-grid { display: grid; gap: 18px; } + .tax-code-form { display: grid; gap: 12px; } + .tax-code-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + .tax-code-badge { display: inline-block; padding: 4px 9px; border-radius: 999px; font-weight: 700; font-size: .84rem; } + .tax-code-badge.active { background: rgba(22, 163, 74, .12); color: #15803d; } + .tax-code-badge.inactive { background: rgba(100, 116, 139, .14); color: #475569; } + @media (max-width: 900px) { .tax-code-inline { grid-template-columns: 1fr; } } + `}),r.jsxs("div",{className:"tax-code-grid",children:[m?r.jsx("div",{className:"alert error",children:m}):null,c?r.jsx("div",{className:"alert success",children:c}):null,r.jsx(de,{title:"MwSt.-Sätze Finanzen",subtitle:"Hier werden die Kostenarten und MwSt.-Sätze gepflegt, die bei Rechnungspositionen im Finanzmodul als Dropdown angeboten werden.",actions:r.jsxs("div",{className:"row-actions",children:[r.jsxs("label",{className:"checkbox-row",children:[r.jsx("input",{type:"checkbox",checked:o,onChange:k=>l(k.target.checked)}),r.jsx("span",{children:"Inaktive anzeigen"})]}),r.jsx("button",{className:"btn secondary",type:"button",onClick:g,disabled:h,children:h?"Lade…":"Aktualisieren"})]}),children:r.jsxs("p",{className:"muted",children:["Aktive Einträge: ",p,". Der Satz wird in Prozent gepflegt, also z. B. ",r.jsx("strong",{children:"19"})," für 19 % oder ",r.jsx("strong",{children:"7"})," für 7 %."]})}),r.jsx(de,{title:s?"MwSt.-/Kostenart bearbeiten":"MwSt.-/Kostenart anlegen",children:r.jsxs("form",{className:"tax-code-form",onSubmit:y,children:[r.jsxs("div",{className:"tax-code-inline",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Code"}),r.jsx("input",{className:"input",required:!0,value:n.code,onChange:k=>w("code",k.target.value),onBlur:k=>w("code",wp(k.target.value)),placeholder:"z. B. Buerobedarf_19%"})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Bezeichnung"}),r.jsx("input",{className:"input",required:!0,value:n.label,onChange:k=>w("label",k.target.value),placeholder:"z. B. Bürobedarf 19%"})]})]}),r.jsxs("div",{className:"tax-code-inline",children:[r.jsxs("label",{children:[r.jsx("span",{children:"MwSt.-Satz in %"}),r.jsx("input",{className:"input",type:"number",min:"0",max:"100",step:"0.001",required:!0,value:n.tax_rate_percent,onChange:k=>w("tax_rate_percent",k.target.value)})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Sortierung"}),r.jsx("input",{className:"input",type:"number",min:"0",step:"1",value:n.sort_order,onChange:k=>w("sort_order",k.target.value)})]})]}),r.jsxs("label",{className:"checkbox-row",children:[r.jsx("input",{type:"checkbox",checked:n.is_active,onChange:k=>w("is_active",k.target.checked)}),r.jsx("span",{children:"Aktiv und in Rechnungen auswählbar"})]}),r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"btn",type:"submit",children:s?"Speichern":"Anlegen"}),s?r.jsx("button",{className:"btn secondary",type:"button",onClick:b,children:"Abbrechen"}):null]})]})}),r.jsx(de,{title:"Vorhandene MwSt.-/Kostenarten",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Sort."}),r.jsx("th",{children:"Code"}),r.jsx("th",{children:"Bezeichnung"}),r.jsx("th",{children:"MwSt."}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[e.map(k=>r.jsxs("tr",{children:[r.jsx("td",{children:k.sort_order}),r.jsx("td",{children:r.jsx("code",{children:k.code})}),r.jsx("td",{children:r.jsx("strong",{children:k.label})}),r.jsx("td",{children:XC(k.tax_rate_percent)}),r.jsx("td",{children:r.jsx("span",{className:`tax-code-badge ${k.is_active?"active":"inactive"}`,children:k.is_active?"Aktiv":"Inaktiv"})}),r.jsx("td",{children:r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>v(k),children:"Bearbeiten"}),k.is_active?r.jsx("button",{className:"mini-btn danger",type:"button",onClick:()=>j(k),children:"Deaktivieren"}):null]})})]},k.id)),e.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:"6",children:"Keine MwSt.-/Kostenarten vorhanden."})})]})]})})})]})]})}function oc(){return{code:"",label:"",description:"",sort_order:"1000",is_active:!0}}function lc(e){return String(e||"").trim().toLowerCase().replace(/ä/g,"ae").replace(/ö/g,"oe").replace(/ü/g,"ue").replace(/ß/g,"ss").replace(/[^a-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,80)}function ZC(e){return{code:e.code||"",label:e.label||"",description:e.description||"",sort_order:String(e.sort_order??1e3),is_active:!!e.is_active}}function YC(){const[e,t]=f.useState([]),[n,i]=f.useState(()=>oc()),[s,a]=f.useState(""),[o,l]=f.useState(!1),[c,u]=f.useState(!0),[m,d]=f.useState(""),[h,x]=f.useState(""),[g,p]=f.useState(!1);async function w(){p(!0),x("");try{const S=await O.get(`/departments${c?"":"?active=true"}`);t(Array.isArray(S)?S:[])}catch(S){x(S.message||"Abteilungen konnten nicht geladen werden.")}finally{p(!1)}}f.useEffect(()=>{w()},[c]),f.useEffect(()=>{if(!o)return;const S=document.body.style.overflow;document.body.style.overflow="hidden";function P(D){D.key==="Escape"&&k()}return window.addEventListener("keydown",P),()=>{document.body.style.overflow=S,window.removeEventListener("keydown",P)}},[o]);const b=f.useMemo(()=>e.filter(S=>S.is_active).length,[e]);function v(S,P){i(D=>({...D,[S]:P}))}function y(){a(""),i(oc()),d(""),x(""),l(!0)}function j(S){a(String(S.id)),i(ZC(S)),d(""),x(""),l(!0)}function k(){l(!1),a(""),i(oc())}async function N(S){S.preventDefault(),d(""),x("");const P=n.label.trim(),D=lc(n.code||P);if(!P){x("Bitte eine Bezeichnung eingeben.");return}if(!D){x("Bitte einen gültigen Code eingeben.");return}const F={code:D,label:P,description:n.description.trim()||null,sort_order:Number(n.sort_order||1e3),is_active:!!n.is_active};try{s?(await O.put(`/departments/${s}`,F),d("Abteilung gespeichert.")):(await O.post("/departments",F),d("Abteilung angelegt.")),k(),await w()}catch(T){x(T.message||"Abteilung konnte nicht gespeichert werden.")}}async function _(S){const P=Number(S.usage_count||0)>0,D=P?`Abteilung „${S.label}“ wird in ${S.usage_count} Vorgang/Vorgängen verwendet und deshalb deaktiviert. Fortfahren?`:`Abteilung „${S.label}“ löschen?`;if(window.confirm(D)){d(""),x("");try{await O.delete(`/departments/${S.id}`),d(P?"Abteilung deaktiviert.":"Abteilung gelöscht."),await w()}catch(F){x(F.message||"Abteilung konnte nicht gelöscht werden.")}}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .department-grid { display: grid; gap: 18px; } + .department-badge { display: inline-block; padding: 4px 9px; border-radius: 999px; font-weight: 700; font-size: .84rem; } + .department-badge.active { background: rgba(22, 163, 74, .12); color: #15803d; } + .department-badge.inactive { background: rgba(100, 116, 139, .14); color: #475569; } + .department-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 24px; + background: rgba(15, 23, 42, 0.62); + backdrop-filter: blur(2px); + } + .department-modal-panel { + width: min(760px, 96vw); + max-height: min(92vh, 920px); + overflow: hidden; + display: flex; + flex-direction: column; + border-radius: 22px; + background: #ffffff; + color: #0f172a; + border: 1px solid rgba(15, 23, 42, 0.12); + box-shadow: 0 30px 90px rgba(15, 23, 42, 0.34), 0 8px 24px rgba(15, 23, 42, 0.18); + } + .department-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 22px 24px 18px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border-bottom: 1px solid rgba(148, 164, 184, 0.28); + } + .department-modal-header h2 { margin: 0; color: #0f172a; font-size: 1.35rem; } + .department-modal-subtitle { margin: 6px 0 0; color: #64748b; line-height: 1.45; } + .department-modal-close { + flex: 0 0 auto; + width: 36px; + height: 36px; + border: 1px solid rgba(148, 164, 184, 0.35); + border-radius: 999px; + background: #ffffff; + color: #0f172a; + cursor: pointer; + font-size: 1.3rem; + line-height: 1; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + } + .department-modal-close:hover { background: #f1f5f9; } + .department-modal-body { padding: 22px 24px; overflow: auto; background: #ffffff; } + .department-inline { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .department-modal-footer { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; padding-top: 4px; } + @media (max-width: 900px) { + .department-inline { grid-template-columns: 1fr; } + .department-modal-backdrop { padding: 12px; align-items: start; } + .department-modal-panel { width: 100%; max-height: calc(100vh - 24px); } + } + `}),r.jsxs("div",{className:"department-grid",children:[h?r.jsx("div",{className:"alert error",children:h}):null,m?r.jsx("div",{className:"alert success",children:m}):null,r.jsx(de,{title:"Abteilungen",subtitle:"Hier werden die Abteilungen gepflegt, die im Vorgangs-Dialog unter Abteilung auswählbar sind.",actions:r.jsxs("div",{className:"row-actions",children:[r.jsxs("label",{className:"checkbox-row",children:[r.jsx("input",{type:"checkbox",checked:c,onChange:S=>u(S.target.checked)}),r.jsx("span",{children:"Inaktive anzeigen"})]}),r.jsx("button",{className:"btn",type:"button",onClick:y,children:"Abteilung hinzufügen"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:w,disabled:g,children:g?"Lade…":"Aktualisieren"})]}),children:r.jsxs("p",{className:"muted",children:["Aktive Abteilungen: ",b,". Abteilungen, die bereits in Vorgängen verwendet werden, werden beim Löschen deaktiviert, damit ältere Vorgänge lesbar bleiben."]})}),r.jsx(de,{title:"Vorhandene Abteilungen",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Sort."}),r.jsx("th",{children:"Code"}),r.jsx("th",{children:"Bezeichnung"}),r.jsx("th",{children:"Verwendung"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[e.map(S=>r.jsxs("tr",{children:[r.jsx("td",{children:S.sort_order}),r.jsx("td",{children:r.jsx("code",{children:S.code})}),r.jsxs("td",{children:[r.jsx("strong",{children:S.label}),S.description?r.jsxs(r.Fragment,{children:[r.jsx("br",{}),r.jsx("small",{children:S.description})]}):null]}),r.jsxs("td",{children:[S.usage_count||0," Vorgänge"]}),r.jsx("td",{children:r.jsx("span",{className:`department-badge ${S.is_active?"active":"inactive"}`,children:S.is_active?"Aktiv":"Inaktiv"})}),r.jsx("td",{children:r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>j(S),children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:()=>_(S),children:Number(S.usage_count||0)>0?"Deaktivieren":"Löschen"})]})})]},S.id)),e.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:"6",children:"Keine Abteilungen vorhanden."})})]})]})})})]}),o?r.jsx("div",{className:"department-modal-backdrop",onMouseDown:k,children:r.jsxs("div",{className:"department-modal-panel",role:"dialog","aria-modal":"true","aria-labelledby":"department-modal-title",onMouseDown:S=>S.stopPropagation(),children:[r.jsxs("div",{className:"department-modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{id:"department-modal-title",children:s?"Abteilung bearbeiten":"Abteilung hinzufügen"}),r.jsx("p",{className:"department-modal-subtitle",children:"Der Code wird im Vorgang gespeichert. Beim Ändern des Codes werden vorhandene Vorgänge automatisch mit umgestellt."})]}),r.jsx("button",{className:"department-modal-close",type:"button",onClick:k,"aria-label":"Schließen",children:"×"})]}),r.jsx("div",{className:"department-modal-body",children:r.jsxs("form",{className:"form-grid",onSubmit:N,children:[r.jsxs("div",{className:"department-inline",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Code"}),r.jsx("input",{className:"input",required:!0,value:n.code,onChange:S=>v("code",S.target.value),onBlur:S=>v("code",lc(S.target.value||n.label)),placeholder:"z. B. personal"})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Bezeichnung"}),r.jsx("input",{className:"input",required:!0,value:n.label,onChange:S=>v("label",S.target.value),onBlur:()=>{n.code||v("code",lc(n.label))},placeholder:"z. B. Personal"})]})]}),r.jsxs("div",{className:"department-inline",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Sortierung"}),r.jsx("input",{className:"input",type:"number",min:"0",step:"1",value:n.sort_order,onChange:S=>v("sort_order",S.target.value)})]}),r.jsxs("label",{className:"checkbox-row",style:{alignSelf:"end",minHeight:42},children:[r.jsx("input",{type:"checkbox",checked:n.is_active,onChange:S=>v("is_active",S.target.checked)}),r.jsx("span",{children:"Aktiv und im Vorgang auswählbar"})]})]}),r.jsxs("label",{className:"full",children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"textarea",value:n.description,onChange:S=>v("description",S.target.value)})]}),r.jsxs("div",{className:"department-modal-footer full",children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:k,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",children:s?"Speichern":"Anlegen"})]})]})})]})}):null]})}function cc(){return{code:"",label:"",description:"",sort_order:"1000",is_active:!0}}function uc(e){return String(e||"").trim().toLowerCase().replace(/ä/g,"ae").replace(/ö/g,"oe").replace(/ü/g,"ue").replace(/ß/g,"ss").replace(/[^a-z0-9]+/g,"_").replace(/^_+|_+$/g,"").slice(0,80)}function JC(e){return{code:e.code||"",label:e.label||"",description:e.description||"",sort_order:String(e.sort_order??1e3),is_active:!!e.is_active}}function e5(){const[e,t]=f.useState([]),[n,i]=f.useState(()=>cc()),[s,a]=f.useState(""),[o,l]=f.useState(!1),[c,u]=f.useState(!0),[m,d]=f.useState(""),[h,x]=f.useState(""),[g,p]=f.useState(!1),w=f.useMemo(()=>e.filter(S=>S.is_active).length,[e]);async function b(){p(!0),x("");try{const S=await O.get(`/document-categories${c?"":"?active=true"}`);t(Array.isArray(S)?S:[])}catch(S){x(S.message||"Dokumentenkategorien konnten nicht geladen werden.")}finally{p(!1)}}f.useEffect(()=>{b()},[c]),f.useEffect(()=>{if(!o)return;const S=document.body.style.overflow;document.body.style.overflow="hidden";function P(D){D.key==="Escape"&&k()}return window.addEventListener("keydown",P),()=>{document.body.style.overflow=S,window.removeEventListener("keydown",P)}},[o]);function v(S,P){i(D=>({...D,[S]:P}))}function y(){a(""),i(cc()),d(""),x(""),l(!0)}function j(S){a(String(S.id)),i(JC(S)),d(""),x(""),l(!0)}function k(){l(!1),a(""),i(cc())}async function N(S){S.preventDefault(),d(""),x("");const P={...n,code:uc(n.code||n.label),label:n.label.trim(),description:n.description.trim()||null,sort_order:Number(n.sort_order||1e3),is_active:!!n.is_active};if(!P.code||!P.label){x("Code und Bezeichnung sind erforderlich.");return}try{s?(await O.put(`/document-categories/${s}`,P),d("Dokumentenkategorie gespeichert.")):(await O.post("/document-categories",P),d("Dokumentenkategorie angelegt.")),k(),await b()}catch(D){x(D.message||"Dokumentenkategorie konnte nicht gespeichert werden.")}}async function _(S){const P=Number(S.usage_count||0)>0,D=P?`Dokumentenkategorie „${S.label}“ wird in ${S.usage_count} Dokument(en) verwendet und deshalb deaktiviert. Fortfahren?`:`Dokumentenkategorie „${S.label}“ löschen?`;if(window.confirm(D)){d(""),x("");try{await O.delete(`/document-categories/${S.id}`),d(P?"Dokumentenkategorie deaktiviert.":"Dokumentenkategorie gelöscht."),await b()}catch(F){x(F.message||"Dokumentenkategorie konnte nicht gelöscht werden.")}}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .document-category-grid { display: grid; gap: 18px; } + .document-category-badge { display: inline-block; padding: 4px 9px; border-radius: 999px; font-weight: 700; font-size: .84rem; } + .document-category-badge.active { background: rgba(22, 163, 74, .12); color: #15803d; } + .document-category-badge.inactive { background: rgba(100, 116, 139, .14); color: #475569; } + .document-category-modal-backdrop { + position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; + background: rgba(15, 23, 42, 0.62); backdrop-filter: blur(2px); + } + .document-category-modal-panel { + width: min(760px, 96vw); max-height: min(92vh, 920px); overflow: hidden; display: flex; flex-direction: column; + border-radius: 22px; background: #ffffff; color: #0f172a; border: 1px solid rgba(15, 23, 42, 0.12); + box-shadow: 0 30px 90px rgba(15, 23, 42, 0.34), 0 8px 24px rgba(15, 23, 42, 0.18); + } + .document-category-modal-header { + display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 22px 24px 18px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); border-bottom: 1px solid rgba(148, 164, 184, 0.28); + } + .document-category-modal-header h2 { margin: 0; color: #0f172a; font-size: 1.35rem; } + .document-category-modal-subtitle { margin: 6px 0 0; color: #64748b; line-height: 1.45; } + .document-category-modal-close { + flex: 0 0 auto; width: 36px; height: 36px; border: 1px solid rgba(148, 164, 184, 0.35); border-radius: 999px; + background: #ffffff; color: #0f172a; cursor: pointer; font-size: 1.3rem; line-height: 1; box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); + } + .document-category-modal-close:hover { background: #f1f5f9; } + .document-category-modal-body { padding: 22px 24px; overflow: auto; background: #ffffff; } + .document-category-inline { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .document-category-modal-footer { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; padding-top: 4px; } + @media (max-width: 900px) { + .document-category-inline { grid-template-columns: 1fr; } + .document-category-modal-backdrop { padding: 12px; align-items: start; } + .document-category-modal-panel { width: 100%; max-height: calc(100vh - 24px); } + } + `}),r.jsxs("div",{className:"document-category-grid",children:[h?r.jsx("div",{className:"alert error",children:h}):null,m?r.jsx("div",{className:"alert success",children:m}):null,r.jsx(de,{title:"Dokumentenkategorien",subtitle:"Hier werden die Kategorien gepflegt, die im Dokument-Dialog auswählbar und als Filter nutzbar sind.",actions:r.jsxs("div",{className:"row-actions",children:[r.jsxs("label",{className:"checkbox-row",children:[r.jsx("input",{type:"checkbox",checked:c,onChange:S=>u(S.target.checked)}),r.jsx("span",{children:"Inaktive anzeigen"})]}),r.jsx("button",{className:"btn",type:"button",onClick:y,children:"Kategorie hinzufügen"}),r.jsx("button",{className:"btn secondary",type:"button",onClick:b,disabled:g,children:g?"Lade…":"Aktualisieren"})]}),children:r.jsxs("p",{className:"muted",children:["Aktive Kategorien: ",w,". Kategorien, die bereits in Dokumenten verwendet werden, werden beim Löschen deaktiviert."]})}),r.jsx(de,{title:"Vorhandene Kategorien",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Sort."}),r.jsx("th",{children:"Code"}),r.jsx("th",{children:"Bezeichnung"}),r.jsx("th",{children:"Verwendung"}),r.jsx("th",{children:"Status"}),r.jsx("th",{children:"Aktionen"})]})}),r.jsxs("tbody",{children:[e.map(S=>r.jsxs("tr",{children:[r.jsx("td",{children:S.sort_order}),r.jsx("td",{children:r.jsx("code",{children:S.code})}),r.jsxs("td",{children:[r.jsx("strong",{children:S.label}),S.description?r.jsxs(r.Fragment,{children:[r.jsx("br",{}),r.jsx("small",{children:S.description})]}):null]}),r.jsxs("td",{children:[S.usage_count||0," Dokumente"]}),r.jsx("td",{children:r.jsx("span",{className:`document-category-badge ${S.is_active?"active":"inactive"}`,children:S.is_active?"Aktiv":"Inaktiv"})}),r.jsx("td",{children:r.jsxs("div",{className:"row-actions",children:[r.jsx("button",{className:"mini-btn",type:"button",onClick:()=>j(S),children:"Bearbeiten"}),r.jsx("button",{className:"mini-btn danger",type:"button",onClick:()=>_(S),children:Number(S.usage_count||0)>0?"Deaktivieren":"Löschen"})]})})]},S.id)),e.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:"6",children:"Keine Dokumentenkategorien vorhanden."})})]})]})})})]}),o?r.jsx("div",{className:"document-category-modal-backdrop",onMouseDown:k,children:r.jsxs("div",{className:"document-category-modal-panel",role:"dialog","aria-modal":"true","aria-labelledby":"document-category-modal-title",onMouseDown:S=>S.stopPropagation(),children:[r.jsxs("div",{className:"document-category-modal-header",children:[r.jsxs("div",{children:[r.jsx("h2",{id:"document-category-modal-title",children:s?"Dokumentenkategorie bearbeiten":"Dokumentenkategorie hinzufügen"}),r.jsx("p",{className:"document-category-modal-subtitle",children:"Der Code dient als technischer Schlüssel, die Bezeichnung wird im Dokument-Dialog angezeigt."})]}),r.jsx("button",{className:"document-category-modal-close",type:"button",onClick:k,"aria-label":"Schließen",children:"×"})]}),r.jsx("div",{className:"document-category-modal-body",children:r.jsxs("form",{className:"form-grid",onSubmit:N,children:[r.jsxs("div",{className:"document-category-inline",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Code"}),r.jsx("input",{className:"input",required:!0,value:n.code,onChange:S=>v("code",S.target.value),onBlur:S=>v("code",uc(S.target.value||n.label)),placeholder:"z. B. protokoll"})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Bezeichnung"}),r.jsx("input",{className:"input",required:!0,value:n.label,onChange:S=>v("label",S.target.value),onBlur:()=>{n.code||v("code",uc(n.label))},placeholder:"z. B. Protokoll"})]})]}),r.jsxs("div",{className:"document-category-inline",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Sortierung"}),r.jsx("input",{className:"input",type:"number",min:"0",step:"1",value:n.sort_order,onChange:S=>v("sort_order",S.target.value)})]}),r.jsxs("label",{className:"checkbox-row",style:{alignSelf:"end",minHeight:42},children:[r.jsx("input",{type:"checkbox",checked:n.is_active,onChange:S=>v("is_active",S.target.checked)}),r.jsx("span",{children:"Aktiv und im Dokument auswählbar"})]})]}),r.jsxs("label",{className:"full",children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"textarea",value:n.description,onChange:S=>v("description",S.target.value)})]}),r.jsxs("div",{className:"document-category-modal-footer full",children:[r.jsx("button",{className:"btn secondary",type:"button",onClick:k,children:"Abbrechen"}),r.jsx("button",{className:"btn",type:"submit",children:s?"Speichern":"Anlegen"})]})]})})]})}):null]})}const Wv="http://localhost:8080/api",ja="br_public_documents_access_token";async function kp(e,t={}){const n=await fetch(`${Wv}${e}`,{credentials:"include",headers:{...t.body?{"Content-Type":"application/json"}:{},...t.headers||{}},...t}),i=await n.json().catch(()=>({}));if(!n.ok){const s=new Error(i.error||"Anfrage konnte nicht verarbeitet werden.");throw s.status=n.status,s}return i}function t5(e){return e?new Intl.DateTimeFormat("de-DE",{dateStyle:"medium"}).format(new Date(e)):""}function Sp(e,t="view",n=""){const i=n?`?access_token=${encodeURIComponent(n)}`:"";return`${Wv}/public/documents/${e}/${t}${i}`}function n5(){const[e,t]=f.useState({enabled:!1,authenticated:!1,intro_text:"",documents:[]}),[n,i]=f.useState(()=>sessionStorage.getItem(ja)||""),[s,a]=f.useState(""),[o,l]=f.useState(""),[c,u]=f.useState(""),[m,d]=f.useState(!0),[h,x]=f.useState(!1);async function g(v=n){d(!0),l("");try{const y=v?`?access_token=${encodeURIComponent(v)}`:"",j=await kp(`/public/documents/status${y}`);t(j||{}),v&&(j!=null&&j.enabled)&&!(j!=null&&j.authenticated)&&(sessionStorage.removeItem(ja),i(""))}catch(y){l(y.message||"Die öffentliche Dokumentenseite konnte nicht geladen werden.")}finally{d(!1)}}f.useEffect(()=>{g(n)},[]);async function p(v){v.preventDefault(),l(""),u("");const y=s.trim();if(y.length<4){l("Das Kennwort muss mindestens 4 Zeichen lang sein.");return}x(!0);try{const k=(await kp("/public/documents/access",{method:"POST",body:JSON.stringify({password:y})})).accessToken||"";sessionStorage.setItem(ja,k),i(k),a(""),u("Zugriff freigeschaltet."),await g(k)}catch(j){l(j.message||"Kennwort konnte nicht geprüft werden.")}finally{x(!1)}}function w(){sessionStorage.removeItem(ja),i(""),a(""),u("Zugriff wurde gesperrt."),g("")}const b=!!(e.enabled&&e.authenticated&&n);return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .public-doc-page { min-height: 100vh; background: radial-gradient(circle at top left, rgba(37, 99, 235, 0.16), transparent 32%), #f6f8fb; padding: 32px 16px; color: #0f172a; } + .public-doc-shell { width: min(980px, 100%); margin: 0 auto; display: grid; gap: 18px; } + .public-doc-card { background: #ffffff; border: 1px solid rgba(148, 164, 184, 0.24); border-radius: 24px; box-shadow: 0 24px 80px rgba(15, 23, 42, 0.12); overflow: hidden; } + .public-doc-header { padding: 28px 30px 22px; border-bottom: 1px solid rgba(148, 164, 184, 0.22); background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); } + .public-doc-header h1 { margin: 0; font-size: clamp(1.6rem, 3vw, 2.35rem); } + .public-doc-header p { margin: 10px 0 0; color: #64748b; line-height: 1.55; white-space: pre-wrap; } + .public-doc-body { padding: 26px 30px 30px; display: grid; gap: 14px; } + .public-doc-item { border: 1px solid rgba(148, 164, 184, 0.28); border-radius: 18px; padding: 16px; display: grid; gap: 10px; background: #ffffff; } + .public-doc-item h2 { margin: 0; font-size: 1.1rem; } + .public-doc-meta { color: #64748b; font-size: .92rem; display: flex; gap: 8px; flex-wrap: wrap; } + .public-doc-description { color: #334155; line-height: 1.5; white-space: pre-wrap; margin: 0; } + .public-doc-actions { display: flex; gap: 8px; flex-wrap: wrap; } + .public-doc-btn { display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 999px; padding: 10px 14px; font-weight: 800; text-decoration: none; background: #2563eb; color: #ffffff; cursor: pointer; } + .public-doc-btn.secondary { background: #e2e8f0; color: #0f172a; } + .public-doc-alert { border-radius: 16px; padding: 12px 14px; font-weight: 700; } + .public-doc-alert.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; } + .public-doc-alert.success { background: #ecfdf5; color: #065f46; border: 1px solid #bbf7d0; } + .public-doc-note { color: #64748b; line-height: 1.5; } + .public-doc-access { border: 1px solid rgba(37, 99, 235, .18); background: #f8fbff; border-radius: 18px; padding: 18px; display: grid; gap: 12px; } + .public-doc-access h2 { margin: 0; font-size: 1.15rem; } + .public-doc-form { display: grid; gap: 10px; max-width: 420px; } + .public-doc-form label { display: grid; gap: 6px; font-weight: 700; } + .public-doc-input { width: 100%; border: 1px solid rgba(148, 164, 184, .5); border-radius: 14px; padding: 11px 12px; font: inherit; } + `}),r.jsx("div",{className:"public-doc-page",children:r.jsx("div",{className:"public-doc-shell",children:r.jsxs("div",{className:"public-doc-card",children:[r.jsxs("div",{className:"public-doc-header",children:[r.jsx("h1",{children:"Öffentliche Dokumente"}),r.jsx("p",{children:e.intro_text||"Vom Betriebsrat freigegebene Dokumente"})]}),r.jsxs("div",{className:"public-doc-body",children:[o?r.jsx("div",{className:"public-doc-alert error",children:o}):null,c?r.jsx("div",{className:"public-doc-alert success",children:c}):null,m?r.jsx("p",{className:"public-doc-note",children:"Dokumente werden geladen …"}):null,!m&&!e.enabled?r.jsx("p",{className:"public-doc-note",children:"Die öffentliche Dokumentenseite ist derzeit nicht freigeschaltet."}):null,!m&&e.enabled&&!b?r.jsxs("div",{className:"public-doc-access",children:[r.jsx("h2",{children:"Kennwort erforderlich"}),r.jsx("p",{className:"public-doc-note",children:"Diese Seite ist nicht frei öffentlich. Bitte geben Sie das bekannte Kennwort ein, um die freigegebenen Dokumente anzuzeigen."}),r.jsxs("form",{className:"public-doc-form",onSubmit:p,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Kennwort"}),r.jsx("input",{className:"public-doc-input",type:"password",minLength:"4",value:s,onChange:v=>a(v.target.value),autoComplete:"current-password"})]}),r.jsx("button",{className:"public-doc-btn",type:"submit",disabled:h,children:h?"Prüfe…":"Dokumente anzeigen"})]})]}):null,!m&&b&&!(e.documents||[]).length?r.jsx("p",{className:"public-doc-note",children:"Derzeit sind keine öffentlichen Dokumente verfügbar."}):null,b?r.jsx("div",{className:"public-doc-actions",children:r.jsx("button",{className:"public-doc-btn secondary",type:"button",onClick:w,children:"Zugriff sperren"})}):null,b?(e.documents||[]).map(v=>r.jsxs("article",{className:"public-doc-item",children:[r.jsxs("div",{children:[r.jsx("h2",{children:v.title}),r.jsxs("div",{className:"public-doc-meta",children:[r.jsx("span",{children:v.document_number}),v.category_label?r.jsxs("span",{children:["· ",v.category_label]}):null,v.file_name?r.jsxs("span",{children:["· ",v.file_name]}):null,v.uploaded_at?r.jsxs("span",{children:["· ",t5(v.uploaded_at)]}):null]})]}),v.description?r.jsx("p",{className:"public-doc-description",children:v.description}):null,r.jsxs("div",{className:"public-doc-actions",children:[r.jsx("a",{className:"public-doc-btn",href:Sp(v.id,"view",n),target:"_blank",rel:"noreferrer",children:"Dokument öffnen"}),r.jsx("a",{className:"public-doc-btn secondary",href:Sp(v.id,"download",n),children:"Download"})]})]},v.id)):null]})]})})})]})}function dc(){return{enabled:!1,password_set:!1,access_password:"",clear_password:!1,intro_text:"Vom Betriebsrat freigegebene Dokumente"}}function r5(e){return e?new Intl.DateTimeFormat("de-DE",{dateStyle:"short",timeStyle:"short"}).format(new Date(e)):""}function i5(){const[e,t]=f.useState(dc()),[n,i]=f.useState([]),[s,a]=f.useState("/public-documents"),[o,l]=f.useState(!1),[c,u]=f.useState(""),[m,d]=f.useState(""),h=f.useMemo(()=>typeof window>"u"?s:`${window.location.origin}${s}`,[s]);async function x(){l(!0),d("");try{const b=await O.get("/documents/public-settings");t({...dc(),...b.settings||{},access_password:"",clear_password:!1}),i(Array.isArray(b.documents)?b.documents:[]),a(b.public_path||"/public-documents")}catch(b){d(b.message||"Einstellungen für öffentliche Dokumente konnten nicht geladen werden.")}finally{l(!1)}}f.useEffect(()=>{x()},[]);function g(b,v){t(y=>{const j={...y,[b]:v};return b==="access_password"&&v&&(j.clear_password=!1),b==="clear_password"&&v&&(j.access_password=""),j})}async function p(b){b.preventDefault(),d(""),u("");const v=String(e.access_password||"").trim(),y=!!v||!!e.password_set&&!e.clear_password;if(v&&v.length<4){d("Das globale Kennwort muss mindestens 4 Zeichen lang sein.");return}if(e.enabled&&!y){d("Zum Einschalten der öffentlichen Dokumentenseite muss ein Kennwort mit mindestens 4 Zeichen gesetzt sein.");return}const j={enabled:!!e.enabled,intro_text:e.intro_text||"",access_password:v,clear_password:!!e.clear_password};try{const k=await O.put("/documents/public-settings",j);u("Einstellungen für öffentliche Dokumente gespeichert."),t({...dc(),...k.settings||{},access_password:"",clear_password:!1}),await x()}catch(k){d(k.message||"Einstellungen konnten nicht gespeichert werden.")}}async function w(){try{await navigator.clipboard.writeText(h),u("Öffentliche URL kopiert.")}catch{d("URL konnte nicht in die Zwischenablage kopiert werden.")}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .public-doc-admin-grid { display: grid; gap: 18px; } + .public-doc-admin-two { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } + .public-doc-admin-form { display: grid; gap: 14px; } + .public-doc-admin-status-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + .public-doc-admin-status { display: inline-flex; align-items: center; gap: 8px; border-radius: 999px; padding: 6px 10px; font-weight: 800; } + .public-doc-admin-status.on { background: #ecfdf5; color: #065f46; } + .public-doc-admin-status.off { background: #fef2f2; color: #991b1b; } + .public-doc-admin-status.neutral { background: #eff6ff; color: #1d4ed8; } + .public-doc-admin-url { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + .public-doc-admin-url code { background: rgba(15, 23, 42, .06); padding: 9px 11px; border-radius: 12px; word-break: break-all; } + .public-doc-admin-note { color: var(--muted); line-height: 1.45; margin: 0; } + .public-doc-admin-form .check-row { display: flex; align-items: center; gap: 10px; font-weight: 700; } + .public-doc-admin-password-box { border: 1px solid rgba(37, 99, 235, .18); background: #f8fbff; border-radius: 18px; padding: 14px; display: grid; gap: 12px; } + .public-doc-admin-password-box h3 { margin: 0; font-size: 1rem; color: #0f172a; } + @media (max-width: 980px) { .public-doc-admin-two { grid-template-columns: 1fr; } } + `}),r.jsxs("div",{className:"public-doc-admin-grid",children:[m?r.jsx("div",{className:"alert error",children:m}):null,c?r.jsx("div",{className:"alert success",children:c}):null,r.jsxs(de,{title:"Öffentliche Dokumente",subtitle:"Fast öffentliche Seite für Dokumente mit Vertraulichkeit „Öffentlich“. Der Zugriff ist nur mit globalem Kennwort möglich.",actions:r.jsx("button",{className:"btn secondary",type:"button",onClick:x,disabled:o,children:o?"Lade…":"Aktualisieren"}),children:[r.jsxs("div",{className:"public-doc-admin-status-row",children:[r.jsx("span",{className:`public-doc-admin-status ${e.enabled?"on":"off"}`,children:e.enabled?"Eingeschaltet":"Ausgeschaltet"}),r.jsx("span",{className:`public-doc-admin-status ${e.password_set?"neutral":"off"}`,children:e.password_set?"Kennwort gesetzt":"Kein Kennwort gesetzt"})]}),r.jsxs("div",{className:"public-doc-admin-url",style:{marginTop:12},children:[r.jsx("code",{children:h}),r.jsx("button",{className:"mini-btn",type:"button",onClick:w,children:"URL kopieren"})]})]}),r.jsxs("div",{className:"public-doc-admin-two",children:[r.jsx(de,{title:"Einstellungen",subtitle:"Auf der öffentlichen Seite erscheinen nur Dokumente mit Vertraulichkeit „Öffentlich“ und erfolgreichem Virenscan — nach Kennworteingabe.",children:r.jsxs("form",{className:"public-doc-admin-form",onSubmit:p,children:[r.jsxs("label",{className:"check-row",children:[r.jsx("input",{type:"checkbox",checked:!!e.enabled,onChange:b=>g("enabled",b.target.checked)}),r.jsx("span",{children:"Öffentliche Dokumentenseite einschalten"})]}),r.jsxs("div",{className:"public-doc-admin-password-box",children:[r.jsx("h3",{children:"Kennwortschutz"}),r.jsx("p",{className:"public-doc-admin-note",children:"Passwortregel: Das globale Kennwort muss mindestens 4 Zeichen lang sein. Ohne Kennwort kann die öffentliche Dokumentenseite nicht eingeschaltet werden."}),r.jsxs("label",{children:[r.jsx("span",{children:"Globales Kennwort setzen/ändern"}),r.jsx("input",{className:"input",type:"password",minLength:"4",value:e.access_password||"",onChange:b=>g("access_password",b.target.value),placeholder:e.password_set?"Neues Kennwort eingeben, um es zu ändern":"Kennwort mindestens 4 Zeichen",autoComplete:"new-password"})]}),e.password_set?r.jsxs("label",{className:"check-row",children:[r.jsx("input",{type:"checkbox",checked:!!e.clear_password,onChange:b=>g("clear_password",b.target.checked)}),r.jsx("span",{children:"Kennwort entfernen"})]}):null]}),r.jsxs("label",{children:[r.jsx("span",{children:"Beschreibung der öffentlichen Seite"}),r.jsx("textarea",{className:"input textarea",value:e.intro_text||"",onChange:b=>g("intro_text",b.target.value)})]}),r.jsx("p",{className:"public-doc-admin-note",children:"Dokumente werden auf der öffentlichen Seite bereitgestellt, wenn sie im Dokument-Dialog die Vertraulichkeit „Öffentlich“ erhalten. Sichtbar sind sie erst nach Eingabe des globalen Kennworts."}),r.jsx("button",{className:"btn",type:"submit",children:"Einstellungen speichern"})]})}),r.jsx(de,{title:"Öffentlich freigegebene Dokumente",subtitle:"Diese Dokumente werden nach Kennworteingabe auf der öffentlichen Seite gelistet.",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Dokument"}),r.jsx("th",{children:"Kategorie"}),r.jsx("th",{children:"Scan"}),r.jsx("th",{children:"Hochgeladen"})]})}),r.jsxs("tbody",{children:[n.map(b=>r.jsxs("tr",{children:[r.jsxs("td",{children:[r.jsx("strong",{children:b.title}),r.jsx("br",{}),r.jsxs("small",{children:[b.document_number," · ",b.file_name]})]}),r.jsx("td",{children:b.category_label||"—"}),r.jsx("td",{children:b.scan_status}),r.jsx("td",{children:r5(b.uploaded_at)})]},b.id)),n.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:"4",children:"Noch keine öffentlich freigegebenen Dokumente vorhanden."})})]})]})})})]})]})]})}function Np(){return{code:"",name:"",description:"",is_active:!0}}function fc(){return{code:"",name:"",description:"",can_view_cases:!1,can_write_cases:!1,can_view_documents:!1,can_write_documents:!1,is_active:!0}}function _a(e,t){const n=new Set(e||[]);return n.has(t)?n.delete(t):n.add(t),Array.from(n)}function s5(){const[e,t]=f.useState({pages:[],permissions:[],role_profiles:[],team_roles:[],users:[]}),[n,i]=f.useState(""),[s,a]=f.useState({page_keys:[],permission_keys:[]}),[o,l]=f.useState(Np()),[c,u]=f.useState(fc()),[m,d]=f.useState(""),[h,x]=f.useState(""),[g,p]=f.useState({role_profile_ids:[],team_role_ids:[]}),[w,b]=f.useState(""),[v,y]=f.useState("");async function j(){var A;y("");try{const $=await O.get("/permissions/matrix");t($||{pages:[],permissions:[],role_profiles:[],team_roles:[],users:[]});const Z=(A=$==null?void 0:$.role_profiles)==null?void 0:A[0];!n&&Z&&(i(String(Z.id)),a({page_keys:Z.page_keys||[],permission_keys:Z.permission_keys||[]}))}catch($){y($.message||"Rechtematrix konnte nicht geladen werden.")}}f.useEffect(()=>{j()},[]);const k=f.useMemo(()=>e.role_profiles.find(A=>String(A.id)===String(n)),[e.role_profiles,n]),N=f.useMemo(()=>e.users.find(A=>String(A.id)===String(h)),[e.users,h]),_=f.useMemo(()=>{const A=new Map;for(const $ of e.permissions||[]){const Z=$.category||"Sonstige";A.has(Z)||A.set(Z,[]),A.get(Z).push($)}return Array.from(A.entries())},[e.permissions]);function S(A){const $=e.role_profiles.find(Z=>String(Z.id)===String(A));i(A),a({page_keys:($==null?void 0:$.page_keys)||[],permission_keys:($==null?void 0:$.permission_keys)||[]}),b(""),y("")}function P(A){const $=e.team_roles.find(Z=>String(Z.id)===String(A));d(A),u($?{...$}:fc())}function D(A){const $=e.users.find(Z=>String(Z.id)===String(A));x(A),p({role_profile_ids:(($==null?void 0:$.role_profile_ids)||[]).map(Number),team_role_ids:(($==null?void 0:$.team_role_ids)||[]).map(Number)}),b(""),y("")}async function F(){if(n){y(""),b("");try{const A=await O.put(`/permissions/roles/${n}/matrix`,s);t(A),b("Rechtematrix gespeichert. Betroffene Benutzer sehen die Änderung spätestens nach Aktualisierung der Seite oder erneutem Login.")}catch(A){y(A.message||"Rechtematrix konnte nicht gespeichert werden.")}}}async function T(A){A.preventDefault(),y(""),b("");try{await O.post("/permissions/roles",o),l(Np()),await j(),b("Neue Rolle erstellt.")}catch($){y($.message||"Rolle konnte nicht erstellt werden.")}}async function M(A){A.preventDefault(),y(""),b("");try{m?(await O.put(`/permissions/team-roles/${m}`,c),b("Teamrolle gespeichert.")):(await O.post("/permissions/team-roles",c),b("Teamrolle erstellt.")),d(""),u(fc()),await j()}catch($){y($.message||"Teamrolle konnte nicht gespeichert werden.")}}async function z(){if(h){y(""),b("");try{const A=await O.put(`/permissions/users/${h}/assignments`,g);t(A),b("Benutzer-Zuweisungen gespeichert.")}catch(A){y(A.message||"Zuweisungen konnten nicht gespeichert werden.")}}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .permissions-grid { display: grid; gap: 16px; } + .permissions-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } + .permission-list { display: grid; gap: 8px; } + .permission-check { display: flex; gap: 8px; align-items: flex-start; padding: 8px 10px; border: 1px solid rgba(148,163,184,.35); border-radius: 12px; } + .permission-check small { display: block; opacity: .7; margin-top: 2px; } + .role-list { display: flex; gap: 8px; flex-wrap: wrap; } + .role-pill { border: 1px solid rgba(148,163,184,.45); background: rgba(255,255,255,.55); border-radius: 999px; padding: 8px 12px; cursor: pointer; } + .role-pill.active { font-weight: 700; background: rgba(37,99,235,.12); color: #1d4ed8; } + .mini-form { display: grid; gap: 10px; } + @media (max-width: 980px) { .permissions-two-col { grid-template-columns: 1fr; } } + `}),r.jsxs("div",{className:"permissions-grid",children:[v?r.jsx("div",{className:"alert error",children:v}):null,w?r.jsx("div",{className:"alert success",children:w}):null,r.jsxs(de,{title:"Rechtematrix",subtitle:"Seiten werden im Menü ausgeblendet, wenn keine zugewiesene Rolle die Seite freigibt. Custom-Rollen können zusätzliche Seiten und Rechte bekommen.",actions:r.jsx("button",{className:"btn",type:"button",disabled:!n,onClick:F,children:"Matrix speichern"}),children:[r.jsx("div",{className:"role-list",style:{marginBottom:14},children:e.role_profiles.map(A=>r.jsxs("button",{type:"button",className:`role-pill ${String(A.id)===String(n)?"active":""}`,onClick:()=>S(String(A.id)),children:[A.name,A.is_system?" · System":""]},A.id))}),k?r.jsxs("div",{className:"permissions-two-col",children:[r.jsxs("div",{children:[r.jsx("h3",{children:"Webseiten"}),r.jsx("div",{className:"permission-list",children:e.pages.map(A=>r.jsxs("label",{className:"permission-check",children:[r.jsx("input",{type:"checkbox",checked:s.page_keys.includes(A.page_key),onChange:()=>a($=>({...$,page_keys:_a($.page_keys,A.page_key)}))}),r.jsxs("span",{children:[r.jsx("strong",{children:A.label}),r.jsxs("small",{children:[A.path," · ",A.description]})]})]},A.page_key))})]}),r.jsxs("div",{children:[r.jsx("h3",{children:"Funktionsrechte"}),_.map(([A,$])=>r.jsxs("div",{style:{marginBottom:14},children:[r.jsx("strong",{children:A}),r.jsx("div",{className:"permission-list",style:{marginTop:8},children:$.map(Z=>r.jsxs("label",{className:"permission-check",children:[r.jsx("input",{type:"checkbox",checked:s.permission_keys.includes(Z.permission_key),onChange:()=>a(R=>({...R,permission_keys:_a(R.permission_keys,Z.permission_key)}))}),r.jsxs("span",{children:[r.jsx("strong",{children:Z.label}),r.jsxs("small",{children:[Z.permission_key," · ",Z.description]})]})]},Z.permission_key))})]},A))]})]}):r.jsx("div",{children:"Keine Rolle ausgewählt."})]}),r.jsxs("div",{className:"permissions-two-col",children:[r.jsx(de,{title:"Neue Custom-Rolle",subtitle:"Die Basisrolle am Benutzer bleibt bestehen; Custom-Rollen erweitern Seiten und Rechte.",children:r.jsxs("form",{className:"mini-form",onSubmit:T,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Code"}),r.jsx("input",{className:"input",value:o.code,onChange:A=>l({...o,code:A.target.value}),placeholder:"z.B. datenschutz_team"})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Name"}),r.jsx("input",{className:"input",value:o.name,onChange:A=>l({...o,name:A.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"input textarea",value:o.description,onChange:A=>l({...o,description:A.target.value})})]}),r.jsx("button",{className:"btn",type:"submit",children:"Custom-Rolle erstellen"})]})}),r.jsx(de,{title:"Teamrollen",subtitle:"Teamrollen können Vorgänge und Dokumente sichtbar machen, ohne globale Systemrechte zu vergeben.",children:r.jsxs("form",{className:"mini-form",onSubmit:M,children:[r.jsxs("label",{children:[r.jsx("span",{children:"Vorhandene Teamrolle bearbeiten"}),r.jsxs("select",{className:"input",value:m,onChange:A=>P(A.target.value),children:[r.jsx("option",{value:"",children:"Neue Teamrolle"}),e.team_roles.map(A=>r.jsx("option",{value:A.id,children:A.name},A.id))]})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Code"}),r.jsx("input",{className:"input",value:c.code,onChange:A=>u({...c,code:A.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Name"}),r.jsx("input",{className:"input",value:c.name,onChange:A=>u({...c,name:A.target.value})})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"input textarea",value:c.description||"",onChange:A=>u({...c,description:A.target.value})})]}),r.jsxs("label",{className:"permission-check",children:[r.jsx("input",{type:"checkbox",checked:c.can_view_cases,onChange:A=>u({...c,can_view_cases:A.target.checked})})," Vorgänge lesen"]}),r.jsxs("label",{className:"permission-check",children:[r.jsx("input",{type:"checkbox",checked:c.can_write_cases,onChange:A=>u({...c,can_write_cases:A.target.checked})})," Vorgänge bearbeiten"]}),r.jsxs("label",{className:"permission-check",children:[r.jsx("input",{type:"checkbox",checked:c.can_view_documents,onChange:A=>u({...c,can_view_documents:A.target.checked})})," Dokumente lesen"]}),r.jsxs("label",{className:"permission-check",children:[r.jsx("input",{type:"checkbox",checked:c.can_write_documents,onChange:A=>u({...c,can_write_documents:A.target.checked})})," Dokumente bearbeiten"]}),r.jsx("button",{className:"btn",type:"submit",children:m?"Teamrolle speichern":"Teamrolle erstellen"})]})})]}),r.jsxs(de,{title:"Benutzer-Zuweisungen",subtitle:"Hier weist du Benutzern zusätzliche Custom-Rollen und Teamrollen zu.",children:[r.jsxs("div",{className:"permissions-two-col",children:[r.jsxs("label",{children:[r.jsx("span",{children:"Benutzer"}),r.jsxs("select",{className:"input",value:h,onChange:A=>D(A.target.value),children:[r.jsx("option",{value:"",children:"Bitte auswählen"}),e.users.map(A=>r.jsxs("option",{value:A.id,children:[A.full_name," · ",A.email," · Basisrolle: ",A.role]},A.id))]})]}),r.jsx("div",{style:{display:"flex",alignItems:"end"},children:r.jsx("button",{className:"btn",type:"button",disabled:!h,onClick:z,children:"Zuweisungen speichern"})})]}),N?r.jsxs("div",{className:"permissions-two-col",style:{marginTop:16},children:[r.jsxs("div",{children:[r.jsx("h3",{children:"Custom-Rollen"}),r.jsxs("div",{className:"permission-list",children:[e.role_profiles.filter(A=>!A.is_system).map(A=>r.jsxs("label",{className:"permission-check",children:[r.jsx("input",{type:"checkbox",checked:g.role_profile_ids.includes(Number(A.id)),onChange:()=>p($=>({...$,role_profile_ids:_a($.role_profile_ids.map(Number),Number(A.id))}))}),r.jsxs("span",{children:[r.jsx("strong",{children:A.name}),r.jsx("small",{children:A.description})]})]},A.id)),e.role_profiles.filter(A=>!A.is_system).length===0?r.jsx("div",{className:"helper-text",children:"Noch keine Custom-Rollen vorhanden."}):null]})]}),r.jsxs("div",{children:[r.jsx("h3",{children:"Teamrollen"}),r.jsxs("div",{className:"permission-list",children:[e.team_roles.map(A=>r.jsxs("label",{className:"permission-check",children:[r.jsx("input",{type:"checkbox",checked:g.team_role_ids.includes(Number(A.id)),onChange:()=>p($=>({...$,team_role_ids:_a($.team_role_ids.map(Number),Number(A.id))}))}),r.jsxs("span",{children:[r.jsx("strong",{children:A.name}),r.jsx("small",{children:A.description})]})]},A.id)),e.team_roles.length===0?r.jsx("div",{className:"helper-text",children:"Noch keine Teamrollen vorhanden."}):null]})]})]}):null]})]})]})}const a5="http://localhost:8080/api",mc="br_kummerkasten_access_token";function Cp(){return{title:"",submitter_name:"",submitter_contact:"",description:""}}async function pc(e,t={}){const n=await fetch(`${a5}${e}`,{...t,headers:{...t.body?{"Content-Type":"application/json"}:{},...t.headers||{}},credentials:"include"}),i=await n.json().catch(()=>({}));if(!n.ok){const s=new Error(i.error||"Anfrage konnte nicht verarbeitet werden.");throw s.status=n.status,s}return i}function o5(){const[e,t]=f.useState({enabled:!1,password_required:!0,intro_text:""}),[n,i]=f.useState(()=>sessionStorage.getItem(mc)||""),[s,a]=f.useState(""),[o,l]=f.useState(()=>Cp()),[c,u]=f.useState(""),[m,d]=f.useState(""),[h,x]=f.useState(!0),[g,p]=f.useState(!1);async function w(){x(!0),d("");try{const k=await pc("/public/kummerkasten/status");t(k||{})}catch(k){d(k.message||"Der Kummerkasten-Status konnte nicht geladen werden.")}finally{x(!1)}}f.useEffect(()=>{w()},[]);const b=f.useMemo(()=>!!n,[n]);function v(k,N){l(_=>({..._,[k]:N}))}async function y(k){if(k.preventDefault(),d(""),u(""),String(s||"").trim().length<4){d("Das Kennwort muss mindestens 4 Zeichen lang sein.");return}try{const _=(await pc("/public/kummerkasten/access",{method:"POST",body:JSON.stringify({password:s})})).accessToken||"";sessionStorage.setItem(mc,_),i(_),a(""),u("Kummerkasten freigeschaltet. Sie können jetzt einen TOP-Vorschlag einreichen.")}catch(N){d(N.message||"Kennwort konnte nicht geprüft werden.")}}async function j(k){k.preventDefault(),d(""),u(""),p(!0);try{const N={public_access_token:n,title:o.title.trim(),description:o.description.trim(),submitter_name:o.submitter_name.trim()||null,submitter_contact:o.submitter_contact.trim()||null},_=await pc("/public/kummerkasten/submit",{method:"POST",body:JSON.stringify(N)});u(`Vielen Dank. Ihr Anliegen wurde als TOP-Bestandseintrag #${_.agenda_item_id} angelegt.`),l(Cp())}catch(N){N.status===401&&(sessionStorage.removeItem(mc),i("")),d(N.message||"Einreichung konnte nicht gespeichert werden.")}finally{p(!1)}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .public-kummer-page { + min-height: 100vh; + background: radial-gradient(circle at top left, rgba(37, 99, 235, 0.16), transparent 32%), #f6f8fb; + padding: 32px 16px; + color: #0f172a; + } + + .public-kummer-shell { + width: min(900px, 100%); + margin: 0 auto; + display: grid; + gap: 18px; + } + + .public-kummer-card { + background: #ffffff; + border: 1px solid rgba(148, 164, 184, 0.24); + border-radius: 24px; + box-shadow: 0 24px 80px rgba(15, 23, 42, 0.12); + overflow: hidden; + } + + .public-kummer-header { + padding: 28px 30px 22px; + border-bottom: 1px solid rgba(148, 164, 184, 0.22); + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + } + + .public-kummer-header h1 { + margin: 0; + font-size: clamp(1.6rem, 3vw, 2.35rem); + } + + .public-kummer-header p { + margin: 10px 0 0; + color: #64748b; + line-height: 1.55; + } + + .public-kummer-body { + padding: 26px 30px 30px; + display: grid; + gap: 16px; + } + + .public-kummer-form { + display: grid; + gap: 14px; + } + + .public-kummer-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + + .public-kummer-label { + display: grid; + gap: 6px; + font-weight: 700; + } + + .public-kummer-label span small { + color: #64748b; + font-weight: 500; + } + + .public-kummer-input, + .public-kummer-textarea { + width: 100%; + box-sizing: border-box; + border: 1px solid rgba(148, 164, 184, 0.55); + border-radius: 14px; + padding: 12px 13px; + font: inherit; + background: #ffffff; + color: #0f172a; + } + + .public-kummer-textarea { + min-height: 180px; + resize: vertical; + } + + .public-kummer-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + } + + .public-kummer-btn { + border: 0; + border-radius: 999px; + padding: 12px 18px; + font-weight: 800; + cursor: pointer; + background: #2563eb; + color: #ffffff; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.22); + } + + .public-kummer-btn:disabled { + opacity: 0.65; + cursor: wait; + } + + .public-kummer-alert { + border-radius: 16px; + padding: 12px 14px; + font-weight: 700; + } + + .public-kummer-alert.error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; + } + + .public-kummer-alert.success { + background: #ecfdf5; + color: #065f46; + border: 1px solid #bbf7d0; + } + + .public-kummer-note { + color: #64748b; + line-height: 1.5; + font-size: 0.95rem; + } + + @media (max-width: 720px) { + .public-kummer-page { padding: 16px 10px; } + .public-kummer-header, .public-kummer-body { padding-left: 18px; padding-right: 18px; } + .public-kummer-grid { grid-template-columns: 1fr; } + .public-kummer-actions { justify-content: stretch; } + .public-kummer-btn { width: 100%; } + } + `}),r.jsx("main",{className:"public-kummer-page",children:r.jsx("div",{className:"public-kummer-shell",children:r.jsxs("section",{className:"public-kummer-card",children:[r.jsxs("div",{className:"public-kummer-header",children:[r.jsx("h1",{children:"Kummerkasten"}),r.jsxs("p",{children:[e.intro_text||"Reichen Sie hier einen Vorschlag oder ein Anliegen für die Tagesordnung ein.",e.target_committee_name?` Ziel-Gremium: ${e.target_committee_name}.`:""]})]}),r.jsxs("div",{className:"public-kummer-body",children:[h?r.jsx("div",{className:"public-kummer-note",children:"Lade Kummerkasten…"}):null,m?r.jsx("div",{className:"public-kummer-alert error",children:m}):null,c?r.jsx("div",{className:"public-kummer-alert success",children:c}):null,!h&&!e.enabled?r.jsx("div",{className:"public-kummer-alert error",children:"Der Kummerkasten ist derzeit nicht freigeschaltet."}):null,!h&&e.enabled&&!b?r.jsxs("form",{className:"public-kummer-form",onSubmit:y,children:[r.jsxs("label",{className:"public-kummer-label",children:[r.jsx("span",{children:"Globales Kennwort"}),r.jsx("input",{className:"public-kummer-input",type:"password",autoComplete:"current-password",value:s,onChange:k=>a(k.target.value),required:!0,minLength:4})]}),r.jsx("p",{className:"public-kummer-note",children:"Das Kennwort muss mindestens 4 Zeichen lang sein."}),r.jsx("div",{className:"public-kummer-actions",children:r.jsx("button",{className:"public-kummer-btn",type:"submit",children:"Kummerkasten öffnen"})})]}):null,!h&&e.enabled&&b?r.jsxs("form",{className:"public-kummer-form",onSubmit:j,children:[r.jsxs("label",{className:"public-kummer-label",children:[r.jsx("span",{children:"Titel"}),r.jsx("input",{className:"public-kummer-input",value:o.title,onChange:k=>v("title",k.target.value),required:!0,minLength:3,maxLength:220,placeholder:"Kurzer Titel für den TOP"})]}),r.jsxs("label",{className:"public-kummer-label",children:[r.jsxs("span",{children:["Name ",r.jsx("small",{children:"(optional)"})]}),r.jsx("input",{className:"public-kummer-input",value:o.submitter_name,onChange:k=>v("submitter_name",k.target.value),maxLength:255,placeholder:"Ihr Name oder anonym lassen"})]}),r.jsxs("label",{className:"public-kummer-label",children:[r.jsxs("span",{children:["Kontaktmöglichkeit ",r.jsx("small",{children:"(optional)"})]}),r.jsx("input",{className:"public-kummer-input",value:o.submitter_contact,onChange:k=>v("submitter_contact",k.target.value),maxLength:255,placeholder:"E-Mail, Telefon oder leer lassen"})]}),r.jsxs("label",{className:"public-kummer-label",children:[r.jsx("span",{children:"Beschreibung"}),r.jsx("textarea",{className:"public-kummer-textarea",value:o.description,onChange:k=>v("description",k.target.value),required:!0,minLength:3,maxLength:8e3,placeholder:"Beschreiben Sie das Anliegen möglichst konkret. Daraus wird ein TOP-Bestandseintrag erzeugt."})]}),r.jsx("p",{className:"public-kummer-note",children:"Nach dem Absenden wird automatisch ein neuer Eintrag im TOP-Bestand angelegt. Die spätere Zuordnung zu einer Sitzung erfolgt intern."}),r.jsx("div",{className:"public-kummer-actions",children:r.jsx("button",{className:"public-kummer-btn",type:"submit",disabled:g,children:g?"Wird eingereicht…":"Als TOP einreichen"})})]}):null]})]})})})]})}function hc(){return{enabled:!1,password_set:!1,access_password:"",clear_password:!1,intro_text:"Reichen Sie hier vertraulich einen Vorschlag oder ein Anliegen für die Tagesordnung ein.",target_committee_id:""}}function l5(e){return e?new Intl.DateTimeFormat("de-DE",{dateStyle:"short",timeStyle:"short"}).format(new Date(e)):""}function c5(e){const t=String(e||"").trim();return t?t.length>=4:!0}function u5(){const[e,t]=f.useState(hc()),[n,i]=f.useState([]),[s,a]=f.useState([]),[o,l]=f.useState("/kummerkasten"),[c,u]=f.useState(!1),[m,d]=f.useState(""),[h,x]=f.useState(""),g=f.useMemo(()=>typeof window>"u"?o:`${window.location.origin}${o}`,[o]);async function p(){u(!0),x("");try{const y=await O.get("/kummerkasten/settings");t({...hc(),...y.settings||{}}),i(Array.isArray(y.committees)?y.committees:[]),a(Array.isArray(y.submissions)?y.submissions:[]),l(y.public_path||"/kummerkasten")}catch(y){x(y.message||"Kummerkasten-Einstellungen konnten nicht geladen werden.")}finally{u(!1)}}f.useEffect(()=>{p()},[]);function w(y,j){t(k=>({...k,[y]:j}))}async function b(y){y.preventDefault(),x(""),d("");const j=String(e.access_password||"").trim();if(!c5(j)){x("Das globale Kennwort muss mindestens 4 Zeichen lang sein.");return}if(e.enabled&&!e.password_set&&!j){x("Zum Einschalten des Kummerkastens muss zuerst ein Kennwort eingegeben werden.");return}if(e.enabled&&e.clear_password&&!j){x("Der Kummerkasten kann nicht eingeschaltet bleiben, wenn das gespeicherte Kennwort entfernt wird.");return}const k={enabled:!!e.enabled,access_password:j,clear_password:!!e.clear_password,intro_text:e.intro_text||"",target_committee_id:e.target_committee_id||null};try{const N=await O.put("/kummerkasten/settings",k);d("Kummerkasten-Einstellungen gespeichert."),t({...hc(),...N.settings||{},access_password:"",clear_password:!1}),await p()}catch(N){x(N.message||"Kummerkasten-Einstellungen konnten nicht gespeichert werden.")}}async function v(){try{await navigator.clipboard.writeText(g),d("Öffentliche URL kopiert.")}catch{x("URL konnte nicht in die Zwischenablage kopiert werden.")}}return r.jsxs(r.Fragment,{children:[r.jsx("style",{children:` + .kummer-admin-grid { display: grid; gap: 18px; } + .kummer-admin-two { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } + .kummer-admin-form { display: grid; gap: 14px; } + .kummer-admin-inline { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .kummer-admin-status { display: inline-flex; align-items: center; gap: 8px; border-radius: 999px; padding: 6px 10px; font-weight: 800; } + .kummer-admin-status.on { background: #ecfdf5; color: #065f46; } + .kummer-admin-status.off { background: #fef2f2; color: #991b1b; } + .kummer-admin-url { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + .kummer-admin-url code { background: rgba(15, 23, 42, .06); padding: 9px 11px; border-radius: 12px; word-break: break-all; } + .kummer-admin-note { color: var(--muted); line-height: 1.45; margin: 0; } + .kummer-admin-form .check-row { display: flex; align-items: center; gap: 10px; font-weight: 700; } + @media (max-width: 980px) { .kummer-admin-two, .kummer-admin-inline { grid-template-columns: 1fr; } } + `}),r.jsxs("div",{className:"kummer-admin-grid",children:[h?r.jsx("div",{className:"alert error",children:h}):null,m?r.jsx("div",{className:"alert success",children:m}):null,r.jsx(de,{title:"Kummerkasten",subtitle:"Fast öffentliche Seite mit globalem Kennwort. Eingereichte Anliegen werden automatisch als TOP-Bestandseinträge erzeugt.",actions:r.jsx("button",{className:"btn secondary",type:"button",onClick:p,disabled:c,children:c?"Lade…":"Aktualisieren"}),children:r.jsxs("div",{className:"kummer-admin-url",children:[r.jsx("span",{className:`kummer-admin-status ${e.enabled?"on":"off"}`,children:e.enabled?"Eingeschaltet":"Ausgeschaltet"}),r.jsx("code",{children:g}),r.jsx("button",{className:"mini-btn",type:"button",onClick:v,children:"URL kopieren"})]})}),r.jsxs("div",{className:"kummer-admin-two",children:[r.jsx(de,{title:"Einstellungen",subtitle:"Zum Einschalten muss ein Kennwort gesetzt sein. Das Kennwort wird nur als Hash gespeichert.",children:r.jsxs("form",{className:"kummer-admin-form",onSubmit:b,children:[r.jsxs("label",{className:"check-row",children:[r.jsx("input",{type:"checkbox",checked:!!e.enabled,onChange:y=>w("enabled",y.target.checked)}),r.jsx("span",{children:"Kummerkasten-Seite einschalten"})]}),r.jsxs("label",{children:[r.jsxs("span",{children:["Globales Kennwort ",e.password_set?"(bereits gesetzt)":""]}),r.jsx("input",{className:"input",type:"password",value:e.access_password||"",onChange:y=>w("access_password",y.target.value),placeholder:e.password_set?"Leer lassen, um Kennwort beizubehalten":"Kennwort setzen",autoComplete:"new-password",minLength:4,maxLength:200})]}),r.jsx("p",{className:"kummer-admin-note",children:"Passwortregel: Das globale Kennwort muss mindestens 4 Zeichen lang sein. Zum Einschalten muss ein Kennwort gespeichert sein. Wenn Sie ein neues Kennwort eintragen, wird es beim Speichern direkt übernommen."}),e.password_set?r.jsxs("label",{className:"check-row",children:[r.jsx("input",{type:"checkbox",checked:!!e.clear_password,onChange:y=>w("clear_password",y.target.checked)}),r.jsx("span",{children:"Gespeichertes Kennwort entfernen"})]}):null,r.jsxs("label",{children:[r.jsx("span",{children:"Einleitungstext der öffentlichen Seite"}),r.jsx("textarea",{className:"input textarea",value:e.intro_text||"",onChange:y=>w("intro_text",y.target.value)})]}),r.jsxs("label",{children:[r.jsx("span",{children:"Ziel-Gremium / Ausschuss"}),r.jsxs("select",{className:"input",value:e.target_committee_id||"",onChange:y=>w("target_committee_id",y.target.value),children:[r.jsx("option",{value:"",children:"Kein Ziel-Gremium"}),n.map(y=>r.jsx("option",{value:y.id,children:y.name},y.id))]})]}),r.jsx("p",{className:"kummer-admin-note",children:"Einreichungen landen im TOP-Bestand ohne Sitzungszuordnung. Die spätere Zuordnung zu einer Sitzung erfolgt wie bei normalen TOP-Bestandseinträgen."}),r.jsx("button",{className:"btn",type:"submit",children:"Einstellungen speichern"})]})}),r.jsx(de,{title:"Letzte Einreichungen",subtitle:"Die erzeugten TOPs sind zusätzlich im TOP-Bestand sichtbar.",children:r.jsx("div",{className:"table-wrap",children:r.jsxs("table",{className:"table",children:[r.jsx("thead",{children:r.jsxs("tr",{children:[r.jsx("th",{children:"Zeitpunkt"}),r.jsx("th",{children:"Einreichung"}),r.jsx("th",{children:"TOP"})]})}),r.jsxs("tbody",{children:[s.map(y=>r.jsxs("tr",{children:[r.jsx("td",{children:l5(y.created_at)}),r.jsxs("td",{children:[r.jsx("strong",{children:y.title}),r.jsx("br",{}),r.jsxs("small",{children:[y.submitter_name||"anonym",y.target_committee_name?` · ${y.target_committee_name}`:""]})]}),r.jsxs("td",{children:["#",y.agenda_item_id,r.jsx("br",{}),r.jsx("small",{children:y.agenda_title})]})]},y.id)),s.length?null:r.jsx("tr",{children:r.jsx("td",{colSpan:"3",children:"Noch keine Einreichungen vorhanden."})})]})]})})})]})]})]})}function d5(){return r.jsx("div",{className:"alert error",children:"Keine Berechtigung für diese Seite."})}function f5({user:e,path:t,children:n}){return Jg(e,t)?n:r.jsx(d5,{})}function Ee(e,t,n){return r.jsx(f5,{user:e,path:t,children:n})}function m5(){const[e,t]=f.useState(()=>{const a=localStorage.getItem("br_user");return a?JSON.parse(a):null}),n=$o(),i=Jt();if(f.useEffect(()=>{if(!e)return;let a=!1;return O.get("/permissions/access").then(o=>{if(a)return;const l={...e,...o};t(l),localStorage.setItem("br_user",JSON.stringify(l))}).catch(()=>{}),()=>{a=!0}},[e==null?void 0:e.id]),i.pathname==="/kummerkasten"||i.pathname==="/public-documents")return r.jsxs(cm,{children:[r.jsx(je,{path:"/kummerkasten",element:r.jsx(o5,{})}),r.jsx(je,{path:"/public-documents",element:r.jsx(n5,{})}),r.jsx(je,{path:"*",element:r.jsx(ca,{to:i.pathname==="/public-documents"?"/public-documents":"/kummerkasten",replace:!0})})]});if(!e)return r.jsx(A1,{onLogin:a=>{t(a),n("/")}});Vo(localStorage.getItem("br_access_token")||window.__br_access_token||"");function s(){NC(),t(null),n("/")}return r.jsx(N1,{user:e,onLogout:s,children:r.jsxs(cm,{children:[r.jsx(je,{path:"/",element:Ee(e,"/",r.jsx(I1,{}))}),r.jsx(je,{path:"/committees",element:Ee(e,"/committees",r.jsx(M1,{}))}),r.jsx(je,{path:"/users",element:Ee(e,"/users",r.jsx(q1,{}))}),r.jsx(je,{path:"/cases",element:Ee(e,"/cases",r.jsx(Y1,{}))}),r.jsx(je,{path:"/deadlines",element:Ee(e,"/deadlines",r.jsx(S2,{}))}),r.jsx(je,{path:"/meetings",element:Ee(e,"/meetings",r.jsx(O2,{}))}),r.jsx(je,{path:"/agenda-items",element:Ee(e,"/agenda-items",r.jsx(F2,{}))}),r.jsx(je,{path:"/decisions",element:Ee(e,"/decisions",r.jsx(H2,{}))}),r.jsx(je,{path:"/tasks",element:Ee(e,"/tasks",r.jsx(X2,{}))}),r.jsx(je,{path:"/calendar",element:Ee(e,"/calendar",r.jsx(IC,{}))}),r.jsx(je,{path:"/documents",element:Ee(e,"/documents",r.jsx(eC,{}))}),r.jsx(je,{path:"/comments",element:Ee(e,"/comments",r.jsx(sC,{}))}),r.jsx(je,{path:"/reports",element:Ee(e,"/reports",r.jsx(MC,{}))}),r.jsx(je,{path:"/finance",element:Ee(e,"/finance",r.jsx(WC,{}))}),r.jsx(je,{path:"/finance-tax-codes",element:Ee(e,"/finance-tax-codes",r.jsx(QC,{}))}),r.jsx(je,{path:"/departments",element:Ee(e,"/departments",r.jsx(YC,{}))}),r.jsx(je,{path:"/document-categories",element:Ee(e,"/document-categories",r.jsx(e5,{}))}),r.jsx(je,{path:"/public-documents-admin",element:Ee(e,"/public-documents-admin",r.jsx(i5,{}))}),r.jsx(je,{path:"/templates",element:Ee(e,"/templates",r.jsx(uC,{}))}),r.jsx(je,{path:"/formular-editor",element:r.jsx(ca,{to:"/templates",replace:!0})}),r.jsx(je,{path:"/form-editor",element:r.jsx(ca,{to:"/templates",replace:!0})}),r.jsx(je,{path:"/notifications",element:Ee(e,"/notifications",r.jsx(fC,{}))}),r.jsx(je,{path:"/permissions",element:Ee(e,"/permissions",r.jsx(s5,{}))}),r.jsx(je,{path:"/kummerkasten-admin",element:Ee(e,"/kummerkasten-admin",r.jsx(u5,{}))}),r.jsx(je,{path:"/security",element:Ee(e,"/security",r.jsx(CC,{}))}),r.jsx(je,{path:"/audit",element:Ee(e,"/audit",r.jsx(zC,{}))}),r.jsx(je,{path:"*",element:r.jsx(ca,{to:"/",replace:!0})})]})})}gc.createRoot(document.getElementById("root")).render(r.jsx(Fp.StrictMode,{children:r.jsx(x1,{children:r.jsx(m5,{})})})); diff --git a/frontend/dist/index.html b/frontend/dist/index.html new file mode 100644 index 0000000..168c9cb --- /dev/null +++ b/frontend/dist/index.html @@ -0,0 +1,13 @@ + + + + + + BR-Office Secure + + + + +
+ + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0a86549 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + BR-Office Secure + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f1db993 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3489 @@ +{ + "name": "br-office-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "br-office-frontend", + "version": "1.0.0", + "dependencies": { + "@fluentui/react-components": "^9.73.8", + "@fluentui/react-icons": "^2.0.295", + "@simplewebauthn/browser": "^13.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.2", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/devtools": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@floating-ui/devtools/-/devtools-0.2.3.tgz", + "integrity": "sha512-ZTcxTvgo9CRlP7vJV62yCxdqmahHTGpSTi5QaTDgGoyQq0OyjaVZhUhXv/qdkQFOI3Sxlfmz0XGG4HaZMsDf8Q==", + "license": "MIT", + "peerDependencies": { + "@floating-ui/dom": "^1.0.0" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@fluentui/keyboard-keys": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@fluentui/keyboard-keys/-/keyboard-keys-9.0.8.tgz", + "integrity": "sha512-iUSJUUHAyTosnXK8O2Ilbfxma+ZyZPMua5vB028Ys96z80v+LFwntoehlFsdH3rMuPsA8GaC1RE7LMezwPBPdw==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/priority-overflow": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@fluentui/priority-overflow/-/priority-overflow-9.3.0.tgz", + "integrity": "sha512-yaBC0R4e+4ZlCWDulB5S+xBrlnLwfzdg68GaarCqQO8OHjLg7Ah05xTj7PsAYcoHeEg/9vYeBwGXBpRO8+Tjqw==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/react-accordion": { + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-accordion/-/react-accordion-9.11.0.tgz", + "integrity": "sha512-mEy73hbJM53tMj3MWqm3ajbBxj48uubnJjumVKI8Z/eXHS8L3GzUy5rf/gUH26xSR2Tl+edpFhYB8PFbJDIKKw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-alert": { + "version": "9.0.0-beta.139", + "resolved": "https://registry.npmjs.org/@fluentui/react-alert/-/react-alert-9.0.0-beta.139.tgz", + "integrity": "sha512-R9r4dwwpWpgFmB8wVeWqipjUh/e6lyacnerX39HtVdgcG/PE+kpdHjKGiy8MAD+BGYCzrUxKNhXTQDlpXasJ1Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-avatar": "^9.11.1", + "@fluentui/react-button": "^9.9.1", + "@fluentui/react-icons": "^2.0.239", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-aria": { + "version": "9.17.11", + "resolved": "https://registry.npmjs.org/@fluentui/react-aria/-/react-aria-9.17.11.tgz", + "integrity": "sha512-K9nz+Wn5JliCpG6bIYYPXvKmpOql+w9uyzmYNYkYQ6QHgoCpph7XUFx1HCtsJm2PPNi8WO8g0ZV9jojdGKl1Tg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-utilities": "^9.26.3", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-avatar": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-avatar/-/react-avatar-9.11.1.tgz", + "integrity": "sha512-y1T67rVQQ/D4FAod8F4crXo9funaptscRIiW81LAsbN82fFVexMPQ9GmXooQQvn6ILvjJtf9IyvSJ195qDsyag==", + "license": "MIT", + "dependencies": { + "@fluentui/react-badge": "^9.5.2", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-popover": "^9.14.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.10.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-badge": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-badge/-/react-badge-9.5.2.tgz", + "integrity": "sha512-+UPAK9dCD6Gx+LWr6vqKMIbYOPf7oXX+GXRtCJ5fekCTHD0VgIWuIMuEtxVrHpJQdb2VNaZadY8/dMomk2JaXw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-breadcrumb": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-breadcrumb/-/react-breadcrumb-9.4.1.tgz", + "integrity": "sha512-XgUB1yv04GdcL/6kUo6kh+BaN4df1A/Ds/fL1QxNrm5E26Vmvvlc0LN0WV/qb5qhKx0NwhtIXgOZHjfzyt7iCA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-button": "^9.9.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-link": "^9.8.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-button": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-button/-/react-button-9.9.1.tgz", + "integrity": "sha512-WNzpseiVbqEKKevTkAnyHNoK/8ktYPE6rvf31gGvSDnBBclqfrn4PSYG2ppi+Z7abmClnaNFxpp1OHuOoVQ8Bg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-card": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-card/-/react-card-9.6.1.tgz", + "integrity": "sha512-KBijjAxi0mBDSgnA1OCglqAVWc+Q0L7A2wCokszX/53oqfJPSvWxWFma7esz9b5MF/kdRrAR0vmy7MiosepNLQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-text": "^9.6.16", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-carousel": { + "version": "9.9.7", + "resolved": "https://registry.npmjs.org/@fluentui/react-carousel/-/react-carousel-9.9.7.tgz", + "integrity": "sha512-lummYk+tASL/rM/SXWruoqhUAyJjTiOMgiCz55ncE3q2pSZe/EbsV5WfRw5B3y7pHX8xLusN831TBgUthj/sUw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-button": "^9.9.1", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.10.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "embla-carousel": "^8.5.1", + "embla-carousel-autoplay": "^8.5.1", + "embla-carousel-fade": "^8.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-checkbox": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-checkbox/-/react-checkbox-9.6.1.tgz", + "integrity": "sha512-Rsf3TmcNrzLuHan9lyUFUmMZnNyvS7DV8C4Vc9lZnZTFRBo94GRMGzu0BcWKFbr3cCDT/r5RmIyQYz0kc7Jd2w==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-label": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-color-picker": { + "version": "9.2.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-color-picker/-/react-color-picker-9.2.16.tgz", + "integrity": "sha512-+H8Ea8dwoSeUCTLRpUiGLrRsNvBnlHplnwJPU0isp8jdAfrIM/savZTLj6o4rqNFpNHQqAXxGwNuUV9YfHoJuQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.3.4", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-combobox": { + "version": "9.17.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-combobox/-/react-combobox-9.17.1.tgz", + "integrity": "sha512-ezgt6tfOKd3wlG6IHvWl0TPNPpfHRtnEwC2kuqHYH/r1nMNp9edFi8Ya3+1eM7oxai19XW0swt69GPwRu51FVQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-components": { + "version": "9.73.8", + "resolved": "https://registry.npmjs.org/@fluentui/react-components/-/react-components-9.73.8.tgz", + "integrity": "sha512-JG4KQjEvRRfPlh4yt6Rv1/k87ydM2y49r5XPNCnuYHahA7kEM+dY8JdOI7n7FW8bdcvZ7qt4smDrQ2XcPfmxlA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-accordion": "^9.11.0", + "@fluentui/react-alert": "9.0.0-beta.139", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-avatar": "^9.11.1", + "@fluentui/react-badge": "^9.5.2", + "@fluentui/react-breadcrumb": "^9.4.1", + "@fluentui/react-button": "^9.9.1", + "@fluentui/react-card": "^9.6.1", + "@fluentui/react-carousel": "^9.9.7", + "@fluentui/react-checkbox": "^9.6.1", + "@fluentui/react-color-picker": "^9.2.16", + "@fluentui/react-combobox": "^9.17.1", + "@fluentui/react-dialog": "^9.18.0", + "@fluentui/react-divider": "^9.7.1", + "@fluentui/react-drawer": "^9.12.0", + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-image": "^9.4.1", + "@fluentui/react-infobutton": "9.0.0-beta.115", + "@fluentui/react-infolabel": "^9.4.20", + "@fluentui/react-input": "^9.8.2", + "@fluentui/react-label": "^9.4.1", + "@fluentui/react-link": "^9.8.1", + "@fluentui/react-list": "^9.6.14", + "@fluentui/react-menu": "^9.24.1", + "@fluentui/react-message-bar": "^9.7.0", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-nav": "^9.3.24", + "@fluentui/react-overflow": "^9.7.2", + "@fluentui/react-persona": "^9.7.3", + "@fluentui/react-popover": "^9.14.2", + "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-progress": "^9.5.1", + "@fluentui/react-provider": "^9.22.16", + "@fluentui/react-radio": "^9.6.2", + "@fluentui/react-rating": "^9.4.1", + "@fluentui/react-search": "^9.4.2", + "@fluentui/react-select": "^9.5.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-skeleton": "^9.7.2", + "@fluentui/react-slider": "^9.6.2", + "@fluentui/react-spinbutton": "^9.6.2", + "@fluentui/react-spinner": "^9.8.2", + "@fluentui/react-swatch-picker": "^9.5.2", + "@fluentui/react-switch": "^9.7.2", + "@fluentui/react-table": "^9.19.15", + "@fluentui/react-tabs": "^9.12.1", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tag-picker": "^9.8.6", + "@fluentui/react-tags": "^9.8.1", + "@fluentui/react-teaching-popover": "^9.6.21", + "@fluentui/react-text": "^9.6.16", + "@fluentui/react-textarea": "^9.7.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-toast": "^9.7.17", + "@fluentui/react-toolbar": "^9.8.0", + "@fluentui/react-tooltip": "^9.10.1", + "@fluentui/react-tree": "^9.16.0", + "@fluentui/react-utilities": "^9.26.3", + "@fluentui/react-virtualizer": "9.0.0-alpha.112", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-context-selector": { + "version": "9.2.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-context-selector/-/react-context-selector-9.2.16.tgz", + "integrity": "sha512-D+/X2liT+eZe0rzXbwddPH333ml2SXz71biR13aeyGJQr8+W+icMAIsYhpwk0CC3KtJ3f1/CLTm7vcIrvqsJ4g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-utilities": "^9.26.3", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0", + "scheduler": ">=0.19.0" + } + }, + "node_modules/@fluentui/react-dialog": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-dialog/-/react-dialog-9.18.0.tgz", + "integrity": "sha512-i+V2o0NJ1itjVADJFov5AR/JetpD2hCMiLye0vfi3/XsFMgEPZnGzILVxPCO/ovULTiCyThcL1UvY0d/PYrZfA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-divider": { + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-divider/-/react-divider-9.7.1.tgz", + "integrity": "sha512-ptymE6iADb/ugezulaMeoAfGxKSwOjHEHBh8N1ydOR3AoOxsSUPkvoPC0mReO/yV5Nas7pz5s5VuJTspmFz0hA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-drawer": { + "version": "9.12.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-drawer/-/react-drawer-9.12.0.tgz", + "integrity": "sha512-PUXeXUH6JqwpjqYphHesHl75UAFSvxQJQqrevMFHE78ZF0Cqn59Xpa+8hGwRSuoRcYa90jjfHzJOOjN0iNM2iA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-dialog": "^9.18.0", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-field": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-field/-/react-field-9.5.1.tgz", + "integrity": "sha512-u8J2d3AWb4yZXvy/mQd95y2lTon890RfybBTCbeBUzApGMI/77WqT5pRJ+zTM3lOMToPHVKylchNFusMpJaX9w==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-label": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-icons": { + "version": "2.0.325", + "resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.325.tgz", + "integrity": "sha512-dbmpGMwyCFfkFdMSTEFnbNMXKDomPpqbzXVKRKM0EEQrcwOoQvm08d+SV+WWH11kni26SrgEtB9ycldD+fr92w==", + "license": "MIT", + "dependencies": { + "@griffel/react": "^1.6.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-image": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-image/-/react-image-9.4.1.tgz", + "integrity": "sha512-yNd2Wq2xq952UUEVBkWeEmM7bTKdWx6BnsHPYRf0kdTADox2PquApYXsI1xw2pnAh3GSjARrGi9Eto0qxouLqA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-infobutton": { + "version": "9.0.0-beta.115", + "resolved": "https://registry.npmjs.org/@fluentui/react-infobutton/-/react-infobutton-9.0.0-beta.115.tgz", + "integrity": "sha512-b+4B0ODzPEb4jNaW9HdT6VVt3CL5FgPL2yuKzALBsYVl3udJdFpyxHsZEPf3JrVTBL/rgF2fRI1iAioX6Fl7DA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.237", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-label": "^9.4.1", + "@fluentui/react-popover": "^9.14.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-infolabel": { + "version": "9.4.20", + "resolved": "https://registry.npmjs.org/@fluentui/react-infolabel/-/react-infolabel-9.4.20.tgz", + "integrity": "sha512-w4FOnNP+CtbVdKBEO6wXAcmOuPZWvmB/BJj+7J/8cLAQm7+4kQgitFHncU6rtFhPdGbikVoBf707/0R1mA4aIg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-label": "^9.4.1", + "@fluentui/react-popover": "^9.14.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-input": { + "version": "9.8.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-input/-/react-input-9.8.2.tgz", + "integrity": "sha512-t9zmqZR4bqeRjpWuCGfI4yrtPoCXFiK2XO4BoV5nNwAesglgz4+Vtso4YXst9QYEAazHtKI73YFJf1mn55hCuA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-jsx-runtime": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-jsx-runtime/-/react-jsx-runtime-9.4.2.tgz", + "integrity": "sha512-y3o0PBg2qzSdvgxDm7rH9BWq7E1h/eUWS+IhjQhd9dRpme6Py01+OLOglHojM5Tc9QjIp2Rjy2mFWBHXOR+8mw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-utilities": "^9.26.3", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-label": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-label/-/react-label-9.4.1.tgz", + "integrity": "sha512-4O3cPX6dSJVBKlIEbznjJ08utEc98lKbZz/6MZTTQfFgYl0TxAhxEDsIIIyNjj0Xy9eJpqubJsaswucWXTG/qg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-link": { + "version": "9.8.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-link/-/react-link-9.8.1.tgz", + "integrity": "sha512-ZxrCeX4pMWHujdmYV8b0QW0ztLtu0rHHvRNx67Y3WqSijVyij8QtNNiZ/nab+UDNlz9t8QIXKdWQgYj1uKDpMg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-list": { + "version": "9.6.14", + "resolved": "https://registry.npmjs.org/@fluentui/react-list/-/react-list-9.6.14.tgz", + "integrity": "sha512-B1mUQFvJOUlZysSduVnATNZggrGpgEWnW9ZSJAZ17LM0+9nWEQRi40jpUGI/d3PGKHt5O2df78s+1nEPAk0L6A==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-checkbox": "^9.6.1", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-menu": { + "version": "9.24.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-menu/-/react-menu-9.24.1.tgz", + "integrity": "sha512-NLB5EhzKFiwax3O5JTRTtsqdEFDGEXzEuP/suyxNAaaQsIuXygo//Rmdq6dSn7GybTpEOZHKxYDyyG7dj+a4YA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-message-bar": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-message-bar/-/react-message-bar-9.7.0.tgz", + "integrity": "sha512-ICFDxZ62r5OG97/FcfK1EfJPxGlyDNyFixLD/a3gOREvEcT/hyZgnlUM9Y30u92HjxChx2SwGWnv3iaQPsvToQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-button": "^9.9.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-link": "^9.8.1", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-motion": { + "version": "9.15.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-motion/-/react-motion-9.15.0.tgz", + "integrity": "sha512-ZNQHYzE6MRbLQFT08/mrcqQ9k7F5niktRP93X1v/kmwKfPjvdDofySfbhQXQs3zQw600690C9rfJTKUd3h+zlg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-utilities": "^9.26.3", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-motion-components-preview": { + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-motion-components-preview/-/react-motion-components-preview-0.15.4.tgz", + "integrity": "sha512-gAHPlyEYylZzUSGwc68VaB+vO8CTX6tgIA3d2+jFrpcwvXZjsdCpF1w1zK1+hTuiipmEaZLZyBz0e0CKH2+3XQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-motion": "*", + "@fluentui/react-utilities": "*", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-nav": { + "version": "9.3.24", + "resolved": "https://registry.npmjs.org/@fluentui/react-nav/-/react-nav-9.3.24.tgz", + "integrity": "sha512-OlB5k5Zev5VNjSRfJvJLO09Hjcv2UHAjLpSVa6gKHx+1NqqSJWZeDLSF7r+/nyZ4CWP5jWZYq7whEu3WvzdVZw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-button": "^9.9.1", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-divider": "^9.7.1", + "@fluentui/react-drawer": "^9.12.0", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.10.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-overflow": { + "version": "9.7.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-overflow/-/react-overflow-9.7.2.tgz", + "integrity": "sha512-5PA67LgnVmbbOzBN2H5gH3OvSVy1373VJfsHq2+6TLCfm+LXAkWBoFwvBuFI7HsMYae9A0FVlgX7gTsKVfMddw==", + "license": "MIT", + "dependencies": { + "@fluentui/priority-overflow": "^9.3.0", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-persona": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-persona/-/react-persona-9.7.3.tgz", + "integrity": "sha512-OY3xpSD6l4NDdeKihriC+H0q6P1CA2xyZ+pe/WwfKPnatxs2BALoRFtDQduMO7AK/j0w7UAxnaZrvEeftLen2g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-avatar": "^9.11.1", + "@fluentui/react-badge": "^9.5.2", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-popover": { + "version": "9.14.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-popover/-/react-popover-9.14.2.tgz", + "integrity": "sha512-EDvzLkT98/vcCSGrcZWUACGsvLjrHin0Xf9eowMQKiiHFWbu8HNRmr7W2XB9Eja1W5HSIK6+mV8ro9zrLibG4w==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-portal": { + "version": "9.8.12", + "resolved": "https://registry.npmjs.org/@fluentui/react-portal/-/react-portal-9.8.12.tgz", + "integrity": "sha512-+WH0wH/5lsodGP6Mam1alHXpkMCYA5uMcnF98RVOs7/GR69KiFcza1mCnvPJUaJ55AfwLuz/xLxuWdWgQnUdMQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-positioning": { + "version": "9.22.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-positioning/-/react-positioning-9.22.1.tgz", + "integrity": "sha512-/r1BHQKr/WCjEM8UGloiq7bWWBSYB/Uqt7D1sAF9EHd968VH07cAN3RMVKmWWjeJO31rstOZHdgcz0WHhFF+2Q==", + "license": "MIT", + "dependencies": { + "@floating-ui/devtools": "^0.2.3", + "@floating-ui/dom": "^1.6.12", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-progress": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-progress/-/react-progress-9.5.1.tgz", + "integrity": "sha512-EXJ/Bp67d5+bXPNpPabxdtXUgCMTtvYrBoKtIS6wE5KeUzaek/rgQ3v5wnGfbuLnJ4J/kj+n7XQEc9fhoFPy9w==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-provider": { + "version": "9.22.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-provider/-/react-provider-9.22.16.tgz", + "integrity": "sha512-S77n5ASUWE/V1I6lX09CrHm4TAKSGENhIrKz9qMKDv2Vrq44/j3eGBLz12k8IW4TJVu9nwGwst9kBpCT+3WHpA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/core": "^1.16.0", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-radio": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-radio/-/react-radio-9.6.2.tgz", + "integrity": "sha512-Sp2us4eWRopUKOMCQw5/iks7euPKY6FeesBCCUIVGBg5VKZf2/CfEtbCa9hMjn4D4PCHGivnUTf23t238mvvnw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-label": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-rating": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-rating/-/react-rating-9.4.1.tgz", + "integrity": "sha512-DfWipzrT44j+yaShtfHz96/vHEa5ut5IR1kobrO0bSqAcpetOn327gFeY+sG/W6xzork/STcy/T836yK8A2+DQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-search": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-search/-/react-search-9.4.2.tgz", + "integrity": "sha512-PIb50euHoMsKWLqFymf8wo/+z1jrx1MB7uNuhjNT5DvwTP4VYAy5EtRCSwVRyxWNSaWSL6iy6dDy517EQE96mA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-input": "^9.8.2", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-select": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-select/-/react-select-9.5.1.tgz", + "integrity": "sha512-8GocQKiUHEUlAks6zA0HbGGSF2lpjuSZuxPzIBqTyuWof8vFiK6eFAcSXb0hTYIVH3RsTihhfc6G3NRnHoBrzg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-shared-contexts": { + "version": "9.26.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-shared-contexts/-/react-shared-contexts-9.26.2.tgz", + "integrity": "sha512-upKXkwlIp5oIhELr4clAZXQkuCd4GDXM6GZEz8BOmRO+PnxyqmycCXvxDxsmi6XN+0vkGM4joiIgkB14o/FctQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-theme": "^9.2.1", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-skeleton": { + "version": "9.7.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-skeleton/-/react-skeleton-9.7.2.tgz", + "integrity": "sha512-PrUgdSGDAZw9FIP5NyvPoPfHDe2N9VxMyBfyTwWfZVg03dzRfnE3vEqr7N5xyfv4JsRs6u1xSqVn/0jdS0IEMQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-slider": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-slider/-/react-slider-9.6.2.tgz", + "integrity": "sha512-lVavtTg8eqovfRokeYDk4popwCi8nuicacJ/HZdF3ni5e3y/2WT/bVP0eErS/GvC7+90ACQQ8uxdr4sjjY/HWA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-spinbutton": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-spinbutton/-/react-spinbutton-9.6.2.tgz", + "integrity": "sha512-P4vvJH7P5yHPFAv6aSo3dZxtErN62DiRJN+nEKS+/XBoRGsOGQdqyyx5Q/PQKOmyQrtwuZdXNHUjcyv8b50T2w==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-spinner": { + "version": "9.8.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-spinner/-/react-spinner-9.8.2.tgz", + "integrity": "sha512-0LxykLJGUD/I3XEeIXAWznwdg9XRe0piaByR0nLFOOV3UPwkVc2w5UdPhy2Y0NZDvtPHbNaMCuQAq82+bxg/0w==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-label": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-swatch-picker": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-swatch-picker/-/react-swatch-picker-9.5.2.tgz", + "integrity": "sha512-DK6UU9OJY9XaGBPU2ROx+B5/7XdwVtHBdVthOAptyKSsYGOdQt5AQqg3ZOXH6r5WYbMRQDuP2OZ2iKtwidFCVQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-switch": { + "version": "9.7.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-switch/-/react-switch-9.7.2.tgz", + "integrity": "sha512-j3e5se+3d+befV9MytkxxvJ9nHZOeZ7thKDTF4YVSYf6kcNx9eOlLvPgDjhGO08gzngO4B7aaprhDN7DJc3W1g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-label": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-table": { + "version": "9.19.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-table/-/react-table-9.19.15.tgz", + "integrity": "sha512-OdQ2Nwx2nAlPMlJeyAFrKa3Zy5Ya/H87OU8MvtFJhabM/FkHiZoli/DO1mavVI+jqavOlJuQWmJ55D6jjuGa7g==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-avatar": "^9.11.1", + "@fluentui/react-checkbox": "^9.6.1", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-radio": "^9.6.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tabs": { + "version": "9.12.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-tabs/-/react-tabs-9.12.1.tgz", + "integrity": "sha512-WvzOtpC6C/7Mo5X+xmE+3stpCbx2iH9BqrEN5KuGrsHJ78DjMDeabYeL90vlrHBdP4VlTpwdORBui/jtWkxnmQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tabster": { + "version": "9.26.14", + "resolved": "https://registry.npmjs.org/@fluentui/react-tabster/-/react-tabster-9.26.14.tgz", + "integrity": "sha512-WibgoF67hl6BXfmsY6RSIWSHadeMP/6EDG9gAacfHlwKvK0+FiHp5ernwuXTPAmu2kiHicn2qUZ8EteCFiFryg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "keyborg": "^2.6.0", + "tabster": "^8.5.5" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tag-picker": { + "version": "9.8.6", + "resolved": "https://registry.npmjs.org/@fluentui/react-tag-picker/-/react-tag-picker-9.8.6.tgz", + "integrity": "sha512-sOZ+wBA3hgGhKrOP7wbjB2yRvAxjcRXtcj1jDTrtSkaDPXb3K0nGmjiqp2mve995ps3wvCGnNKK4EurX842ZbA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-combobox": "^9.17.1", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-tags": "^9.8.1", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tags": { + "version": "9.8.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-tags/-/react-tags-9.8.1.tgz", + "integrity": "sha512-6ZTW78fu5eWByKHIM3i+raDrX3hwfZ67ONfZ8wEUXfZHowskxqpMHI8Gw7IAMWkC1scgLqEnht8TnHvZgjo7Ug==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-avatar": "^9.11.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-teaching-popover": { + "version": "9.6.21", + "resolved": "https://registry.npmjs.org/@fluentui/react-teaching-popover/-/react-teaching-popover-9.6.21.tgz", + "integrity": "sha512-V86zLB1B8xu3U/02FvvMdsJP+ZC9l3vT9bQ2Gr7hZHxJ4/0NLpVcrYSBFHpON9e/WK3p+A5b5V96p86b5Pavlg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-button": "^9.9.1", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-popover": "^9.14.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-text": { + "version": "9.6.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-text/-/react-text-9.6.16.tgz", + "integrity": "sha512-ZzCSJWQ6LrVuPqA6sqNEZaXbLvhi2NxBOtlMudWlqYzidLQp038d7mMGSzNnhyeblg+gj+bOVE2eOgWFuVHGYw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-textarea": { + "version": "9.7.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-textarea/-/react-textarea-9.7.2.tgz", + "integrity": "sha512-awlkZoW81WaOqSoXTT9rZs3mTAzCCHnC9eAm6J8ZxI5+ASX07BTolBfZ82it5wxOHI5GMDfbFOl+xIy8uAMdzA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.5.1", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-theme": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-theme/-/react-theme-9.2.1.tgz", + "integrity": "sha512-lJxfz7LmmglFz+c9C41qmMqaRRZZUPtPPl9DWQ79vH+JwZd4dkN7eA78OTRwcGCOTPEKoLTX72R+EFaWEDlX+w==", + "license": "MIT", + "dependencies": { + "@fluentui/tokens": "1.0.0-alpha.23", + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/react-toast": { + "version": "9.7.17", + "resolved": "https://registry.npmjs.org/@fluentui/react-toast/-/react-toast-9.7.17.tgz", + "integrity": "sha512-DWA5EARWSo1k19iWAulLpKrcUHT+Dq/Bw9zfdpoQEWWybrAZwyN7WiYFkBjKCQRxSp10OjLASSQK91CXfb1wJA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-toolbar": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-toolbar/-/react-toolbar-9.8.0.tgz", + "integrity": "sha512-EIe+QWOaFR1pZzENefsFTmjxGa2yJb4A/by3kGuGqSjx7isqPUllPq0/kFQzfoUYgPDJbJtQ+KyuRDekTL0QpQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-button": "^9.9.1", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-divider": "^9.7.1", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-radio": "^9.6.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tooltip": { + "version": "9.10.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-tooltip/-/react-tooltip-9.10.1.tgz", + "integrity": "sha512-IPHBFjqGhaaMDhLt5NSNOE9LEpDOpT7qgEqNz+Mlflo0A4qI2LW/EnkNop7IRmX/bC88A+wUtEONTjjR87dNBw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-portal": "^9.8.12", + "@fluentui/react-positioning": "^9.22.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tree": { + "version": "9.16.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-tree/-/react-tree-9.16.0.tgz", + "integrity": "sha512-c+Q4AVaYk9U69aGDgmJVNne+CtWKS75YIfGoxs6+9+wE2Wqz4T0E+gE1ng7ARCQQgI7E2NEJlot6DuI6nYYrRw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.11", + "@fluentui/react-avatar": "^9.11.1", + "@fluentui/react-button": "^9.9.1", + "@fluentui/react-checkbox": "^9.6.1", + "@fluentui/react-context-selector": "^9.2.16", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-motion": "^9.15.0", + "@fluentui/react-motion-components-preview": "^0.15.4", + "@fluentui/react-radio": "^9.6.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.14", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-utilities": { + "version": "9.26.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-utilities/-/react-utilities-9.26.3.tgz", + "integrity": "sha512-bXB3jMm/RroT8c5eGZkijkPbLd4MqMI6biBHjavo0e7OkZHv9IPfH2nDkGhSn5Sh8e6kRcX0IjYhbM10WUK2iQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-shared-contexts": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-virtualizer": { + "version": "9.0.0-alpha.112", + "resolved": "https://registry.npmjs.org/@fluentui/react-virtualizer/-/react-virtualizer-9.0.0-alpha.112.tgz", + "integrity": "sha512-dao/mQssaPFxCXMx7K+G/DrRoZg28kXcE1NGbJ1RPtbkVCzJgwrEEeDhM5/wyOXO/Z5EZ31FIerDDVOyr6FAaw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-utilities": "^9.26.3", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/tokens": { + "version": "1.0.0-alpha.23", + "resolved": "https://registry.npmjs.org/@fluentui/tokens/-/tokens-1.0.0-alpha.23.tgz", + "integrity": "sha512-uxrzF9Z+J10naP0pGS7zPmzSkspSS+3OJDmYIK3o1nkntQrgBXq3dBob4xSlTDm5aOQ0kw6EvB9wQgtlyy4eKQ==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@griffel/core": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/@griffel/core/-/core-1.20.1.tgz", + "integrity": "sha512-ld1mX04zpmeHn8agx4slSEh8kJ+8or3Y0x9gsJNKSKn6GdCkZBSiGUh+oBXCBn8RKzz8l60TA9IhVSStnyKekA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@griffel/style-types": "^1.4.0", + "csstype": "^3.1.3", + "rtl-css-js": "^1.16.1", + "stylis": "^4.2.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@griffel/react": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@griffel/react/-/react-1.6.1.tgz", + "integrity": "sha512-mNM4/+dIXzqeHboWpVZ1/jiwTAYNc5/8y/V/HasnQ2QXnV6gSUYpeUk/0n6IFU3NJmVJly9JrLSfNo0hM/IFeA==", + "license": "MIT", + "dependencies": { + "@griffel/core": "^1.20.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@griffel/style-types": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@griffel/style-types/-/style-types-1.4.0.tgz", + "integrity": "sha512-vNDfOGV7RN/XkA7vxgf7Z5HgW8eiBm5cHT9wQPhsKB4pxWom5u6eQ9CkYE5mCCTSPl9H6Nd1NBai04d4P6BD7Q==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz", + "integrity": "sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/embla-carousel-fade": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-fade/-/embla-carousel-fade-8.6.0.tgz", + "integrity": "sha512-qaYsx5mwCz72ZrjlsXgs1nKejSrW+UhkbOMwLgfRT7w2LtdEB03nPRI06GHuHv5ac2USvbEiX2/nAHctcDwvpg==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyborg": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/keyborg/-/keyborg-2.6.0.tgz", + "integrity": "sha512-o5kvLbuTF+o326CMVYpjlaykxqYP9DphFQZ2ZpgrvBouyvOxyEB7oqe8nOLFpiV5VCtz0D3pt8gXQYWpLpBnmA==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rtl-css-js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz", + "integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/tabster": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/tabster/-/tabster-8.7.0.tgz", + "integrity": "sha512-AKYquti8AdWzuqJdQo4LUMQDZrHoYQy6V+8yUq2PmgLZV10EaB+8BD0nWOfC/3TBp4mPNg4fbHkz6SFtkr0PpA==", + "license": "MIT", + "dependencies": { + "keyborg": "2.6.0", + "tslib": "^2.8.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.53.3" + } + }, + "node_modules/tabster/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c0d3a92 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "br-office-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@fluentui/react-components": "^9.73.8", + "@fluentui/react-icons": "^2.0.295", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.2", + "vite": "^5.4.8" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..8895ba3 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,124 @@ +import { Routes, Route, Navigate, useNavigate, useLocation } from "react-router-dom"; +import { useEffect, useState } from "react"; +import Layout from "./components/Layout"; +import LoginPage from "./pages/LoginPage"; +import DashboardPage from "./pages/DashboardPage"; +import CommitteesPage from "./pages/CommitteesPage"; +import UsersPage from "./pages/UsersPage"; +import CasesPage from "./pages/CasesPage"; +import DeadlinesPage from "./pages/DeadlinesPage"; +import MeetingsPage from "./pages/MeetingsPage"; +import AgendaItemsPage from "./pages/AgendaItemsPage"; +import DecisionsPage from "./pages/DecisionsPage"; +import TasksPage from "./pages/TasksPage"; +import DocumentsPage from "./pages/DocumentsPage"; +import CommentsPage from "./pages/CommentsPage"; +import TemplatesPage from "./pages/TemplatesPage"; +import NotificationsPage from "./pages/NotificationsPage"; +import SecurityPage from "./pages/SecurityPage"; +import AuditPage from "./pages/AuditPage"; +import CalendarPage from "./pages/CalendarPage"; +import ReportsPage from "./pages/ReportsPage"; +import FinancePage from "./pages/FinancePage"; +import FinanceTaxCodesPage from "./pages/FinanceTaxCodesPage"; +import DepartmentsPage from "./pages/DepartmentsPage"; +import DocumentCategoriesPage from "./pages/DocumentCategoriesPage"; +import PublicDocumentsPage from "./pages/PublicDocumentsPage"; +import PublicDocumentsAdminPage from "./pages/PublicDocumentsAdminPage"; +import PermissionsPage from "./pages/PermissionsPage"; +import KummerkastenPage from "./pages/KummerkastenPage"; +import KummerkastenAdminPage from "./pages/KummerkastenAdminPage"; +import { logoutLocal } from "./lib/auth"; +import { api, setAccessToken } from "./lib/api"; +import { canViewPath } from "./lib/access"; + +function ForbiddenPage() { + return
Keine Berechtigung für diese Seite.
; +} + +function RequirePage({ user, path, children }) { + return canViewPath(user, path) ? children : ; +} + +function page(user, path, component) { + return {component}; +} + +export default function App() { + const [user, setUser] = useState(() => { + const raw = localStorage.getItem("br_user"); + return raw ? JSON.parse(raw) : null; + }); + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + if (!user) return; + let cancelled = false; + api.get("/permissions/access") + .then((access) => { + if (cancelled) return; + const updatedUser = { ...user, ...access }; + setUser(updatedUser); + localStorage.setItem("br_user", JSON.stringify(updatedUser)); + }) + .catch(() => undefined); + return () => { cancelled = true; }; + }, [user?.id]); + + if (location.pathname === "/kummerkasten" || location.pathname === "/public-documents") { + return ( + + } /> + } /> + } /> + + ); + } + + if (!user) { + return { setUser(u); navigate("/"); }} />; + } + + setAccessToken(localStorage.getItem("br_access_token") || window.__br_access_token || ""); + + function handleLogout() { + logoutLocal(); + setUser(null); + navigate("/"); + } + + return ( + + + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + } /> + } /> + )} /> + )} /> + )} /> + )} /> + )} /> + } /> + + + ); +} diff --git a/frontend/src/assets/br-office-logo.png b/frontend/src/assets/br-office-logo.png new file mode 100644 index 0000000..b69dc8f Binary files /dev/null and b/frontend/src/assets/br-office-logo.png differ diff --git a/frontend/src/components/CrudPage.jsx b/frontend/src/components/CrudPage.jsx new file mode 100644 index 0000000..4587403 --- /dev/null +++ b/frontend/src/components/CrudPage.jsx @@ -0,0 +1,183 @@ +import { useEffect, useMemo, useState } from "react"; +import SectionCard from "./SectionCard"; +import { api } from "../lib/api"; + +export default function CrudPage({ title, subtitle, endpoint, columns, fields, fileUpload = false, detailHint }) { + const [rows, setRows] = useState([]); + const [form, setForm] = useState({}); + const [selected, setSelected] = useState(null); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [query, setQuery] = useState(""); + + async function load() { + setError(""); + try { + const data = await api.get(endpoint); + setRows(Array.isArray(data) ? data : []); + } catch (err) { + setError(err.message); + } + } + + useEffect(() => { load(); }, [endpoint]); + + const filtered = useMemo(() => { + const q = query.toLowerCase(); + return rows.filter((row) => JSON.stringify(row).toLowerCase().includes(q)); + }, [rows, query]); + + function choose(row) { + setSelected(row); + setForm(row); + setMessage(""); + setError(""); + } + + function reset() { + setSelected(null); + setForm({}); + } + + async function submit(e) { + e.preventDefault(); + setError(""); + setMessage(""); + try { + if (fileUpload) { + const fd = new FormData(); + Object.entries(form).forEach(([key, value]) => { + if (value !== undefined && value !== null) fd.append(key, value); + }); + await api.post(`${endpoint}/upload`, fd); + } else if (selected?.id) { + await api.post(`${endpoint.replace(/\/$/, "")}/${selected.id}`, form); // fallback for old backends + } + setMessage(selected?.id ? "Gespeichert" : "Erstellt"); + if (!fileUpload && selected?.id) { + await fetchUpdate(); + } else if (!fileUpload && !selected?.id) { + await api.post(endpoint, form); + } + await load(); + if (!fileUpload) reset(); + } catch (err) { + // old backends may not support POST on /:id, so use PUT + try { + if (!fileUpload && selected?.id) { + await fetch(`${(import.meta.env.VITE_API_BASE_URL || "http://localhost:8080/api")}${endpoint}/${selected.id}`, { + method: "PUT", + credentials: "include", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${window.__br_access_token || ""}`, + }, + body: JSON.stringify(form), + }).then(async (r) => { + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || "API-Fehler"); + }); + setMessage("Gespeichert"); + await load(); + reset(); + return; + } + if (!fileUpload && !selected?.id) { + await api.post(endpoint, form); + setMessage("Erstellt"); + await load(); + reset(); + return; + } + } catch (secondErr) { + setError(secondErr.message); + return; + } + setError(err.message); + } + } + + async function fetchUpdate() { + await fetch(`${(import.meta.env.VITE_API_BASE_URL || "http://localhost:8080/api")}${endpoint}/${selected.id}`, { + method: "PUT", + credentials: "include", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${window.__br_access_token || ""}`, + }, + body: JSON.stringify(form), + }).then(async (r) => { + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || "API-Fehler"); + }); + } + + async function remove(id) { + setError(""); + try { + await fetch(`${(import.meta.env.VITE_API_BASE_URL || "http://localhost:8080/api")}${endpoint}/${id}`, { + method: "DELETE", + credentials: "include", + headers: { + Authorization: `Bearer ${window.__br_access_token || ""}`, + } + }).then(async (r) => { + const j = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(j.error || "API-Fehler"); + }); + await load(); + if (selected?.id === id) reset(); + } catch (err) { + setError(err.message); + } + } + + return ( +
+ setQuery(e.target.value)} />}> + {error ?
{error}
: null} + {message ?
{message}
: null} +
+ + + + {columns.map((col) => )} + + + + + {filtered.map((row) => ( + choose(row)}> + {columns.map((col) => )} + + + ))} + +
{col.label}Aktionen
{String(row[col.key] ?? "")} + + +
+
+
+ Zurücksetzen}> +
+ {fields.map((field) => ( +