From dd0746e2e6df2f06e1f6cff23742a132f9161cea Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Thu, 16 Jul 2026 09:35:32 +0200 Subject: [PATCH] first commit --- .claude/launch.json | 17 + .env.example | 50 + .gitignore | 35 + .vscode/extensions.json | 12 + README.md | 51 + apps/tablet-web/index.html | 12 + apps/tablet-web/package.json | 49 + apps/tablet-web/postcss.config.js | 5 + apps/tablet-web/src/App.tsx | 39 + apps/tablet-web/src/apps/admin/AdminApp.tsx | 309 ++ apps/tablet-web/src/apps/admin/GroupsTab.tsx | 176 + .../src/apps/admin/PermissionTree.tsx | 135 + apps/tablet-web/src/apps/admin/api.ts | 128 + .../src/apps/calendar/CalendarApp.tsx | 202 + .../src/apps/calendar/EventDetail.tsx | 206 + .../src/apps/calendar/EventForm.tsx | 166 + apps/tablet-web/src/apps/calendar/api.ts | 86 + apps/tablet-web/src/apps/calendar/labels.ts | 42 + .../src/apps/charges/CaseCreator.tsx | 117 + .../src/apps/charges/ChargesApp.tsx | 142 + apps/tablet-web/src/apps/charges/api.ts | 70 + .../src/apps/dashboard/StartApp.tsx | 172 + apps/tablet-web/src/apps/dashboard/api.ts | 39 + .../src/apps/dispatch/DispatchApp.tsx | 127 + apps/tablet-web/src/apps/dispatch/LiveMap.tsx | 128 + apps/tablet-web/src/apps/dispatch/api.ts | 76 + .../src/apps/dispatch/mapProjection.ts | 25 + .../src/apps/documents/DocumentsApp.tsx | 282 + .../src/apps/documents/SharePanel.tsx | 167 + apps/tablet-web/src/apps/documents/api.ts | 70 + apps/tablet-web/src/apps/laws/LawsApp.tsx | 218 + apps/tablet-web/src/apps/laws/api.ts | 35 + .../src/apps/persons/CitizenForm.tsx | 292 + .../src/apps/persons/PersonDetail.tsx | 375 ++ .../src/apps/persons/PersonsApp.tsx | 216 + apps/tablet-web/src/apps/persons/api.ts | 71 + apps/tablet-web/src/apps/persons/labels.ts | 44 + apps/tablet-web/src/apps/registry.tsx | 35 + .../src/apps/treatment/TreatmentApp.tsx | 161 + apps/tablet-web/src/apps/treatment/api.ts | 29 + .../src/apps/vehicles/VehiclesApp.tsx | 420 ++ apps/tablet-web/src/apps/vehicles/api.ts | 76 + apps/tablet-web/src/core/api-client.ts | 41 + apps/tablet-web/src/core/auth.ts | 57 + apps/tablet-web/src/core/branding.ts | 55 + apps/tablet-web/src/core/config.ts | 12 + apps/tablet-web/src/core/permissions.ts | 11 + apps/tablet-web/src/core/query-client.ts | 11 + apps/tablet-web/src/core/socket.ts | 24 + apps/tablet-web/src/index.css | 192 + apps/tablet-web/src/main.tsx | 10 + apps/tablet-web/src/shell/Shell.tsx | 95 + apps/tablet-web/src/shell/StatusBar.tsx | 44 + apps/tablet-web/src/ui/Button.tsx | 28 + apps/tablet-web/src/ui/Card.tsx | 24 + apps/tablet-web/src/ui/Input.tsx | 14 + apps/tablet-web/src/ui/RichTextEditor.tsx | 180 + apps/tablet-web/src/ui/cn.ts | 5 + apps/tablet-web/src/vite-env.d.ts | 16 + apps/tablet-web/tsconfig.app.json | 13 + apps/tablet-web/tsconfig.json | 4 + apps/tablet-web/tsconfig.node.json | 11 + apps/tablet-web/vite.config.ts | 19 + backend/api/drizzle.config.ts | 21 + backend/api/drizzle/0000_classy_speedball.sql | 121 + .../api/drizzle/0001_whole_rafael_vega.sql | 12 + backend/api/drizzle/0002_curly_chat.sql | 2 + .../api/drizzle/0003_breezy_rick_jones.sql | 11 + backend/api/drizzle/0004_past_shiva.sql | 22 + .../api/drizzle/0005_crazy_quentin_quire.sql | 2 + backend/api/drizzle/0006_watery_sphinx.sql | 12 + backend/api/drizzle/0007_first_kabuki.sql | 18 + .../api/drizzle/0008_standalone_citizens.sql | 49 + backend/api/drizzle/0009_citizen_acl.sql | 2 + .../api/drizzle/0010_treatment_records.sql | 16 + backend/api/drizzle/0011_laws.sql | 15 + backend/api/drizzle/0012_calendar.sql | 30 + backend/api/drizzle/meta/0000_snapshot.json | 803 +++ backend/api/drizzle/meta/0001_snapshot.json | 879 +++ backend/api/drizzle/meta/0002_snapshot.json | 879 +++ backend/api/drizzle/meta/0003_snapshot.json | 955 ++++ backend/api/drizzle/meta/0004_snapshot.json | 1093 ++++ backend/api/drizzle/meta/0005_snapshot.json | 1109 ++++ backend/api/drizzle/meta/0006_snapshot.json | 1184 ++++ backend/api/drizzle/meta/0007_snapshot.json | 1291 +++++ backend/api/drizzle/meta/_journal.json | 97 + backend/api/package.json | 38 + backend/api/src/app.ts | 80 + .../src/config/__tests__/rankDefaults.test.ts | 35 + backend/api/src/config/env.ts | 43 + backend/api/src/config/jobRoles.ts | 18 + backend/api/src/config/rankDefaults.ts | 55 + backend/api/src/db/client.ts | 21 + backend/api/src/db/schema/index.ts | 2 + backend/api/src/db/schema/mdt.ts | 433 ++ backend/api/src/db/schema/qbox.ts | 29 + backend/api/src/db/setup-dev.ts | 247 + backend/api/src/modules/admin/admin.route.ts | 51 + .../api/src/modules/admin/admin.service.ts | 120 + .../auth/__tests__/auth.service.test.ts | 41 + backend/api/src/modules/auth/auth.route.ts | 116 + backend/api/src/modules/auth/auth.service.ts | 140 + backend/api/src/modules/auth/discord.ts | 52 + .../modules/authorities/authorities.route.ts | 42 + .../authorities/authorities.service.ts | 67 + backend/api/src/modules/board/board.route.ts | 43 + .../api/src/modules/board/board.service.ts | 76 + .../api/src/modules/bridge/bridge.route.ts | 68 + .../src/modules/calendar/calendar.route.ts | 117 + .../src/modules/calendar/calendar.service.ts | 179 + .../cases/__tests__/cases.logic.test.ts | 42 + backend/api/src/modules/cases/cases.logic.ts | 47 + backend/api/src/modules/cases/cases.route.ts | 95 + .../api/src/modules/cases/cases.service.ts | 255 + .../src/modules/dispatch/dispatch.route.ts | 59 + .../src/modules/dispatch/dispatch.service.ts | 94 + .../src/modules/documents/documents.route.ts | 102 + .../modules/documents/documents.service.ts | 169 + .../api/src/modules/groups/groups.route.ts | 75 + .../api/src/modules/groups/groups.service.ts | 90 + .../api/src/modules/health/health.route.ts | 21 + backend/api/src/modules/laws/laws.route.ts | 41 + backend/api/src/modules/laws/laws.service.ts | 70 + .../persons/__tests__/persons.mapper.test.ts | 86 + .../api/src/modules/persons/persons.mapper.ts | 75 + .../api/src/modules/persons/persons.route.ts | 102 + .../src/modules/persons/persons.service.ts | 238 + .../src/modules/treatment/treatment.route.ts | 55 + .../modules/treatment/treatment.service.ts | 104 + .../__tests__/vehicles.mapper.test.ts | 41 + .../src/modules/vehicles/vehicles.mapper.ts | 36 + .../src/modules/vehicles/vehicles.route.ts | 95 + .../src/modules/vehicles/vehicles.service.ts | 156 + backend/api/src/plugins/auth.ts | 42 + backend/api/src/realtime/officers.ts | 63 + backend/api/src/realtime/socket.ts | 97 + backend/api/src/server.ts | 16 + backend/api/src/types/fastify.d.ts | 18 + backend/api/src/utils/bridge-auth.ts | 34 + backend/api/src/utils/json.ts | 13 + backend/api/src/utils/names.ts | 23 + backend/api/tsconfig.json | 11 + docs/dev-setup.md | 54 + docs/fivem-bridge.md | 64 + fivem-bridge/client/main.lua | 66 + fivem-bridge/config.lua | 29 + fivem-bridge/fxmanifest.lua | 36 + fivem-bridge/nui/app.js | 55 + fivem-bridge/nui/index.html | 16 + fivem-bridge/nui/style.css | 25 + fivem-bridge/server/main.lua | 120 + package.json | 28 + packages/shared/package.json | 24 + packages/shared/src/dto/announcement.ts | 31 + packages/shared/src/dto/auth.ts | 40 + packages/shared/src/dto/authority.ts | 34 + packages/shared/src/dto/calendar.ts | 69 + packages/shared/src/dto/case.ts | 49 + packages/shared/src/dto/charge.ts | 31 + packages/shared/src/dto/common.ts | 35 + packages/shared/src/dto/dispatch.ts | 45 + packages/shared/src/dto/document.ts | 81 + packages/shared/src/dto/group.ts | 39 + packages/shared/src/dto/index.ts | 17 + packages/shared/src/dto/law.ts | 28 + packages/shared/src/dto/officer.ts | 28 + packages/shared/src/dto/person.ts | 108 + packages/shared/src/dto/rank.ts | 39 + packages/shared/src/dto/treatment.ts | 39 + packages/shared/src/dto/vehicle.ts | 44 + packages/shared/src/dto/warrant.ts | 34 + packages/shared/src/events/index.ts | 46 + packages/shared/src/index.ts | 3 + packages/shared/src/rbac/index.ts | 234 + packages/shared/tsconfig.json | 8 + pnpm-lock.yaml | 4862 +++++++++++++++++ pnpm-workspace.yaml | 4 + tsconfig.base.json | 22 + turbo.json | 18 + 179 files changed, 25679 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 README.md create mode 100644 apps/tablet-web/index.html create mode 100644 apps/tablet-web/package.json create mode 100644 apps/tablet-web/postcss.config.js create mode 100644 apps/tablet-web/src/App.tsx create mode 100644 apps/tablet-web/src/apps/admin/AdminApp.tsx create mode 100644 apps/tablet-web/src/apps/admin/GroupsTab.tsx create mode 100644 apps/tablet-web/src/apps/admin/PermissionTree.tsx create mode 100644 apps/tablet-web/src/apps/admin/api.ts create mode 100644 apps/tablet-web/src/apps/calendar/CalendarApp.tsx create mode 100644 apps/tablet-web/src/apps/calendar/EventDetail.tsx create mode 100644 apps/tablet-web/src/apps/calendar/EventForm.tsx create mode 100644 apps/tablet-web/src/apps/calendar/api.ts create mode 100644 apps/tablet-web/src/apps/calendar/labels.ts create mode 100644 apps/tablet-web/src/apps/charges/CaseCreator.tsx create mode 100644 apps/tablet-web/src/apps/charges/ChargesApp.tsx create mode 100644 apps/tablet-web/src/apps/charges/api.ts create mode 100644 apps/tablet-web/src/apps/dashboard/StartApp.tsx create mode 100644 apps/tablet-web/src/apps/dashboard/api.ts create mode 100644 apps/tablet-web/src/apps/dispatch/DispatchApp.tsx create mode 100644 apps/tablet-web/src/apps/dispatch/LiveMap.tsx create mode 100644 apps/tablet-web/src/apps/dispatch/api.ts create mode 100644 apps/tablet-web/src/apps/dispatch/mapProjection.ts create mode 100644 apps/tablet-web/src/apps/documents/DocumentsApp.tsx create mode 100644 apps/tablet-web/src/apps/documents/SharePanel.tsx create mode 100644 apps/tablet-web/src/apps/documents/api.ts create mode 100644 apps/tablet-web/src/apps/laws/LawsApp.tsx create mode 100644 apps/tablet-web/src/apps/laws/api.ts create mode 100644 apps/tablet-web/src/apps/persons/CitizenForm.tsx create mode 100644 apps/tablet-web/src/apps/persons/PersonDetail.tsx create mode 100644 apps/tablet-web/src/apps/persons/PersonsApp.tsx create mode 100644 apps/tablet-web/src/apps/persons/api.ts create mode 100644 apps/tablet-web/src/apps/persons/labels.ts create mode 100644 apps/tablet-web/src/apps/registry.tsx create mode 100644 apps/tablet-web/src/apps/treatment/TreatmentApp.tsx create mode 100644 apps/tablet-web/src/apps/treatment/api.ts create mode 100644 apps/tablet-web/src/apps/vehicles/VehiclesApp.tsx create mode 100644 apps/tablet-web/src/apps/vehicles/api.ts create mode 100644 apps/tablet-web/src/core/api-client.ts create mode 100644 apps/tablet-web/src/core/auth.ts create mode 100644 apps/tablet-web/src/core/branding.ts create mode 100644 apps/tablet-web/src/core/config.ts create mode 100644 apps/tablet-web/src/core/permissions.ts create mode 100644 apps/tablet-web/src/core/query-client.ts create mode 100644 apps/tablet-web/src/core/socket.ts create mode 100644 apps/tablet-web/src/index.css create mode 100644 apps/tablet-web/src/main.tsx create mode 100644 apps/tablet-web/src/shell/Shell.tsx create mode 100644 apps/tablet-web/src/shell/StatusBar.tsx create mode 100644 apps/tablet-web/src/ui/Button.tsx create mode 100644 apps/tablet-web/src/ui/Card.tsx create mode 100644 apps/tablet-web/src/ui/Input.tsx create mode 100644 apps/tablet-web/src/ui/RichTextEditor.tsx create mode 100644 apps/tablet-web/src/ui/cn.ts create mode 100644 apps/tablet-web/src/vite-env.d.ts create mode 100644 apps/tablet-web/tsconfig.app.json create mode 100644 apps/tablet-web/tsconfig.json create mode 100644 apps/tablet-web/tsconfig.node.json create mode 100644 apps/tablet-web/vite.config.ts create mode 100644 backend/api/drizzle.config.ts create mode 100644 backend/api/drizzle/0000_classy_speedball.sql create mode 100644 backend/api/drizzle/0001_whole_rafael_vega.sql create mode 100644 backend/api/drizzle/0002_curly_chat.sql create mode 100644 backend/api/drizzle/0003_breezy_rick_jones.sql create mode 100644 backend/api/drizzle/0004_past_shiva.sql create mode 100644 backend/api/drizzle/0005_crazy_quentin_quire.sql create mode 100644 backend/api/drizzle/0006_watery_sphinx.sql create mode 100644 backend/api/drizzle/0007_first_kabuki.sql create mode 100644 backend/api/drizzle/0008_standalone_citizens.sql create mode 100644 backend/api/drizzle/0009_citizen_acl.sql create mode 100644 backend/api/drizzle/0010_treatment_records.sql create mode 100644 backend/api/drizzle/0011_laws.sql create mode 100644 backend/api/drizzle/0012_calendar.sql create mode 100644 backend/api/drizzle/meta/0000_snapshot.json create mode 100644 backend/api/drizzle/meta/0001_snapshot.json create mode 100644 backend/api/drizzle/meta/0002_snapshot.json create mode 100644 backend/api/drizzle/meta/0003_snapshot.json create mode 100644 backend/api/drizzle/meta/0004_snapshot.json create mode 100644 backend/api/drizzle/meta/0005_snapshot.json create mode 100644 backend/api/drizzle/meta/0006_snapshot.json create mode 100644 backend/api/drizzle/meta/0007_snapshot.json create mode 100644 backend/api/drizzle/meta/_journal.json create mode 100644 backend/api/package.json create mode 100644 backend/api/src/app.ts create mode 100644 backend/api/src/config/__tests__/rankDefaults.test.ts create mode 100644 backend/api/src/config/env.ts create mode 100644 backend/api/src/config/jobRoles.ts create mode 100644 backend/api/src/config/rankDefaults.ts create mode 100644 backend/api/src/db/client.ts create mode 100644 backend/api/src/db/schema/index.ts create mode 100644 backend/api/src/db/schema/mdt.ts create mode 100644 backend/api/src/db/schema/qbox.ts create mode 100644 backend/api/src/db/setup-dev.ts create mode 100644 backend/api/src/modules/admin/admin.route.ts create mode 100644 backend/api/src/modules/admin/admin.service.ts create mode 100644 backend/api/src/modules/auth/__tests__/auth.service.test.ts create mode 100644 backend/api/src/modules/auth/auth.route.ts create mode 100644 backend/api/src/modules/auth/auth.service.ts create mode 100644 backend/api/src/modules/auth/discord.ts create mode 100644 backend/api/src/modules/authorities/authorities.route.ts create mode 100644 backend/api/src/modules/authorities/authorities.service.ts create mode 100644 backend/api/src/modules/board/board.route.ts create mode 100644 backend/api/src/modules/board/board.service.ts create mode 100644 backend/api/src/modules/bridge/bridge.route.ts create mode 100644 backend/api/src/modules/calendar/calendar.route.ts create mode 100644 backend/api/src/modules/calendar/calendar.service.ts create mode 100644 backend/api/src/modules/cases/__tests__/cases.logic.test.ts create mode 100644 backend/api/src/modules/cases/cases.logic.ts create mode 100644 backend/api/src/modules/cases/cases.route.ts create mode 100644 backend/api/src/modules/cases/cases.service.ts create mode 100644 backend/api/src/modules/dispatch/dispatch.route.ts create mode 100644 backend/api/src/modules/dispatch/dispatch.service.ts create mode 100644 backend/api/src/modules/documents/documents.route.ts create mode 100644 backend/api/src/modules/documents/documents.service.ts create mode 100644 backend/api/src/modules/groups/groups.route.ts create mode 100644 backend/api/src/modules/groups/groups.service.ts create mode 100644 backend/api/src/modules/health/health.route.ts create mode 100644 backend/api/src/modules/laws/laws.route.ts create mode 100644 backend/api/src/modules/laws/laws.service.ts create mode 100644 backend/api/src/modules/persons/__tests__/persons.mapper.test.ts create mode 100644 backend/api/src/modules/persons/persons.mapper.ts create mode 100644 backend/api/src/modules/persons/persons.route.ts create mode 100644 backend/api/src/modules/persons/persons.service.ts create mode 100644 backend/api/src/modules/treatment/treatment.route.ts create mode 100644 backend/api/src/modules/treatment/treatment.service.ts create mode 100644 backend/api/src/modules/vehicles/__tests__/vehicles.mapper.test.ts create mode 100644 backend/api/src/modules/vehicles/vehicles.mapper.ts create mode 100644 backend/api/src/modules/vehicles/vehicles.route.ts create mode 100644 backend/api/src/modules/vehicles/vehicles.service.ts create mode 100644 backend/api/src/plugins/auth.ts create mode 100644 backend/api/src/realtime/officers.ts create mode 100644 backend/api/src/realtime/socket.ts create mode 100644 backend/api/src/server.ts create mode 100644 backend/api/src/types/fastify.d.ts create mode 100644 backend/api/src/utils/bridge-auth.ts create mode 100644 backend/api/src/utils/json.ts create mode 100644 backend/api/src/utils/names.ts create mode 100644 backend/api/tsconfig.json create mode 100644 docs/dev-setup.md create mode 100644 docs/fivem-bridge.md create mode 100644 fivem-bridge/client/main.lua create mode 100644 fivem-bridge/config.lua create mode 100644 fivem-bridge/fxmanifest.lua create mode 100644 fivem-bridge/nui/app.js create mode 100644 fivem-bridge/nui/index.html create mode 100644 fivem-bridge/nui/style.css create mode 100644 fivem-bridge/server/main.lua create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/dto/announcement.ts create mode 100644 packages/shared/src/dto/auth.ts create mode 100644 packages/shared/src/dto/authority.ts create mode 100644 packages/shared/src/dto/calendar.ts create mode 100644 packages/shared/src/dto/case.ts create mode 100644 packages/shared/src/dto/charge.ts create mode 100644 packages/shared/src/dto/common.ts create mode 100644 packages/shared/src/dto/dispatch.ts create mode 100644 packages/shared/src/dto/document.ts create mode 100644 packages/shared/src/dto/group.ts create mode 100644 packages/shared/src/dto/index.ts create mode 100644 packages/shared/src/dto/law.ts create mode 100644 packages/shared/src/dto/officer.ts create mode 100644 packages/shared/src/dto/person.ts create mode 100644 packages/shared/src/dto/rank.ts create mode 100644 packages/shared/src/dto/treatment.ts create mode 100644 packages/shared/src/dto/vehicle.ts create mode 100644 packages/shared/src/dto/warrant.ts create mode 100644 packages/shared/src/events/index.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/rbac/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 turbo.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..c2b3efa --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "tablet-web", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "@d4rk-tablet/web", "dev"], + "port": 5200 + }, + { + "name": "api", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "@d4rk-tablet/api", "dev"], + "port": 3000 + } + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5030f8c --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +# ───────────────────────────────────────────────────────────── +# d4rk_tablet — Environment (kopieren nach .env und ausfüllen) +# ───────────────────────────────────────────────────────────── + +# ── Backend / API ── +API_PORT=3000 +API_HOST=0.0.0.0 +# Erlaubte Origins für CORS (Web-App + FiveM NUI). Komma-separiert. +# Dev-Web läuft auf :5190 (Vite), :5173 als Fallback. +CORS_ORIGINS=http://localhost:5190,http://localhost:5173,https://mdt.d4rkst3r.de + +# ── Datenbank (bestehende QBox-MariaDB) ── +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=root +DB_PASSWORD= +DB_NAME=qbox +# Optionales Prefix falls QBox-Tabellen anders heißen (leer lassen wenn Standard) +DB_QBOX_PLAYERS_TABLE=players +DB_QBOX_VEHICLES_TABLE=player_vehicles + +# ── GTA-Map-Tiles ── +# Ordner mit den Tiles ({style}/{z}/{x}/{y}.jpg) — served unter /tiles/. +# Prod: auf Server-/CDN-Pfad zeigen. Leer/ungültig = /tiles/ inaktiv. +TILES_DIR=./tiles + + +# ── Auth ── +# Signiert die kurzlebigen Web-JWTs +JWT_SECRET=change-me-super-secret +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d +# Shared Secret zwischen fivem-bridge (Server) und Backend (/auth/fivem, HMAC) +BRIDGE_HMAC_SECRET=change-me-bridge-secret + +# ── Discord OAuth2 (Browser-Login) ── +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_REDIRECT_URI=http://localhost:3000/auth/discord/callback + +# ── Frontend ── +# Dev-Web-Port (Vite). 5173 kann auf Windows OS-reserviert sein → 5190 default. +# Basis-URL zum Backend (von der Web-App aus) +VITE_API_URL=http://localhost:3000 +VITE_SOCKET_URL=http://localhost:3000 +# GTA-Map (MapLibre): Tile-Basis-URL + Default-Stil (satellite|road|roads2|minimap) +VITE_MAP_TILES_URL=http://localhost:3000/tiles +VITE_MAP_STYLE=satellite +# Dev-Only: umgeht Auth für lokale Entwicklung im Browser (NIE in Prod!) +VITE_DEV_AUTH_BYPASS=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77d5fcd --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build output +dist/ +build/ +*.tsbuildinfo + +# Hochgeladene Bilder / Tiles (Laufzeit-Assets) +tiles/ + +# Turborepo +.turbo/ + +# Env / secrets +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Editor / OS +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +.idea/ +.DS_Store +Thumbs.db + +# Drizzle +drizzle/meta/_journal.json.bak diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..8958637 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "usernamehw.errorlens", + "christian-kohler.path-intellisense", + "eamodio.gitlens", + "humao.rest-client", + "bradlc.vscode-tailwindcss", + "dsznajder.es7-react-js-snippets" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4c2bee --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# d4rk_tablet — Behörden-MDT / CAD + +Eigenständige Behörden-Software (MDT/CAD) für FiveM. Läuft **unabhängig vom Game-Server**: +im Spiel (NUI-iframe) *und* im Browser (Leitstelle, 2. Monitor, echtes Tablet). + +> Das komplette System lebt im Backend/Web. Bürger & Fahrzeuge werden **MDT-eigen** +> geführt (nicht aus QBox gelesen) — so lassen sich auch illegale Einreise, unbekannte +> Täter (John Doe) und gefälschte Kennzeichen sauber abbilden. + +## Architektur + +``` +apps/tablet-web React 18 + Vite 5 + Tailwind 4 + Zustand + TanStack Query + Lucide +backend/api Fastify + Drizzle ORM (mysql2) + Socket.io → liest QBox-MariaDB direkt +fivem-bridge minimale Lua-Resource (Token, NUI, Positionen, Waypoint) +packages/shared TS-DTOs (zod) + Socket-Events + RBAC — von web & backend genutzt +``` + +## Setup + +```bash +pnpm install # Node >=20, pnpm 10 +cp .env.example .env # DB-Creds, JWT-, HMAC-, Discord-Secrets eintragen +pnpm dev # startet web (5173) + api (3000) via Turborepo +``` + +Weitere Scripts: `pnpm typecheck`, `pnpm build`, `pnpm db:generate`, `pnpm db:migrate`. + +## Datenbank + +- **Komplett eigenständig:** alle Daten in eigenen `mdt_*`-Tabellen (Migrations via Drizzle Kit). +- Bürger (`mdt_citizens`) und Fahrzeuge (`mdt_vehicles`) werden von der Behörde selbst geführt + — QBox-Tabellen werden **nicht** mehr gelesen (bewusste Entscheidung für Hardcore-RP). + +## Milestones + +1. ✅ **Scaffold** — Monorepo, shared DTOs, Fastify + Vite Skelett, Drizzle-Setup +2. ✅ **Auth** — JWT + RBAC-Guards, `/auth/fivem` (HMAC), Discord OAuth, Dev-Login +3. ✅ **Personen + Fahrzeuge** — QBox-Reads (JSON-Parsing), REST + RBAC, TanStack-Query-UI. Siehe [docs/dev-setup.md](docs/dev-setup.md) für Seed + Live-Test +4. ✅ **Strafakten / Haftbefehle** — Strafenkatalog, Akte anlegen (Charges × Anzahl → Summe Bußgeld/Haft), Haftbefehl ausstellen/aufheben. Behörden-Branding + Tablet-Statusbar. +5. ✅ **Realtime-Dispatch + Map** — Socket.io (JWT-Auth, Dept-Rooms), Einsätze live, Officer-Positionen live. **MapLibre GTA-Satelliten-Map** (Tiles served vom Backend `/tiles/`, Game→lng/lat-Projektion). HMAC-Bridge-Endpoints (`/bridge/*`). +6. ✅ **Rechte & Mitarbeiter** — Hybrid-Rechte (granulare Nodes, QBox-Rang-Matrix ∪ Overrides, ins JWT gebacken). **Visueller Rechte-Explorer** (Baum MDT/CAD/Admin, Tri-State, Suche, Rollen-Templates) + Mitarbeiter-Verwaltung. Guards prüfen `permissions`. +7. ✅ **FiveM-Bridge** — Lua-Resource (`fivem-bridge/`): Tablet öffnen (NUI-iframe + Token via postMessage), Server-Token-Flow (Shared-Secret), server-seitiger Positions-Push, Dienst-Sync, Dispatch-Export, Waypoint. Siehe [docs/fivem-bridge.md](docs/fivem-bridge.md). Backend-Contract verifiziert; Lua-Ingame-Test beim Betreiber. +8. 🚧 Weitere Module (aus EMS-MDT-Vorlage): ✅ **Start-Dashboard** (Uhr, Termine, **Schwarzes Brett**) · ✅ **Dokumente** (Ordner + **Tiptap Rich-Text** [Tabellen/Aufgabenlisten/Link/Bild/…], Aktenzeichen `DO-JJJJ-MM-TT-###`) · ✅ **Bürgerakten** (MDT-eigene, voll editierbare Bürger-Registry: anlegen/bearbeiten/löschen, Mugshot-URL, Aufenthaltsstatus inkl. illegaler Einreise, Aliasse, Personenbeschreibung, Lizenzen, Fahndung) · ✅ **Fahrzeug-Registry** (MDT-eigen: anlegen/bearbeiten/löschen, Halter-Verknüpfung, Kennzeichen-Status registriert/**gefälscht**/gestohlen) · ✅ **EMS-Behandlungsakten** (Pendant zu Strafakten: Patient-Suche, Aktenzeichen `TR-JJJJ-MM-TT-###`, Diagnose + Behandlung als **Tiptap Rich-Text**, offen/abgeschlossen) · ✅ **Gesetze** (Strafgesetzbuch-Nachschlagewerk: nach Kategorie gruppiert, durchsuchbar, Gesetzestext als **Tiptap Rich-Text** + Bußgeld/Haftzeit, pflegbar mit `mdt.laws.manage`) · ✅ **Kalender** (echte Monatsansicht, Termine/Events mit Kategorie-Farben, Einladung einzelner/mehrerer Personen, **Selbst-Eintragung + RSVP** [Zusage/Absage/Vielleicht], Tiptap-Beschreibung) · ⬜ Mein Profil · ⬜ Dispatch-Units +9. ✅ **Freigabe-System (ACL)** — konfigurierbare **Behörden-Registry** + **Gruppen/Teams** + modernes **Freigabe-Panel** (Lesen/Schreiben × Behörden/Gruppen/Personen, öffentlich-Toggle) auf Dokumenten/Ordnern **und Bürgerakten** (dort als Sichtbarkeits-ACL: welche Behörde sieht welche Akte). Verwaltung: Rechte-Matrix · Mitarbeiter · Behörden · Gruppen. +10. ⬜ Feature-Flags · Dienststatus-Gating · Ownership · Temporär/Vertretung · Audit-Log · Deployment + +## Dev-Hinweise + +- **Browser-Dev ohne Login:** `VITE_DEV_AUTH_BYPASS=true` in `.env` → Mock-Officer (police, admin). +- Auth-Token kommt im NUI per `postMessage({ action: 'auth', token, user })`. diff --git a/apps/tablet-web/index.html b/apps/tablet-web/index.html new file mode 100644 index 0000000..20bfda8 --- /dev/null +++ b/apps/tablet-web/index.html @@ -0,0 +1,12 @@ + + + + + + d4rk_tablet — MDT + + +
+ + + diff --git a/apps/tablet-web/package.json b/apps/tablet-web/package.json new file mode 100644 index 0000000..586806e --- /dev/null +++ b/apps/tablet-web/package.json @@ -0,0 +1,49 @@ +{ + "name": "@d4rk-tablet/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b" + }, + "dependencies": { + "@d4rk-tablet/shared": "workspace:*", + "@tanstack/react-query": "^5.62.11", + "@tiptap/extension-highlight": "^2.27.2", + "@tiptap/extension-image": "^2.27.2", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-placeholder": "^2.27.2", + "@tiptap/extension-table": "^2.27.2", + "@tiptap/extension-table-cell": "^2.27.2", + "@tiptap/extension-table-header": "^2.27.2", + "@tiptap/extension-table-row": "^2.27.2", + "@tiptap/extension-task-item": "^2.27.2", + "@tiptap/extension-task-list": "^2.27.2", + "@tiptap/extension-text-align": "^2.27.2", + "@tiptap/extension-underline": "^2.27.2", + "@tiptap/pm": "^2.27.2", + "@tiptap/react": "^2.27.2", + "@tiptap/starter-kit": "^2.27.2", + "clsx": "^2.1.1", + "lucide-react": "^0.356.0", + "maplibre-gl": "^4.7.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "socket.io-client": "^4.8.1", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.1", + "@types/node": "^20.17.10", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "postcss": "^8.5.15", + "tailwindcss": "^4.3.1", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/apps/tablet-web/postcss.config.js b/apps/tablet-web/postcss.config.js new file mode 100644 index 0000000..a34a3d5 --- /dev/null +++ b/apps/tablet-web/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/apps/tablet-web/src/App.tsx b/apps/tablet-web/src/App.tsx new file mode 100644 index 0000000..9e1cff7 --- /dev/null +++ b/apps/tablet-web/src/App.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from './core/query-client'; +import { config } from './core/config'; +import { useAuth } from './core/auth'; +import { Shell } from './shell/Shell'; +import { EmptyState } from './ui/Card'; + +function LoginScreen() { + return ( +
+

MDT — Anmeldung erforderlich

+ + Mit Discord anmelden + +
+ ); +} + +export function App() { + const ready = useAuth((s) => s.ready); + const user = useAuth((s) => s.user); + const init = useAuth((s) => s.init); + + useEffect(() => { + init(); + }, [init]); + + return ( + +
+ {!ready ? : !user ? : } +
+
+ ); +} diff --git a/apps/tablet-web/src/apps/admin/AdminApp.tsx b/apps/tablet-web/src/apps/admin/AdminApp.tsx new file mode 100644 index 0000000..a8bffa5 --- /dev/null +++ b/apps/tablet-web/src/apps/admin/AdminApp.tsx @@ -0,0 +1,309 @@ +import { useEffect, useState } from 'react'; +import { ShieldCheck, Users as UsersIcon, Search, Wand2, Building2, UsersRound, Trash2, Plus } from 'lucide-react'; +import { + DEPARTMENTS, + ROLE_TEMPLATES, + type Department, + type Permission, + type Rank, +} from '@d4rk-tablet/shared'; +import { + useRankMatrix, useUpdateRank, useAdminUsers, useUpdateUserRoles, + useAuthorities, useCreateAuthority, useUpdateAuthority, useDeleteAuthority, +} from './api'; +import { PermissionTree } from './PermissionTree'; +import { GroupsTab } from './GroupsTab'; +import { Card } from '../../ui/Card'; +import { Button } from '../../ui/Button'; +import { Input } from '../../ui/Input'; +import { cn } from '../../ui/cn'; + +type Tab = 'ranks' | 'users' | 'authorities' | 'groups'; + +export function AdminApp() { + const [tab, setTab] = useState('ranks'); + return ( +
+
+ setTab('ranks')} icon={}> + Rechte-Matrix + + setTab('users')} icon={}> + Mitarbeiter + + setTab('authorities')} icon={}> + Behörden + + setTab('groups')} icon={}> + Gruppen + +
+
+ {tab === 'ranks' && } + {tab === 'users' && } + {tab === 'authorities' && } + {tab === 'groups' && } +
+
+ ); +} + +function AuthoritiesTab() { + const { data: authorities } = useAuthorities(); + const create = useCreateAuthority(); + const update = useUpdateAuthority(); + const del = useDeleteAuthority(); + const [form, setForm] = useState(false); + const [key, setKey] = useState(''); + const [name, setName] = useState(''); + const [color, setColor] = useState('#2f6df6'); + const [department, setDepartment] = useState('police'); + const [jobs, setJobs] = useState(''); + + const submit = () => { + create.mutate( + { key: key.trim(), name: name.trim(), color, department, jobs: jobs.split(',').map((j) => j.trim()).filter(Boolean) }, + { onSuccess: () => { setForm(false); setKey(''); setName(''); setJobs(''); } }, + ); + }; + + return ( +
+
+ +
+ + {form && ( + +
+ setKey(e.target.value)} placeholder="Key (z.B. lspd)" className="w-40" /> + setName(e.target.value)} placeholder="Name" /> + setColor(e.target.value)} className="h-9 w-12 rounded border border-[var(--color-border)] bg-transparent" /> +
+
+ + setJobs(e.target.value)} placeholder="QBox-Jobs (Komma-getrennt): police, leo" /> +
+
+ + +
+
+ )} + + {authorities?.map((a) => ( + + +
+

+ {a.name} {a.key} +

+

{a.department}

+
+ { + const next = e.target.value.split(',').map((j) => j.trim()).filter(Boolean); + if (next.join(',') !== a.jobs.join(',')) update.mutate({ id: a.id, input: { jobs: next } }); + }} + className="w-52 shrink-0" + /> + +
+ ))} +
+ ); +} + +function TabButton({ active, onClick, icon, children }: { active: boolean; onClick: () => void; icon: React.ReactNode; children: React.ReactNode }) { + return ( + + ); +} + +function RankMatrix() { + const [dept, setDept] = useState('police'); + const [grade, setGrade] = useState(null); + const { data: ranks } = useRankMatrix(dept); + + const selected = ranks?.find((r) => r.grade === grade) ?? ranks?.[0] ?? null; + + return ( +
+
+ {DEPARTMENTS.map((d) => ( + + ))} +
+ +
+ {/* Rang-Liste */} +
+ {ranks?.map((rank) => ( + + ))} +
+ + {/* Editor */} +
+ {selected ? ( + + ) : ( +

Rang auswählen…

+ )} +
+
+
+ ); +} + +function RankEditor({ department, rank }: { department: Department; rank: Rank }) { + const update = useUpdateRank(department); + const [perms, setPerms] = useState>(new Set(rank.permissions)); + const [search, setSearch] = useState(''); + const [templateOpen, setTemplateOpen] = useState(false); + useEffect(() => setPerms(new Set(rank.permissions)), [rank]); + + const changed = + perms.size !== rank.permissions.length || rank.permissions.some((p) => !perms.has(p)); + + return ( + +
+
+ Grade {rank.grade} +

{rank.label}

+
+
+ {/* Template anwenden */} +
+ + {templateOpen && ( +
+ {Object.keys(ROLE_TEMPLATES).map((name) => ( + + ))} +
+ )} +
+ +
+
+ +
+ + setSearch(e.target.value)} placeholder="Recht suchen…" className="pl-9" /> +
+ +
+ +
+
+ ); +} + +const OVERRIDE_ROLES = ['admin', 'dispatch'] as const; + +function UsersList() { + const { data: users } = useAdminUsers(); + const update = useUpdateUserRoles(); + + return ( +
+ {users?.length === 0 &&

Keine Mitarbeiter erfasst.

} + {users?.map((u) => ( + +
+

{u.name}

+

+ {u.department ? `${u.department} · Grade ${u.grade ?? '—'}` : 'keine Behörde'} + {u.citizenid && ` · ${u.citizenid}`} +

+
+
+ {OVERRIDE_ROLES.map((role) => { + const on = u.roles.includes(role); + return ( + + ); + })} +
+
+ ))} +
+ ); +} diff --git a/apps/tablet-web/src/apps/admin/GroupsTab.tsx b/apps/tablet-web/src/apps/admin/GroupsTab.tsx new file mode 100644 index 0000000..74752f4 --- /dev/null +++ b/apps/tablet-web/src/apps/admin/GroupsTab.tsx @@ -0,0 +1,176 @@ +import { useState } from 'react'; +import { Plus, Trash2, UserPlus, X, Search, Users } from 'lucide-react'; +import { + useGroups, + useGroup, + useCreateGroup, + useDeleteGroup, + useAddMember, + useRemoveMember, +} from './api'; +import { useSearchPersons } from '../persons/api'; +import { Card, EmptyState } from '../../ui/Card'; +import { Input } from '../../ui/Input'; +import { Button } from '../../ui/Button'; +import { cn } from '../../ui/cn'; + +export function GroupsTab() { + const { data: groups } = useGroups(); + const create = useCreateGroup(); + const del = useDeleteGroup(); + const [selected, setSelected] = useState(null); + const [form, setForm] = useState(false); + const [name, setName] = useState(''); + const [color, setColor] = useState('#8b5cf6'); + + return ( +
+ {/* Gruppenliste */} +
+ + {form && ( + +
+ setName(e.target.value)} placeholder="Gruppenname" /> + setColor(e.target.value)} + className="h-9 w-10 shrink-0 rounded border border-[var(--color-border)] bg-transparent" + /> +
+ +
+ )} + {groups?.map((g) => ( + + ))} +
+ + {/* Gruppendetail */} +
+ {selected ? ( + setSelected(null)} onDelete={del.mutate} /> + ) : ( + + )} +
+
+ ); +} + +function GroupDetailView({ + id, + onDelete, + onDeleted, +}: { + id: number; + onDelete: (id: number) => void; + onDeleted: () => void; +}) { + const { data: group } = useGroup(id); + const add = useAddMember(id); + const remove = useRemoveMember(id); + const [query, setQuery] = useState(''); + const { data: results } = useSearchPersons(query); + + if (!group) return

Lädt…

; + + const memberIds = new Set(group.members.map((m) => m.citizenid)); + + return ( +
+
+
+ +

{group.name}

+ {group.memberCount} Mitglieder +
+ +
+ + {/* Mitglied hinzufügen */} + +

+ Mitglied hinzufügen +

+
+ + setQuery(e.target.value)} placeholder="Person suchen…" className="pl-9" /> +
+ {query.trim().length >= 2 && ( +
+ {results?.filter((p) => !memberIds.has(p.citizenid)).map((p) => ( + + ))} +
+ )} +
+ + {/* Mitglieder */} + +

+ Mitglieder +

+ {group.members.length === 0 &&

Keine Mitglieder.

} + {group.members.map((m) => ( +
+ + {m.name} + {m.citizenid} + + +
+ ))} +
+
+ ); +} diff --git a/apps/tablet-web/src/apps/admin/PermissionTree.tsx b/apps/tablet-web/src/apps/admin/PermissionTree.tsx new file mode 100644 index 0000000..9bb5de7 --- /dev/null +++ b/apps/tablet-web/src/apps/admin/PermissionTree.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronRight, Check, Minus } from 'lucide-react'; +import { PERMISSION_TREE, type Permission, type PermissionLeaf } from '@d4rk-tablet/shared'; +import { cn } from '../../ui/cn'; + +type BoxState = 'on' | 'off' | 'some'; + +function CheckBox({ state, onClick }: { state: BoxState; onClick: () => void }) { + return ( + + ); +} + +export function PermissionTree({ + value, + onChange, + search, +}: { + value: Set; + onChange: (next: Set) => void; + search: string; +}) { + const [collapsed, setCollapsed] = useState>(new Set()); + const q = search.trim().toLowerCase(); + + const leafVisible = (leaf: PermissionLeaf, groupLabel: string, areaLabel: string) => + !q || + leaf.id.toLowerCase().includes(q) || + leaf.label.toLowerCase().includes(q) || + groupLabel.toLowerCase().includes(q) || + areaLabel.toLowerCase().includes(q); + + const toggleLeaf = (id: Permission) => { + const next = new Set(value); + next.has(id) ? next.delete(id) : next.add(id); + onChange(next); + }; + const setMany = (ids: Permission[], on: boolean) => { + const next = new Set(value); + for (const id of ids) (on ? next.add(id) : next.delete(id)); + onChange(next); + }; + const stateOf = (ids: Permission[]): BoxState => { + const on = ids.filter((id) => value.has(id)).length; + return on === 0 ? 'off' : on === ids.length ? 'on' : 'some'; + }; + + return ( +
+ {PERMISSION_TREE.map((area) => { + const visibleGroups = area.groups + .map((g) => ({ ...g, leaves: g.children.filter((l) => leafVisible(l, g.label, area.label)) })) + .filter((g) => g.leaves.length > 0); + if (visibleGroups.length === 0) return null; + + const areaIds = visibleGroups.flatMap((g) => g.leaves.map((l) => l.id)); + const isCollapsed = collapsed.has(area.id) && !q; + + return ( +
+
+ + setMany(areaIds, stateOf(areaIds) !== 'on')} /> + {area.label} + + {areaIds.filter((id) => value.has(id)).length}/{areaIds.length} + +
+ + {!isCollapsed && ( +
+ {visibleGroups.map((g) => { + const groupIds = g.leaves.map((l) => l.id); + return ( +
+
+ setMany(groupIds, stateOf(groupIds) !== 'on')} + /> + + {g.label} + +
+
+ {g.leaves.map((leaf) => ( + + ))} +
+
+ ); + })} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/tablet-web/src/apps/admin/api.ts b/apps/tablet-web/src/apps/admin/api.ts new file mode 100644 index 0000000..caa72bc --- /dev/null +++ b/apps/tablet-web/src/apps/admin/api.ts @@ -0,0 +1,128 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + PERMISSIONS, + type Rank, + type MdtUser, + type Department, + type Permission, + type UpdateRankInput, + type Authority, + type CreateAuthorityInput, + type UpdateAuthorityInput, + type Group, + type GroupDetail, + type CreateGroupInput, +} from '@d4rk-tablet/shared'; +import { api } from '../../core/api-client'; + +export function useRankMatrix(department: Department) { + return useQuery({ + queryKey: ['admin', 'ranks', department], + queryFn: () => api(`/admin/ranks/${department}`), + }); +} + +export function useUpdateRank(department: Department) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ grade, input }: { grade: number; input: UpdateRankInput }) => + api(`/admin/ranks/${department}/${grade}`, { + method: 'PUT', + body: JSON.stringify(input), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'ranks', department] }), + }); +} + +export function useAdminUsers() { + return useQuery({ queryKey: ['admin', 'users'], queryFn: () => api('/admin/users') }); +} + +export function useUpdateUserRoles() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, roles }: { id: number; roles: string[] }) => + api(`/admin/users/${id}/roles`, { method: 'PATCH', body: JSON.stringify({ roles }) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }), + }); +} + +export const ALL_PERMISSIONS: Permission[] = [...PERMISSIONS]; + +// ── Behörden-Registry ── +export function useAuthorities() { + return useQuery({ queryKey: ['authorities'], queryFn: () => api('/authorities') }); +} + +export function useCreateAuthority() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateAuthorityInput) => + api('/admin/authorities', { method: 'POST', body: JSON.stringify(input) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['authorities'] }), + }); +} + +export function useUpdateAuthority() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: UpdateAuthorityInput }) => + api(`/admin/authorities/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['authorities'] }), + }); +} + +export function useDeleteAuthority() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => api<{ ok: boolean }>(`/admin/authorities/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['authorities'] }), + }); +} + +// ── Gruppen ── +export function useGroups() { + return useQuery({ queryKey: ['groups'], queryFn: () => api('/groups') }); +} +export function useGroup(id: number | null) { + return useQuery({ + queryKey: ['groups', id], + queryFn: () => api(`/groups/${id}`), + enabled: id != null, + }); +} +function useInvalidateGroups() { + const qc = useQueryClient(); + return () => qc.invalidateQueries({ queryKey: ['groups'] }); +} +export function useCreateGroup() { + const invalidate = useInvalidateGroups(); + return useMutation({ + mutationFn: (input: CreateGroupInput) => + api('/groups', { method: 'POST', body: JSON.stringify(input) }), + onSuccess: invalidate, + }); +} +export function useDeleteGroup() { + const invalidate = useInvalidateGroups(); + return useMutation({ + mutationFn: (id: number) => api<{ ok: boolean }>(`/groups/${id}`, { method: 'DELETE' }), + onSuccess: invalidate, + }); +} +export function useAddMember(groupId: number) { + const invalidate = useInvalidateGroups(); + return useMutation({ + mutationFn: (input: { citizenid: string; name: string }) => + api(`/groups/${groupId}/members`, { method: 'POST', body: JSON.stringify(input) }), + onSuccess: invalidate, + }); +} +export function useRemoveMember(groupId: number) { + const invalidate = useInvalidateGroups(); + return useMutation({ + mutationFn: (citizenid: string) => + api(`/groups/${groupId}/members/${encodeURIComponent(citizenid)}`, { method: 'DELETE' }), + onSuccess: invalidate, + }); +} diff --git a/apps/tablet-web/src/apps/calendar/CalendarApp.tsx b/apps/tablet-web/src/apps/calendar/CalendarApp.tsx new file mode 100644 index 0000000..3088e03 --- /dev/null +++ b/apps/tablet-web/src/apps/calendar/CalendarApp.tsx @@ -0,0 +1,202 @@ +import { useMemo, useState } from 'react'; +import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'; +import type { CalendarEvent, CreateEventInput } from '@d4rk-tablet/shared'; +import { useCan } from '../../core/permissions'; +import { Button } from '../../ui/Button'; +import { cn } from '../../ui/cn'; +import { useEvents, useCreateEvent, useUpdateEvent } from './api'; +import { EventDetail } from './EventDetail'; +import { EventForm } from './EventForm'; +import { CATEGORY, MONTHS, WEEKDAYS, dayKey, timeLabel } from './labels'; + +function monthGridDays(year: number, month: number): Date[] { + const first = new Date(year, month, 1); + const startOffset = (first.getDay() + 6) % 7; // Montag-first + const start = new Date(year, month, 1 - startOffset); + return Array.from({ length: 42 }, (_, i) => { + const d = new Date(start); + d.setDate(start.getDate() + i); + return d; + }); +} + +type FormMode = null | { mode: 'create'; date?: Date } | { mode: 'edit'; event: CalendarEvent }; + +export function CalendarApp() { + const [cursor, setCursor] = useState(() => { + const n = new Date(); + return { year: n.getFullYear(), month: n.getMonth() }; + }); + const [selectedId, setSelectedId] = useState(null); + const [form, setForm] = useState(null); + const canManage = useCan('mdt.calendar.manage'); + const create = useCreateEvent(); + const update = useUpdateEvent(); + + const days = useMemo(() => monthGridDays(cursor.year, cursor.month), [cursor]); + const from = days[0]!; + const to = new Date(days[41]!); + to.setHours(23, 59, 59, 999); + + const { data: events } = useEvents(from.toISOString(), to.toISOString()); + + const byDay = useMemo(() => { + const map = new Map(); + for (const ev of events ?? []) { + const start = new Date(ev.startAt); + const end = ev.endAt ? new Date(ev.endAt) : new Date(ev.startAt); + const cur = new Date(start.getFullYear(), start.getMonth(), start.getDate()); + const last = new Date(end.getFullYear(), end.getMonth(), end.getDate()); + for (let i = 0; cur <= last && i < 60; i++) { + const k = dayKey(cur); + const list = map.get(k) ?? []; + list.push(ev); + map.set(k, list); + cur.setDate(cur.getDate() + 1); + } + } + return map; + }, [events]); + + const selected = (events ?? []).find((e) => e.id === selectedId) ?? null; + const todayKey = dayKey(new Date()); + + const shift = (delta: number) => { + setCursor((c) => { + const d = new Date(c.year, c.month + delta, 1); + return { year: d.getFullYear(), month: d.getMonth() }; + }); + }; + + if (form) { + const onSubmit = (input: CreateEventInput) => { + if (form.mode === 'create') { + create.mutate(input, { onSuccess: () => setForm(null) }); + } else { + update.mutate( + { id: form.event.id, input }, + { onSuccess: () => setForm(null) }, + ); + } + }; + return ( + setForm(null)} + onSubmit={onSubmit} + /> + ); + } + + return ( +
+
+
+

+ {MONTHS[cursor.month]} {cursor.year} +

+
+ + + +
+
+ {canManage && ( + + )} +
+ + {/* Wochentage */} +
+ {WEEKDAYS.map((w) => ( +
+ {w} +
+ ))} +
+ + {/* Grid */} +
+ {days.map((d) => { + const k = dayKey(d); + const inMonth = d.getMonth() === cursor.month; + const isToday = k === todayKey; + const dayEvents = byDay.get(k) ?? []; + return ( +
canManage && setForm({ mode: 'create', date: d })} + className={cn( + 'flex min-h-24 flex-col gap-1 rounded-lg border border-[var(--color-border)] p-1.5 text-left', + inMonth ? 'bg-[var(--color-surface)]' : 'bg-[var(--color-surface)]/40', + canManage && 'cursor-pointer hover:border-[var(--color-primary)]', + )} + > + + {d.getDate()} + +
+ {dayEvents.slice(0, 3).map((ev) => ( + + ))} + {dayEvents.length > 3 && ( + +{dayEvents.length - 3} mehr + )} +
+
+ ); + })} +
+ + {selected && ( + setSelectedId(null)} + onEdit={() => { + setForm({ mode: 'edit', event: selected }); + setSelectedId(null); + }} + /> + )} +
+ ); +} diff --git a/apps/tablet-web/src/apps/calendar/EventDetail.tsx b/apps/tablet-web/src/apps/calendar/EventDetail.tsx new file mode 100644 index 0000000..4235b61 --- /dev/null +++ b/apps/tablet-web/src/apps/calendar/EventDetail.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react'; +import { X, MapPin, Clock, User, Pencil, Trash2, Search, UserPlus, Check, HelpCircle, Ban } from 'lucide-react'; +import type { AttendeeStatus, CalendarEvent } from '@d4rk-tablet/shared'; +import { useAuth } from '../../core/auth'; +import { useCan } from '../../core/permissions'; +import { useSearchPersons } from '../persons/api'; +import { Button } from '../../ui/Button'; +import { RichTextEditor } from '../../ui/RichTextEditor'; +import { cn } from '../../ui/cn'; +import { useRsvp, useRemoveSelf, useInvite, useRemoveAttendee, useDeleteEvent } from './api'; +import { ATTENDEE_STATUS, CATEGORY, timeLabel } from './labels'; + +const TONE: Record<'success' | 'danger' | 'warning' | 'muted', string> = { + success: 'bg-[var(--color-success)]/15 text-[var(--color-success)]', + danger: 'bg-[var(--color-danger)]/15 text-[var(--color-danger)]', + warning: 'bg-[var(--color-warning)]/15 text-[var(--color-warning)]', + muted: 'bg-[var(--color-surface-2)] text-[var(--color-muted)]', +}; + +function dateRange(ev: CalendarEvent): string { + const start = new Date(ev.startAt); + const dateStr = start.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'long' }); + if (ev.allDay) return `${dateStr} · ganztägig`; + const end = ev.endAt ? ` – ${timeLabel(ev.endAt)}` : ''; + return `${dateStr} · ${timeLabel(ev.startAt)}${end}`; +} + +export function EventDetail({ + event, + onClose, + onEdit, +}: { + event: CalendarEvent; + onClose: () => void; + onEdit: () => void; +}) { + const myId = useAuth((s) => s.user?.citizenid ?? null); + const canManage = useCan('mdt.calendar.manage'); + const rsvp = useRsvp(); + const removeSelf = useRemoveSelf(); + const invite = useInvite(); + const removeAttendee = useRemoveAttendee(); + const del = useDeleteEvent(); + const [query, setQuery] = useState(''); + const { data: persons } = useSearchPersons(query); + + const mine = event.attendees.find((a) => a.citizenid === myId) ?? null; + const cat = CATEGORY[event.category]; + + const setStatus = (status: AttendeeStatus) => rsvp.mutate({ id: event.id, status }); + + return ( +
+
e.stopPropagation()} + > + {/* Kopf */} +
+
+ +
+

{event.title}

+ {cat.label} +
+
+ +
+ + {/* Meta */} +
+ + {dateRange(event)} + + {event.location && ( + + {event.location} + + )} + {event.organizerName && ( + + Organisator: {event.organizerName} + + )} +
+ + {event.description.replace(/<[^>]*>/g, '').trim() && ( +
+ +
+ )} + + {/* Meine Teilnahme */} +
+

Meine Teilnahme

+ {mine && ( +

+ Status:{' '} + + {ATTENDEE_STATUS[mine.status].label} + +

+ )} + {mine || event.openSignup ? ( +
+ + + + {mine && ( + + )} +
+ ) : ( +

Selbst-Eintragung für diesen Termin deaktiviert.

+ )} +
+ + {/* Teilnehmer */} +
+

Teilnehmer ({event.attendees.length})

+ {event.attendees.length === 0 &&

Noch keine Teilnehmer.

} + {event.attendees.map((a) => ( +
+ + + {a.name} + {a.citizenid === myId && (du)} + + + {ATTENDEE_STATUS[a.status].label} + + {canManage && ( + + )} +
+ ))} +
+ + {/* Verwaltung */} + {canManage && ( +
+
+ + setQuery(e.target.value)} + placeholder="Person einladen…" + className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] py-2 pl-8 pr-3 text-sm outline-none focus:border-[var(--color-primary)]" + /> +
+ {query.trim().length >= 2 && ( +
+ {persons + ?.filter((p) => !event.attendees.some((a) => a.citizenid === p.citizenid)) + .map((p) => ( + + ))} +
+ )} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/tablet-web/src/apps/calendar/EventForm.tsx b/apps/tablet-web/src/apps/calendar/EventForm.tsx new file mode 100644 index 0000000..0bc8700 --- /dev/null +++ b/apps/tablet-web/src/apps/calendar/EventForm.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import { Search, X, UserPlus } from 'lucide-react'; +import type { CalendarEvent, CreateEventInput, EventCategory, Invitee } from '@d4rk-tablet/shared'; +import { useSearchPersons } from '../persons/api'; +import { Card } from '../../ui/Card'; +import { Input } from '../../ui/Input'; +import { Button } from '../../ui/Button'; +import { RichTextEditor } from '../../ui/RichTextEditor'; +import { CATEGORY_OPTIONS, toLocalInput } from './labels'; + +const toIso = (local: string): string | null => (local ? new Date(local).toISOString() : null); + +export function EventForm({ + initial, + presetDate, + title, + submitLabel, + submitting, + onSubmit, + onCancel, +}: { + initial?: CalendarEvent; + presetDate?: Date; + title: string; + submitLabel: string; + submitting: boolean; + onSubmit: (input: CreateEventInput) => void; + onCancel: () => void; +}) { + const preset = presetDate + ? (() => { + const d = new Date(presetDate); + d.setHours(12, 0, 0, 0); + return toLocalInput(d.toISOString()); + })() + : ''; + + const [eventTitle, setEventTitle] = useState(initial?.title ?? ''); + const [category, setCategory] = useState(initial?.category ?? 'meeting'); + const [startAt, setStartAt] = useState(initial ? toLocalInput(initial.startAt) : preset); + const [endAt, setEndAt] = useState(initial ? toLocalInput(initial.endAt) : ''); + const [allDay, setAllDay] = useState(initial?.allDay ?? false); + const [location, setLocation] = useState(initial?.location ?? ''); + const [openSignup, setOpenSignup] = useState(initial?.openSignup ?? true); + const [description, setDescription] = useState(initial?.description ?? ''); + const [invitees, setInvitees] = useState([]); + const [query, setQuery] = useState(''); + const { data: persons } = useSearchPersons(query); + + const isEdit = !!initial; + const canSubmit = eventTitle.trim() && startAt && !submitting; + + return ( +
+
+

{title}

+
+ + +
+
+ + + + + + + + + + + + +

Beschreibung

+ +
+ + {!isEdit && ( + +

Personen einladen

+
+ {invitees.map((i) => ( + + {i.name} + + + ))} +
+
+ + setQuery(e.target.value)} placeholder="Person suchen…" className="pl-8" /> +
+
+ {persons + ?.filter((p) => !invitees.some((i) => i.citizenid === p.citizenid)) + .map((p) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/apps/tablet-web/src/apps/calendar/api.ts b/apps/tablet-web/src/apps/calendar/api.ts new file mode 100644 index 0000000..3685bdb --- /dev/null +++ b/apps/tablet-web/src/apps/calendar/api.ts @@ -0,0 +1,86 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + CalendarEvent, + CreateEventInput, + UpdateEventInput, + AttendeeStatus, + Invitee, +} from '@d4rk-tablet/shared'; +import { api } from '../../core/api-client'; + +export function useEvents(fromIso: string, toIso: string) { + return useQuery({ + queryKey: ['calendar', fromIso, toIso], + queryFn: () => + api( + `/calendar/events?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`, + ), + placeholderData: (prev) => prev, + }); +} + +function useInvalidate() { + const qc = useQueryClient(); + return () => void qc.invalidateQueries({ queryKey: ['calendar'] }); +} + +export function useCreateEvent() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (input: CreateEventInput) => + api('/calendar/events', { method: 'POST', body: JSON.stringify(input) }), + onSuccess: invalidate, + }); +} + +export function useUpdateEvent() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: UpdateEventInput }) => + api(`/calendar/events/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), + onSuccess: invalidate, + }); +} + +export function useDeleteEvent() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: number) => api(`/calendar/events/${id}`, { method: 'DELETE' }), + onSuccess: invalidate, + }); +} + +export function useRsvp() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, status }: { id: number; status: AttendeeStatus }) => + api(`/calendar/events/${id}/rsvp`, { method: 'PUT', body: JSON.stringify({ status }) }), + onSuccess: invalidate, + }); +} + +export function useRemoveSelf() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: number) => api(`/calendar/events/${id}/rsvp`, { method: 'DELETE' }), + onSuccess: invalidate, + }); +} + +export function useInvite() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, invitee }: { id: number; invitee: Invitee }) => + api(`/calendar/events/${id}/attendees`, { method: 'POST', body: JSON.stringify(invitee) }), + onSuccess: invalidate, + }); +} + +export function useRemoveAttendee() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, citizenid }: { id: number; citizenid: string }) => + api(`/calendar/events/${id}/attendees/${encodeURIComponent(citizenid)}`, { method: 'DELETE' }), + onSuccess: invalidate, + }); +} diff --git a/apps/tablet-web/src/apps/calendar/labels.ts b/apps/tablet-web/src/apps/calendar/labels.ts new file mode 100644 index 0000000..567125c --- /dev/null +++ b/apps/tablet-web/src/apps/calendar/labels.ts @@ -0,0 +1,42 @@ +import type { AttendeeStatus, EventCategory } from '@d4rk-tablet/shared'; + +export const CATEGORY: Record = { + dienst: { label: 'Dienst', color: '#2f6df6' }, + schulung: { label: 'Schulung', color: '#8b5cf6' }, + meeting: { label: 'Meeting', color: '#f59e0b' }, + event: { label: 'Event', color: '#10b981' }, + sonstiges: { label: 'Sonstiges', color: '#64748b' }, +}; +export const CATEGORY_OPTIONS = (Object.keys(CATEGORY) as EventCategory[]).map((value) => ({ + value, + label: CATEGORY[value].label, +})); + +export const ATTENDEE_STATUS: Record = { + accepted: { label: 'Zugesagt', tone: 'success' }, + declined: { label: 'Abgesagt', tone: 'danger' }, + maybe: { label: 'Vielleicht', tone: 'warning' }, + invited: { label: 'Eingeladen', tone: 'muted' }, +}; + +export const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; +export const MONTHS = [ + 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', +]; + +/** ISO → Wert für (lokale Zeit). */ +export function toLocalInput(iso: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function timeLabel(iso: string): string { + const d = new Date(iso); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +export const dayKey = (d: Date): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; diff --git a/apps/tablet-web/src/apps/charges/CaseCreator.tsx b/apps/tablet-web/src/apps/charges/CaseCreator.tsx new file mode 100644 index 0000000..3f66d73 --- /dev/null +++ b/apps/tablet-web/src/apps/charges/CaseCreator.tsx @@ -0,0 +1,117 @@ +import { useMemo, useState } from 'react'; +import { Minus, Plus, Gavel } from 'lucide-react'; +import type { ChargeCatalogItem } from '@d4rk-tablet/shared'; +import { useCatalog, useCreateCase } from './api'; +import { Card } from '../../ui/Card'; +import { Input } from '../../ui/Input'; +import { Button } from '../../ui/Button'; +import { RichTextEditor } from '../../ui/RichTextEditor'; + +export function CaseCreator({ citizenid, onCreated }: { citizenid: string; onCreated: () => void }) { + const { data: catalog } = useCatalog(); + const create = useCreateCase(citizenid); + const [title, setTitle] = useState(''); + const [narrative, setNarrative] = useState(''); + const [counts, setCounts] = useState>({}); + + function setCount(id: number, delta: number) { + setCounts((prev) => { + const next = Math.max(0, (prev[id] ?? 0) + delta); + return { ...prev, [id]: next }; + }); + } + + const totals = useMemo(() => { + let fine = 0; + let jail = 0; + for (const item of catalog ?? []) { + const c = counts[item.id] ?? 0; + fine += item.fine * c; + jail += item.jailTime * c; + } + return { fine, jail }; + }, [catalog, counts]); + + const selected = Object.entries(counts).filter(([, c]) => c > 0); + const canSubmit = title.trim().length > 0 && selected.length > 0 && !create.isPending; + + function submit() { + create.mutate( + { + title: title.trim(), + suspectCitizenid: citizenid, + narrative, + charges: selected.map(([id, count]) => ({ catalogId: Number(id), count })), + }, + { + onSuccess: () => { + setTitle(''); + setNarrative(''); + setCounts({}); + onCreated(); + }, + }, + ); + } + + return ( + +
+ Neue Strafakte +
+ + setTitle(e.target.value)} placeholder="Titel der Akte…" /> + +
+ {(catalog ?? []).map((item: ChargeCatalogItem) => { + const c = counts[item.id] ?? 0; + return ( +
+ + {item.code} + + {item.title} + + {item.fine}€ · {item.jailTime}M + +
+ + {c} + +
+
+ ); + })} +
+ +
+

Sachverhalt

+ +
+ +
+ + Summe: {totals.fine}€ ·{' '} + {totals.jail} Monate + + +
+
+ ); +} diff --git a/apps/tablet-web/src/apps/charges/ChargesApp.tsx b/apps/tablet-web/src/apps/charges/ChargesApp.tsx new file mode 100644 index 0000000..21642ae --- /dev/null +++ b/apps/tablet-web/src/apps/charges/ChargesApp.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import { Search, User, FileText, ShieldAlert } from 'lucide-react'; +import { useSearchPersons } from '../persons/api'; +import { useCases, useWarrants, useCreateWarrant, useUpdateWarrant } from './api'; +import { CaseCreator } from './CaseCreator'; +import { Card, EmptyState } from '../../ui/Card'; +import { Input } from '../../ui/Input'; +import { Button } from '../../ui/Button'; +import { RichTextEditor } from '../../ui/RichTextEditor'; +import { cn } from '../../ui/cn'; + +interface Selected { + citizenid: string; + name: string; +} + +export function ChargesApp() { + const [query, setQuery] = useState(''); + const [selected, setSelected] = useState(null); + const { data: results } = useSearchPersons(query); + + return ( +
+
+
+ + setQuery(e.target.value)} placeholder="Beschuldigten suchen…" className="pl-9" /> +
+
+ {results?.map((p) => ( + + ))} +
+
+ +
+ {selected ? ( + + ) : ( + + )} +
+
+ ); +} + +function SuspectView({ selected }: { selected: Selected }) { + const { citizenid, name } = selected; + const { data: cases } = useCases(citizenid); + const { data: warrants } = useWarrants(citizenid); + const createWarrant = useCreateWarrant(citizenid); + const updateWarrant = useUpdateWarrant(citizenid); + const [reason, setReason] = useState(''); + + return ( +
+

{name}

+ + undefined} /> + + {/* Haftbefehl ausstellen */} + +

Haftbefehl ausstellen

+
+ setReason(e.target.value)} placeholder="Grund…" /> + +
+ {warrants && warrants.length > 0 && ( +
+ {warrants.map((w) => ( +
+ + {w.reason} + {w.status} + {w.status === 'active' && ( + + )} +
+ ))} +
+ )} +
+ + {/* Bestehende Akten */} + +

Strafakten ({cases?.length ?? 0})

+ {cases?.length === 0 &&

Keine Akten.

} + {cases?.map((c) => ( +
+
+ + {c.title} + {c.status} +
+
+ {c.charges.map((ch, i) => ( + + {ch.count}× {ch.code} + + ))} +
+

+ Gesamt: {c.totalFine}€ · {c.totalJailTime} Monate +

+ {c.narrative.replace(/<[^>]*>/g, '').trim() && ( +
+ +
+ )} +
+ ))} +
+
+ ); +} diff --git a/apps/tablet-web/src/apps/charges/api.ts b/apps/tablet-web/src/apps/charges/api.ts new file mode 100644 index 0000000..441a9dd --- /dev/null +++ b/apps/tablet-web/src/apps/charges/api.ts @@ -0,0 +1,70 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + Case, + ChargeCatalogItem, + CreateCaseInput, + Warrant, + CreateWarrantInput, + UpdateWarrantInput, +} from '@d4rk-tablet/shared'; +import { api } from '../../core/api-client'; + +export function useCatalog() { + return useQuery({ + queryKey: ['charges', 'catalog'], + queryFn: () => api('/charges/catalog'), + staleTime: 5 * 60_000, + }); +} + +export function useCases(citizenid: string | null) { + return useQuery({ + queryKey: ['cases', citizenid], + queryFn: () => api(`/cases?citizenid=${citizenid}`), + enabled: !!citizenid, + }); +} + +export function useWarrants(citizenid: string | null) { + return useQuery({ + queryKey: ['warrants', citizenid], + queryFn: () => api(`/warrants?citizenid=${citizenid}`), + enabled: !!citizenid, + }); +} + +function useInvalidatePerson(citizenid: string) { + const qc = useQueryClient(); + return () => { + void qc.invalidateQueries({ queryKey: ['cases', citizenid] }); + void qc.invalidateQueries({ queryKey: ['warrants', citizenid] }); + void qc.invalidateQueries({ queryKey: ['persons', 'detail', citizenid] }); + }; +} + +export function useCreateCase(citizenid: string) { + const invalidate = useInvalidatePerson(citizenid); + return useMutation({ + mutationFn: (input: CreateCaseInput) => + api('/cases', { method: 'POST', body: JSON.stringify(input) }), + onSuccess: invalidate, + }); +} + +export function useCreateWarrant(citizenid: string) { + const invalidate = useInvalidatePerson(citizenid); + return useMutation({ + mutationFn: (input: CreateWarrantInput) => + api('/warrants', { method: 'POST', body: JSON.stringify(input) }), + onSuccess: invalidate, + }); +} + +export function useUpdateWarrant(citizenid: string) { + const invalidate = useInvalidatePerson(citizenid); + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: UpdateWarrantInput }) => + api(`/warrants/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), + onSuccess: invalidate, + }); +} diff --git a/apps/tablet-web/src/apps/dashboard/StartApp.tsx b/apps/tablet-web/src/apps/dashboard/StartApp.tsx new file mode 100644 index 0000000..c6cdafc --- /dev/null +++ b/apps/tablet-web/src/apps/dashboard/StartApp.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react'; +import { Pin, Trash2, AlertCircle, Plus, CalendarDays } from 'lucide-react'; +import type { Announcement } from '@d4rk-tablet/shared'; +import { useAnnouncements, useCreateAnnouncement, useUpdateAnnouncement, useDeleteAnnouncement } from './api'; +import { useCan } from '../../core/permissions'; +import { Card } from '../../ui/Card'; +import { Input } from '../../ui/Input'; +import { Button } from '../../ui/Button'; + +function useClock() { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, []); + return now; +} + +export function StartApp() { + const now = useClock(); + const { data: posts } = useAnnouncements(); + const canManage = useCan('mdt.board.manage'); + const [composing, setComposing] = useState(false); + + return ( +
+
+ {/* Uhr */} + +

+ {now.toLocaleDateString('de-DE', { weekday: 'long' })} +

+

+ {now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} + + {now.toLocaleTimeString('de-DE', { second: '2-digit' })} + +

+

+ {now.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} +

+
+ + {/* Termine (Platzhalter bis Kalender-Modul) */} + +

+ Heutige Termine +

+
+ Heute keine Termine geplant. +
+
+
+ + {/* Schwarzes Brett */} +
+

Schwarzes Brett

+ {canManage && ( + + )} +
+ + {composing && setComposing(false)} />} + +
+ {posts?.length === 0 && ( +

Noch keine Beiträge.

+ )} + {posts?.map((p) => ( + + ))} +
+
+ ); +} + +function ComposeForm({ onDone }: { onDone: () => void }) { + const create = useCreateAnnouncement(); + const [title, setTitle] = useState(''); + const [body, setBody] = useState(''); + const [important, setImportant] = useState(false); + + return ( + + setTitle(e.target.value)} placeholder="Titel" /> +