first commit

This commit is contained in:
2026-07-16 09:35:32 +02:00
commit dd0746e2e6
179 changed files with 25679 additions and 0 deletions
+17
View File
@@ -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
}
]
}
+50
View File
@@ -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
+35
View File
@@ -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
+12
View File
@@ -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"
]
}
+51
View File
@@ -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 })`.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>d4rk_tablet — MDT</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+49
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
+39
View File
@@ -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 (
<div className="flex h-full flex-col items-center justify-center gap-4">
<p className="text-lg font-medium">MDT Anmeldung erforderlich</p>
<a
href={`${config.apiUrl}/auth/discord/login`}
className="rounded-lg bg-[var(--color-primary)] px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-[var(--color-primary-hover)]"
>
Mit Discord anmelden
</a>
</div>
);
}
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 (
<QueryClientProvider client={queryClient}>
<div className="h-full w-full">
{!ready ? <EmptyState title="Lädt…" /> : !user ? <LoginScreen /> : <Shell />}
</div>
</QueryClientProvider>
);
}
+309
View File
@@ -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<Tab>('ranks');
return (
<div className="flex h-full flex-col gap-4">
<div className="flex gap-2">
<TabButton active={tab === 'ranks'} onClick={() => setTab('ranks')} icon={<ShieldCheck size={16} />}>
Rechte-Matrix
</TabButton>
<TabButton active={tab === 'users'} onClick={() => setTab('users')} icon={<UsersIcon size={16} />}>
Mitarbeiter
</TabButton>
<TabButton active={tab === 'authorities'} onClick={() => setTab('authorities')} icon={<Building2 size={16} />}>
Behörden
</TabButton>
<TabButton active={tab === 'groups'} onClick={() => setTab('groups')} icon={<UsersRound size={16} />}>
Gruppen
</TabButton>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{tab === 'ranks' && <RankMatrix />}
{tab === 'users' && <UsersList />}
{tab === 'authorities' && <AuthoritiesTab />}
{tab === 'groups' && <GroupsTab />}
</div>
</div>
);
}
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<Department>('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 (
<div className="flex flex-col gap-3">
<div className="flex justify-end">
<Button onClick={() => setForm((v) => !v)}>
<span className="flex items-center gap-1.5"><Plus size={14} /> Neue Behörde</span>
</Button>
</div>
{form && (
<Card className="flex flex-col gap-2">
<div className="flex gap-2">
<Input value={key} onChange={(e) => setKey(e.target.value)} placeholder="Key (z.B. lspd)" className="w-40" />
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="h-9 w-12 rounded border border-[var(--color-border)] bg-transparent" />
</div>
<div className="flex gap-2">
<select value={department} onChange={(e) => setDepartment(e.target.value as Department)} className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-2 text-sm capitalize outline-none">
{DEPARTMENTS.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
<Input value={jobs} onChange={(e) => setJobs(e.target.value)} placeholder="QBox-Jobs (Komma-getrennt): police, leo" />
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setForm(false)}>Abbrechen</Button>
<Button disabled={!key.trim() || !name.trim() || create.isPending} onClick={submit}>Anlegen</Button>
</div>
</Card>
)}
{authorities?.map((a) => (
<Card key={a.id} className="flex items-center gap-3">
<span className="h-4 w-4 shrink-0 rounded-full" style={{ background: a.color }} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{a.name} <span className="font-mono text-xs text-[var(--color-muted)]">{a.key}</span>
</p>
<p className="text-xs capitalize text-[var(--color-muted)]">{a.department}</p>
</div>
<Input
defaultValue={a.jobs.join(', ')}
title="QBox-Jobs (Komma-getrennt)"
onBlur={(e) => {
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"
/>
<button onClick={() => del.mutate(a.id)} className="shrink-0 rounded p-1.5 text-[var(--color-muted)] hover:text-[var(--color-danger)]">
<Trash2 size={15} />
</button>
</Card>
))}
</div>
);
}
function TabButton({ active, onClick, icon, children }: { active: boolean; onClick: () => void; icon: React.ReactNode; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className={cn(
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm transition-colors',
active ? 'bg-[var(--color-primary)] text-white' : 'bg-[var(--color-surface-2)] text-[var(--color-muted)] hover:text-[var(--color-text)]',
)}
>
{icon}
{children}
</button>
);
}
function RankMatrix() {
const [dept, setDept] = useState<Department>('police');
const [grade, setGrade] = useState<number | null>(null);
const { data: ranks } = useRankMatrix(dept);
const selected = ranks?.find((r) => r.grade === grade) ?? ranks?.[0] ?? null;
return (
<div className="flex h-full flex-col gap-3">
<div className="flex gap-2">
{DEPARTMENTS.map((d) => (
<button
key={d}
onClick={() => {
setDept(d);
setGrade(null);
}}
className={cn(
'rounded-lg px-3 py-1.5 text-sm capitalize transition-colors',
dept === d ? 'bg-[var(--color-primary)] text-white' : 'bg-[var(--color-surface-2)] text-[var(--color-muted)]',
)}
>
{d}
</button>
))}
</div>
<div className="flex min-h-0 flex-1 gap-4">
{/* Rang-Liste */}
<div className="flex w-56 shrink-0 flex-col gap-1 overflow-auto">
{ranks?.map((rank) => (
<button
key={rank.grade}
onClick={() => setGrade(rank.grade)}
className={cn(
'flex items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors',
selected?.grade === rank.grade
? 'bg-[var(--color-surface-2)]'
: 'hover:bg-[var(--color-surface-2)]',
)}
>
<span>
{rank.label}
<span className="block text-xs text-[var(--color-muted)]">Grade {rank.grade}</span>
</span>
<span className="text-xs text-[var(--color-muted)]">{rank.permissions.length}</span>
</button>
))}
</div>
{/* Editor */}
<div className="min-w-0 flex-1 overflow-auto">
{selected ? (
<RankEditor key={`${dept}-${selected.grade}`} department={dept} rank={selected} />
) : (
<p className="text-sm text-[var(--color-muted)]">Rang auswählen</p>
)}
</div>
</div>
</div>
);
}
function RankEditor({ department, rank }: { department: Department; rank: Rank }) {
const update = useUpdateRank(department);
const [perms, setPerms] = useState<Set<Permission>>(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 (
<Card className="flex h-full flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div>
<span className="text-xs text-[var(--color-muted)]">Grade {rank.grade}</span>
<p className="font-medium">{rank.label}</p>
</div>
<div className="flex items-center gap-2">
{/* Template anwenden */}
<div className="relative">
<Button variant="ghost" onClick={() => setTemplateOpen((o) => !o)}>
<span className="flex items-center gap-1.5">
<Wand2 size={14} /> Template
</span>
</Button>
{templateOpen && (
<div className="absolute right-0 z-20 mt-1 w-48 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] p-1 shadow-xl">
{Object.keys(ROLE_TEMPLATES).map((name) => (
<button
key={name}
onClick={() => {
setPerms(new Set(ROLE_TEMPLATES[name] ?? []));
setTemplateOpen(false);
}}
className="block w-full rounded px-3 py-1.5 text-left text-sm hover:bg-[var(--color-border)]"
>
{name}
</button>
))}
</div>
)}
</div>
<Button
disabled={!changed || update.isPending}
onClick={() => update.mutate({ grade: rank.grade, input: { label: rank.label, permissions: [...perms] } })}
>
{update.isPending ? 'Speichert…' : 'Speichern'}
</Button>
</div>
</div>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-muted)]" />
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Recht suchen…" className="pl-9" />
</div>
<div className="min-h-0 flex-1 overflow-auto">
<PermissionTree value={perms} onChange={setPerms} search={search} />
</div>
</Card>
);
}
const OVERRIDE_ROLES = ['admin', 'dispatch'] as const;
function UsersList() {
const { data: users } = useAdminUsers();
const update = useUpdateUserRoles();
return (
<div className="flex flex-col gap-2">
{users?.length === 0 && <p className="text-sm text-[var(--color-muted)]">Keine Mitarbeiter erfasst.</p>}
{users?.map((u) => (
<Card key={u.id} className="flex items-center gap-4">
<div className="flex-1">
<p className="font-medium">{u.name}</p>
<p className="text-xs text-[var(--color-muted)]">
{u.department ? `${u.department} · Grade ${u.grade ?? '—'}` : 'keine Behörde'}
{u.citizenid && ` · ${u.citizenid}`}
</p>
</div>
<div className="flex gap-1.5">
{OVERRIDE_ROLES.map((role) => {
const on = u.roles.includes(role);
return (
<button
key={role}
disabled={update.isPending}
onClick={() => {
const roles = on ? u.roles.filter((r) => r !== role) : [...u.roles, role];
update.mutate({ id: u.id, roles });
}}
className={cn(
'rounded-full px-3 py-1 text-xs capitalize transition-colors',
on ? 'bg-[var(--color-primary)] text-white' : 'bg-[var(--color-surface-2)] text-[var(--color-muted)] hover:text-[var(--color-text)]',
)}
>
{role}
</button>
);
})}
</div>
</Card>
))}
</div>
);
}
@@ -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<number | null>(null);
const [form, setForm] = useState(false);
const [name, setName] = useState('');
const [color, setColor] = useState('#8b5cf6');
return (
<div className="flex h-full gap-4">
{/* Gruppenliste */}
<div className="flex w-64 shrink-0 flex-col gap-2 overflow-auto">
<Button onClick={() => setForm((v) => !v)}>
<span className="flex items-center justify-center gap-1.5">
<Plus size={14} /> Neue Gruppe
</span>
</Button>
{form && (
<Card className="flex flex-col gap-2">
<div className="flex gap-2">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Gruppenname" />
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-9 w-10 shrink-0 rounded border border-[var(--color-border)] bg-transparent"
/>
</div>
<Button
disabled={!name.trim() || create.isPending}
onClick={() =>
create.mutate({ name: name.trim(), color }, { onSuccess: () => { setName(''); setForm(false); } })
}
>
Anlegen
</Button>
</Card>
)}
{groups?.map((g) => (
<button
key={g.id}
onClick={() => setSelected(g.id)}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors',
selected === g.id ? 'bg-[var(--color-surface-2)]' : 'hover:bg-[var(--color-surface-2)]',
)}
>
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: g.color }} />
<span className="flex-1 truncate">{g.name}</span>
<span className="text-xs text-[var(--color-muted)]">{g.memberCount}</span>
</button>
))}
</div>
{/* Gruppendetail */}
<div className="min-w-0 flex-1 overflow-auto">
{selected ? (
<GroupDetailView id={selected} onDeleted={() => setSelected(null)} onDelete={del.mutate} />
) : (
<EmptyState title="Gruppen" hint="Gruppe auswählen oder neu anlegen." />
)}
</div>
</div>
);
}
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 <p className="text-sm text-[var(--color-muted)]">Lädt</p>;
const memberIds = new Set(group.members.map((m) => m.citizenid));
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="h-4 w-4 rounded-full" style={{ background: group.color }} />
<h2 className="text-xl font-semibold">{group.name}</h2>
<span className="text-sm text-[var(--color-muted)]">{group.memberCount} Mitglieder</span>
</div>
<Button
variant="danger"
onClick={() => {
onDelete(id);
onDeleted();
}}
>
<span className="flex items-center gap-1.5"><Trash2 size={14} /> Gruppe löschen</span>
</Button>
</div>
{/* Mitglied hinzufügen */}
<Card className="flex flex-col gap-2">
<p className="flex items-center gap-2 text-sm font-medium">
<UserPlus size={16} className="text-[var(--color-primary)]" /> Mitglied hinzufügen
</p>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-muted)]" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Person suchen…" className="pl-9" />
</div>
{query.trim().length >= 2 && (
<div className="flex flex-col gap-1">
{results?.filter((p) => !memberIds.has(p.citizenid)).map((p) => (
<button
key={p.citizenid}
onClick={() =>
add.mutate(
{ citizenid: p.citizenid, name: `${p.firstname} ${p.lastname}` },
{ onSuccess: () => setQuery('') },
)
}
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm hover:bg-[var(--color-surface-2)]"
>
<Plus size={14} className="text-[var(--color-primary)]" />
{p.firstname} {p.lastname}
<span className="text-xs text-[var(--color-muted)]">{p.citizenid}</span>
</button>
))}
</div>
)}
</Card>
{/* Mitglieder */}
<Card className="flex flex-col gap-1">
<p className="mb-1 flex items-center gap-2 text-sm font-medium">
<Users size={16} /> Mitglieder
</p>
{group.members.length === 0 && <p className="text-sm text-[var(--color-muted)]">Keine Mitglieder.</p>}
{group.members.map((m) => (
<div key={m.citizenid} className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-2)] px-3 py-2 text-sm">
<span className="flex-1">
{m.name}
<span className="ml-2 font-mono text-xs text-[var(--color-muted)]">{m.citizenid}</span>
</span>
<button
onClick={() => remove.mutate(m.citizenid)}
className="rounded p-1 text-[var(--color-muted)] hover:text-[var(--color-danger)]"
>
<X size={15} />
</button>
</div>
))}
</Card>
</div>
);
}
@@ -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 (
<button
type="button"
onClick={onClick}
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors',
state === 'off'
? 'border-[var(--color-border)] bg-transparent'
: 'border-[var(--color-primary)] bg-[var(--color-primary)] text-white',
)}
>
{state === 'on' && <Check size={11} />}
{state === 'some' && <Minus size={11} />}
</button>
);
}
export function PermissionTree({
value,
onChange,
search,
}: {
value: Set<Permission>;
onChange: (next: Set<Permission>) => void;
search: string;
}) {
const [collapsed, setCollapsed] = useState<Set<string>>(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 (
<div className="flex flex-col gap-1">
{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 (
<div key={area.id} className="rounded-lg border border-[var(--color-border)]">
<div className="flex items-center gap-2 px-2 py-1.5">
<button
type="button"
onClick={() =>
setCollapsed((prev) => {
const n = new Set(prev);
n.has(area.id) ? n.delete(area.id) : n.add(area.id);
return n;
})
}
className="text-[var(--color-muted)]"
>
{isCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
</button>
<CheckBox state={stateOf(areaIds)} onClick={() => setMany(areaIds, stateOf(areaIds) !== 'on')} />
<span className="text-sm font-semibold">{area.label}</span>
<span className="text-xs text-[var(--color-muted)]">
{areaIds.filter((id) => value.has(id)).length}/{areaIds.length}
</span>
</div>
{!isCollapsed && (
<div className="flex flex-col gap-2 px-3 pb-2">
{visibleGroups.map((g) => {
const groupIds = g.leaves.map((l) => l.id);
return (
<div key={g.id}>
<div className="flex items-center gap-2 py-1">
<CheckBox
state={stateOf(groupIds)}
onClick={() => setMany(groupIds, stateOf(groupIds) !== 'on')}
/>
<span className="text-xs font-medium uppercase tracking-wide text-[var(--color-muted)]">
{g.label}
</span>
</div>
<div className="ml-6 flex flex-col gap-1">
{g.leaves.map((leaf) => (
<label
key={leaf.id}
className="flex cursor-pointer items-center gap-2 text-sm"
onClick={(e) => {
e.preventDefault();
toggleLeaf(leaf.id);
}}
>
<CheckBox state={value.has(leaf.id) ? 'on' : 'off'} onClick={() => toggleLeaf(leaf.id)} />
<span>{leaf.label}</span>
<span className="font-mono text-xs text-[var(--color-muted)]">{leaf.id}</span>
</label>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
);
}
+128
View File
@@ -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<Rank[]>(`/admin/ranks/${department}`),
});
}
export function useUpdateRank(department: Department) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ grade, input }: { grade: number; input: UpdateRankInput }) =>
api<Rank>(`/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<MdtUser[]>('/admin/users') });
}
export function useUpdateUserRoles() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, roles }: { id: number; roles: string[] }) =>
api<MdtUser>(`/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<Authority[]>('/authorities') });
}
export function useCreateAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateAuthorityInput) =>
api<Authority>('/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<Authority>(`/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<Group[]>('/groups') });
}
export function useGroup(id: number | null) {
return useQuery({
queryKey: ['groups', id],
queryFn: () => api<GroupDetail>(`/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<Group>('/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<GroupDetail>(`/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<GroupDetail>(`/groups/${groupId}/members/${encodeURIComponent(citizenid)}`, { method: 'DELETE' }),
onSuccess: invalidate,
});
}
@@ -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<number | null>(null);
const [form, setForm] = useState<FormMode>(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<string, CalendarEvent[]>();
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 (
<EventForm
initial={form.mode === 'edit' ? form.event : undefined}
presetDate={form.mode === 'create' ? form.date : undefined}
title={form.mode === 'edit' ? 'Termin bearbeiten' : 'Neuer Termin'}
submitLabel={form.mode === 'edit' ? 'Speichern' : 'Anlegen'}
submitting={create.isPending || update.isPending}
onCancel={() => setForm(null)}
onSubmit={onSubmit}
/>
);
}
return (
<div className="mx-auto flex max-w-5xl flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold">
{MONTHS[cursor.month]} {cursor.year}
</h2>
<div className="flex items-center gap-1">
<Button variant="ghost" onClick={() => shift(-1)} className="px-2 py-1">
<ChevronLeft size={16} />
</Button>
<Button
variant="ghost"
onClick={() => {
const n = new Date();
setCursor({ year: n.getFullYear(), month: n.getMonth() });
}}
className="px-3 py-1 text-xs"
>
Heute
</Button>
<Button variant="ghost" onClick={() => shift(1)} className="px-2 py-1">
<ChevronRight size={16} />
</Button>
</div>
</div>
{canManage && (
<Button onClick={() => setForm({ mode: 'create' })} className="flex items-center gap-1.5">
<Plus size={16} /> Neuer Termin
</Button>
)}
</div>
{/* Wochentage */}
<div className="grid grid-cols-7 gap-1 text-center text-xs font-medium uppercase tracking-wide text-[var(--color-muted)]">
{WEEKDAYS.map((w) => (
<div key={w} className="py-1">
{w}
</div>
))}
</div>
{/* Grid */}
<div className="grid grid-cols-7 gap-1">
{days.map((d) => {
const k = dayKey(d);
const inMonth = d.getMonth() === cursor.month;
const isToday = k === todayKey;
const dayEvents = byDay.get(k) ?? [];
return (
<div
key={k}
onClick={() => 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)]',
)}
>
<span
className={cn(
'text-xs',
isToday
? 'flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-primary)] font-semibold text-white'
: inMonth
? 'text-[var(--color-text)]'
: 'text-[var(--color-muted)]',
)}
>
{d.getDate()}
</span>
<div className="flex flex-col gap-0.5">
{dayEvents.slice(0, 3).map((ev) => (
<button
key={ev.id}
onClick={(e) => {
e.stopPropagation();
setSelectedId(ev.id);
}}
className="flex items-center gap-1 truncate rounded px-1 py-0.5 text-left text-[11px] text-white"
style={{ background: CATEGORY[ev.category].color }}
>
{!ev.allDay && <span className="shrink-0 opacity-80">{timeLabel(ev.startAt)}</span>}
<span className="truncate">{ev.title}</span>
</button>
))}
{dayEvents.length > 3 && (
<span className="px-1 text-[10px] text-[var(--color-muted)]">+{dayEvents.length - 3} mehr</span>
)}
</div>
</div>
);
})}
</div>
{selected && (
<EventDetail
event={selected}
onClose={() => setSelectedId(null)}
onEdit={() => {
setForm({ mode: 'edit', event: selected });
setSelectedId(null);
}}
/>
)}
</div>
);
}
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
<div
className="flex max-h-[85vh] w-full max-w-lg flex-col gap-4 overflow-auto rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5"
onClick={(e) => e.stopPropagation()}
>
{/* Kopf */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<span className="mt-1.5 h-3 w-3 shrink-0 rounded-full" style={{ background: cat.color }} />
<div>
<h2 className="text-lg font-semibold">{event.title}</h2>
<span className="text-xs text-[var(--color-muted)]">{cat.label}</span>
</div>
</div>
<button onClick={onClose} className="rounded p-1 text-[var(--color-muted)] hover:text-[var(--color-text)]">
<X size={18} />
</button>
</div>
{/* Meta */}
<div className="flex flex-col gap-1.5 text-sm">
<span className="flex items-center gap-2">
<Clock size={14} className="text-[var(--color-muted)]" /> {dateRange(event)}
</span>
{event.location && (
<span className="flex items-center gap-2">
<MapPin size={14} className="text-[var(--color-muted)]" /> {event.location}
</span>
)}
{event.organizerName && (
<span className="flex items-center gap-2 text-[var(--color-muted)]">
<User size={14} /> Organisator: {event.organizerName}
</span>
)}
</div>
{event.description.replace(/<[^>]*>/g, '').trim() && (
<div className="rounded-lg bg-[var(--color-surface-2)] p-3">
<RichTextEditor content={event.description} editable={false} />
</div>
)}
{/* Meine Teilnahme */}
<div className="flex flex-col gap-2 rounded-lg border border-[var(--color-border)] p-3">
<p className="text-sm font-medium">Meine Teilnahme</p>
{mine && (
<p className="text-xs text-[var(--color-muted)]">
Status:{' '}
<span className={cn('rounded-full px-2 py-0.5', TONE[ATTENDEE_STATUS[mine.status].tone])}>
{ATTENDEE_STATUS[mine.status].label}
</span>
</p>
)}
{mine || event.openSignup ? (
<div className="flex flex-wrap gap-2">
<Button onClick={() => setStatus('accepted')} disabled={rsvp.isPending} className="flex items-center gap-1.5 px-3 py-1.5 text-xs">
<Check size={13} /> Zusagen
</Button>
<Button variant="ghost" onClick={() => setStatus('maybe')} disabled={rsvp.isPending} className="flex items-center gap-1.5 px-3 py-1.5 text-xs">
<HelpCircle size={13} /> Vielleicht
</Button>
<Button variant="ghost" onClick={() => setStatus('declined')} disabled={rsvp.isPending} className="flex items-center gap-1.5 px-3 py-1.5 text-xs">
<Ban size={13} /> Absagen
</Button>
{mine && (
<Button variant="ghost" onClick={() => removeSelf.mutate(event.id)} disabled={removeSelf.isPending} className="px-3 py-1.5 text-xs">
Austragen
</Button>
)}
</div>
) : (
<p className="text-xs text-[var(--color-muted)]">Selbst-Eintragung für diesen Termin deaktiviert.</p>
)}
</div>
{/* Teilnehmer */}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Teilnehmer ({event.attendees.length})</p>
{event.attendees.length === 0 && <p className="text-sm text-[var(--color-muted)]">Noch keine Teilnehmer.</p>}
{event.attendees.map((a) => (
<div key={a.citizenid} className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-2)] px-3 py-1.5 text-sm">
<User size={14} className="text-[var(--color-muted)]" />
<span className="flex-1">
{a.name}
{a.citizenid === myId && <span className="ml-1 text-xs text-[var(--color-muted)]">(du)</span>}
</span>
<span className={cn('rounded-full px-2 py-0.5 text-xs', TONE[ATTENDEE_STATUS[a.status].tone])}>
{ATTENDEE_STATUS[a.status].label}
</span>
{canManage && (
<button
onClick={() => removeAttendee.mutate({ id: event.id, citizenid: a.citizenid })}
className="rounded p-0.5 text-[var(--color-muted)] hover:text-[var(--color-danger)]"
>
<X size={13} />
</button>
)}
</div>
))}
</div>
{/* Verwaltung */}
{canManage && (
<div className="flex flex-col gap-2 border-t border-[var(--color-border)] pt-3">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-muted)]" />
<input
value={query}
onChange={(e) => 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)]"
/>
</div>
{query.trim().length >= 2 && (
<div className="flex max-h-32 flex-col gap-0.5 overflow-auto">
{persons
?.filter((p) => !event.attendees.some((a) => a.citizenid === p.citizenid))
.map((p) => (
<button
key={p.citizenid}
onClick={() =>
invite.mutate({
id: event.id,
invitee: { citizenid: p.citizenid, name: `${p.firstname} ${p.lastname}` },
})
}
className="flex items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-[var(--color-surface-2)]"
>
<UserPlus size={13} className="text-[var(--color-muted)]" />
{p.firstname} {p.lastname}
</button>
))}
</div>
)}
<div className="flex gap-2">
<Button variant="ghost" onClick={onEdit} className="flex items-center gap-1.5">
<Pencil size={14} /> Bearbeiten
</Button>
<Button
variant="danger"
onClick={() => {
if (confirm(`Termin „${event.title}" löschen?`)) del.mutate(event.id, { onSuccess: onClose });
}}
className="flex items-center gap-1.5"
>
<Trash2 size={14} /> Löschen
</Button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -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<EventCategory>(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<Invitee[]>([]);
const [query, setQuery] = useState('');
const { data: persons } = useSearchPersons(query);
const isEdit = !!initial;
const canSubmit = eventTitle.trim() && startAt && !submitting;
return (
<div className="mx-auto flex max-w-2xl flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{title}</h2>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} disabled={submitting}>
Abbrechen
</Button>
<Button
disabled={!canSubmit}
onClick={() =>
onSubmit({
title: eventTitle.trim(),
description,
category,
startAt: toIso(startAt)!,
endAt: toIso(endAt),
allDay,
location: location.trim() || null,
openSignup,
invitees,
})
}
>
{submitting ? 'Speichert…' : submitLabel}
</Button>
</div>
</div>
<Card className="grid grid-cols-2 gap-3">
<label className="col-span-2 flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Titel *</span>
<Input value={eventTitle} onChange={(e) => setEventTitle(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Kategorie</span>
<select
value={category}
onChange={(e) => setCategory(e.target.value as EventCategory)}
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-sm outline-none focus:border-[var(--color-primary)]"
>
{CATEGORY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Ort</span>
<Input value={location} onChange={(e) => setLocation(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Beginn *</span>
<Input type="datetime-local" value={startAt} onChange={(e) => setStartAt(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Ende</span>
<Input type="datetime-local" value={endAt} onChange={(e) => setEndAt(e.target.value)} />
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} />
Ganztägig
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={openSignup} onChange={(e) => setOpenSignup(e.target.checked)} />
Selbst-Eintragung erlaubt
</label>
</Card>
<Card className="flex flex-col gap-2">
<p className="text-sm font-medium">Beschreibung</p>
<RichTextEditor content={description} onChange={setDescription} editable />
</Card>
{!isEdit && (
<Card className="flex flex-col gap-2">
<p className="text-sm font-medium">Personen einladen</p>
<div className="flex flex-wrap gap-1.5">
{invitees.map((i) => (
<span key={i.citizenid} className="flex items-center gap-1 rounded-full bg-[var(--color-surface-2)] px-2 py-0.5 text-xs">
{i.name}
<button onClick={() => setInvitees((prev) => prev.filter((x) => x.citizenid !== i.citizenid))}>
<X size={11} />
</button>
</span>
))}
</div>
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-muted)]" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Person suchen…" className="pl-8" />
</div>
<div className="flex max-h-40 flex-col gap-0.5 overflow-auto">
{persons
?.filter((p) => !invitees.some((i) => i.citizenid === p.citizenid))
.map((p) => (
<button
key={p.citizenid}
onClick={() =>
setInvitees((prev) => [...prev, { citizenid: p.citizenid, name: `${p.firstname} ${p.lastname}` }])
}
className="flex items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-[var(--color-surface-2)]"
>
<UserPlus size={13} className="text-[var(--color-muted)]" />
{p.firstname} {p.lastname}
<span className="font-mono text-xs text-[var(--color-muted)]">{p.citizenid}</span>
</button>
))}
</div>
</Card>
)}
</div>
);
}
+86
View File
@@ -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<CalendarEvent[]>(
`/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<CalendarEvent>('/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<CalendarEvent>(`/calendar/events/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
onSuccess: invalidate,
});
}
export function useDeleteEvent() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: number) => api<void>(`/calendar/events/${id}`, { method: 'DELETE' }),
onSuccess: invalidate,
});
}
export function useRsvp() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, status }: { id: number; status: AttendeeStatus }) =>
api<CalendarEvent>(`/calendar/events/${id}/rsvp`, { method: 'PUT', body: JSON.stringify({ status }) }),
onSuccess: invalidate,
});
}
export function useRemoveSelf() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: number) => api<CalendarEvent>(`/calendar/events/${id}/rsvp`, { method: 'DELETE' }),
onSuccess: invalidate,
});
}
export function useInvite() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, invitee }: { id: number; invitee: Invitee }) =>
api<CalendarEvent>(`/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<CalendarEvent>(`/calendar/events/${id}/attendees/${encodeURIComponent(citizenid)}`, { method: 'DELETE' }),
onSuccess: invalidate,
});
}
@@ -0,0 +1,42 @@
import type { AttendeeStatus, EventCategory } from '@d4rk-tablet/shared';
export const CATEGORY: Record<EventCategory, { label: string; color: string }> = {
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<AttendeeStatus, { label: string; tone: 'success' | 'danger' | 'warning' | 'muted' }> = {
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 <input type="datetime-local"> (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')}`;
@@ -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<Record<number, number>>({});
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 (
<Card className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Gavel size={16} className="text-[var(--color-primary)]" /> Neue Strafakte
</div>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Titel der Akte…" />
<div className="flex max-h-52 flex-col gap-1 overflow-auto">
{(catalog ?? []).map((item: ChargeCatalogItem) => {
const c = counts[item.id] ?? 0;
return (
<div
key={item.id}
className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-2)] px-3 py-1.5 text-sm"
>
<span className="w-14 shrink-0 font-mono text-xs text-[var(--color-muted)]">
{item.code}
</span>
<span className="flex-1 truncate">{item.title}</span>
<span className="text-xs text-[var(--color-muted)]">
{item.fine} · {item.jailTime}M
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setCount(item.id, -1)}
className="rounded bg-[var(--color-border)] p-1 disabled:opacity-40"
disabled={c === 0}
>
<Minus size={12} />
</button>
<span className="w-5 text-center tabular-nums">{c}</span>
<button
onClick={() => setCount(item.id, 1)}
className="rounded bg-[var(--color-border)] p-1"
>
<Plus size={12} />
</button>
</div>
</div>
);
})}
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-wide text-[var(--color-muted)]">Sachverhalt</p>
<RichTextEditor content={narrative} onChange={setNarrative} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--color-muted)]">
Summe: <span className="font-medium text-[var(--color-text)]">{totals.fine}</span> ·{' '}
<span className="font-medium text-[var(--color-text)]">{totals.jail} Monate</span>
</span>
<Button onClick={submit} disabled={!canSubmit}>
{create.isPending ? 'Erstellt…' : 'Akte anlegen'}
</Button>
</div>
</Card>
);
}
@@ -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<Selected | null>(null);
const { data: results } = useSearchPersons(query);
return (
<div className="flex h-full gap-4">
<div className="flex w-72 shrink-0 flex-col gap-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-muted)]" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Beschuldigten suchen…" className="pl-9" />
</div>
<div className="flex flex-col gap-1 overflow-auto">
{results?.map((p) => (
<button
key={p.citizenid}
onClick={() => setSelected({ citizenid: p.citizenid, name: `${p.firstname} ${p.lastname}` })}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors',
selected?.citizenid === p.citizenid ? 'bg-[var(--color-surface-2)]' : 'hover:bg-[var(--color-surface-2)]',
)}
>
<User size={16} className="text-[var(--color-muted)]" />
<span className="flex-1">
{p.firstname} {p.lastname}
<span className="block text-xs text-[var(--color-muted)]">{p.citizenid}</span>
</span>
{p.isWanted && <ShieldAlert size={16} className="text-[var(--color-danger)]" />}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto">
{selected ? (
<SuspectView key={selected.citizenid} selected={selected} />
) : (
<EmptyState title="Strafakten" hint="Beschuldigten links auswählen." />
)}
</div>
</div>
);
}
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 (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">{name}</h2>
<CaseCreator citizenid={citizenid} onCreated={() => undefined} />
{/* Haftbefehl ausstellen */}
<Card className="flex flex-col gap-3">
<p className="text-sm font-medium">Haftbefehl ausstellen</p>
<div className="flex gap-2">
<Input value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Grund…" />
<Button
variant="danger"
disabled={reason.trim().length === 0 || createWarrant.isPending}
onClick={() =>
createWarrant.mutate({ citizenid, reason: reason.trim(), caseId: null, expiresAt: null }, { onSuccess: () => setReason('') })
}
>
Ausstellen
</Button>
</div>
{warrants && warrants.length > 0 && (
<div className="flex flex-col gap-1">
{warrants.map((w) => (
<div key={w.id} className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-2)] px-3 py-2 text-sm">
<ShieldAlert size={16} className={w.status === 'active' ? 'text-[var(--color-danger)]' : 'text-[var(--color-muted)]'} />
<span className="flex-1">{w.reason}</span>
<span className="text-xs capitalize text-[var(--color-muted)]">{w.status}</span>
{w.status === 'active' && (
<button
onClick={() => updateWarrant.mutate({ id: w.id, input: { status: 'revoked' } })}
className="text-xs text-[var(--color-primary)] hover:underline"
>
aufheben
</button>
)}
</div>
))}
</div>
)}
</Card>
{/* Bestehende Akten */}
<Card className="flex flex-col gap-2">
<p className="text-sm font-medium">Strafakten ({cases?.length ?? 0})</p>
{cases?.length === 0 && <p className="text-sm text-[var(--color-muted)]">Keine Akten.</p>}
{cases?.map((c) => (
<div key={c.id} className="rounded-lg bg-[var(--color-surface-2)] px-3 py-2">
<div className="flex items-center gap-2">
<FileText size={16} className="text-[var(--color-muted)]" />
<span className="flex-1 text-sm font-medium">{c.title}</span>
<span className="text-xs capitalize text-[var(--color-muted)]">{c.status}</span>
</div>
<div className="mt-1 flex flex-wrap gap-1.5 pl-6">
{c.charges.map((ch, i) => (
<span key={i} className="rounded bg-[var(--color-border)] px-2 py-0.5 text-xs">
{ch.count}× {ch.code}
</span>
))}
</div>
<p className="mt-1 pl-6 text-xs text-[var(--color-muted)]">
Gesamt: {c.totalFine} · {c.totalJailTime} Monate
</p>
{c.narrative.replace(/<[^>]*>/g, '').trim() && (
<div className="mt-2 pl-6">
<RichTextEditor content={c.narrative} editable={false} />
</div>
)}
</div>
))}
</Card>
</div>
);
}
+70
View File
@@ -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<ChargeCatalogItem[]>('/charges/catalog'),
staleTime: 5 * 60_000,
});
}
export function useCases(citizenid: string | null) {
return useQuery({
queryKey: ['cases', citizenid],
queryFn: () => api<Case[]>(`/cases?citizenid=${citizenid}`),
enabled: !!citizenid,
});
}
export function useWarrants(citizenid: string | null) {
return useQuery({
queryKey: ['warrants', citizenid],
queryFn: () => api<Warrant[]>(`/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<Case>('/cases', { method: 'POST', body: JSON.stringify(input) }),
onSuccess: invalidate,
});
}
export function useCreateWarrant(citizenid: string) {
const invalidate = useInvalidatePerson(citizenid);
return useMutation({
mutationFn: (input: CreateWarrantInput) =>
api<Warrant>('/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<Warrant>(`/warrants/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
onSuccess: invalidate,
});
}
@@ -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 (
<div className="mx-auto flex max-w-5xl flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
{/* Uhr */}
<Card className="flex flex-col justify-center">
<p className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
{now.toLocaleDateString('de-DE', { weekday: 'long' })}
</p>
<p className="text-5xl font-semibold tabular-nums">
{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
<span className="ml-1 text-2xl text-[var(--color-muted)]">
{now.toLocaleTimeString('de-DE', { second: '2-digit' })}
</span>
</p>
<p className="text-sm text-[var(--color-muted)]">
{now.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}
</p>
</Card>
{/* Termine (Platzhalter bis Kalender-Modul) */}
<Card className="flex flex-col gap-2">
<p className="flex items-center gap-2 text-xs uppercase tracking-wide text-[var(--color-muted)]">
<CalendarDays size={14} /> Heutige Termine
</p>
<div className="flex flex-1 items-center justify-center text-sm text-[var(--color-muted)]">
Heute keine Termine geplant.
</div>
</Card>
</div>
{/* Schwarzes Brett */}
<div className="flex items-center justify-between px-1">
<p className="text-sm font-medium">Schwarzes Brett</p>
{canManage && (
<Button onClick={() => setComposing((v) => !v)}>
<span className="flex items-center gap-1.5">
<Plus size={14} /> Neuer Beitrag
</span>
</Button>
)}
</div>
{composing && <ComposeForm onDone={() => setComposing(false)} />}
<div className="flex flex-col gap-2">
{posts?.length === 0 && (
<p className="px-1 text-sm text-[var(--color-muted)]">Noch keine Beiträge.</p>
)}
{posts?.map((p) => (
<PostCard key={p.id} post={p} canManage={canManage} />
))}
</div>
</div>
);
}
function ComposeForm({ onDone }: { onDone: () => void }) {
const create = useCreateAnnouncement();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [important, setImportant] = useState(false);
return (
<Card className="flex flex-col gap-2">
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Titel" />
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
placeholder="Text…"
className="w-full resize-none rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-sm outline-none focus:border-[var(--color-primary)]"
/>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<input type="checkbox" checked={important} onChange={(e) => setImportant(e.target.checked)} />
Als wichtig markieren
</label>
<div className="flex gap-2">
<Button variant="ghost" onClick={onDone}>
Abbrechen
</Button>
<Button
disabled={!title.trim() || create.isPending}
onClick={() =>
create.mutate(
{ title: title.trim(), body, important, pinned: false },
{ onSuccess: onDone },
)
}
>
{create.isPending ? 'Postet…' : 'Posten'}
</Button>
</div>
</div>
</Card>
);
}
function PostCard({ post, canManage }: { post: Announcement; canManage: boolean }) {
const update = useUpdateAnnouncement();
const del = useDeleteAnnouncement();
return (
<Card className="flex items-start gap-3">
<div className="mt-0.5">
{post.important ? (
<AlertCircle size={18} className="text-[var(--color-danger)]" />
) : (
<span className="block h-2 w-2 rounded-full bg-[var(--color-primary)]" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
{post.pinned && <Pin size={13} className="text-[var(--color-warning)]" />}
<p className="font-medium">{post.title}</p>
</div>
{post.body && <p className="mt-1 whitespace-pre-wrap text-sm text-[var(--color-muted)]">{post.body}</p>}
<p className="mt-1 text-xs text-[var(--color-muted)]">
{post.authorName ?? 'System'} ·{' '}
{new Date(post.createdAt).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
{canManage && (
<div className="flex gap-1">
<button
title="Anpinnen"
onClick={() => update.mutate({ id: post.id, input: { pinned: !post.pinned } })}
className="rounded p-1.5 text-[var(--color-muted)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-warning)]"
>
<Pin size={15} />
</button>
<button
title="Löschen"
onClick={() => del.mutate(post.id)}
className="rounded p-1.5 text-[var(--color-muted)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-danger)]"
>
<Trash2 size={15} />
</button>
</div>
)}
</Card>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
Announcement,
CreateAnnouncementInput,
UpdateAnnouncementInput,
} from '@d4rk-tablet/shared';
import { api } from '../../core/api-client';
const KEY = ['board'];
export function useAnnouncements() {
return useQuery({ queryKey: KEY, queryFn: () => api<Announcement[]>('/board') });
}
export function useCreateAnnouncement() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateAnnouncementInput) =>
api<Announcement>('/board', { method: 'POST', body: JSON.stringify(input) }),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
}
export function useUpdateAnnouncement() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdateAnnouncementInput }) =>
api<Announcement>(`/board/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
}
export function useDeleteAnnouncement() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api<{ ok: boolean }>(`/board/${id}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
}
@@ -0,0 +1,127 @@
import { useState } from 'react';
import { Radio, Plus, MapPin, UserPlus } from 'lucide-react';
import { DEPARTMENTS, type Department, type DispatchPriority } from '@d4rk-tablet/shared';
import { useAuth } from '../../core/auth';
import { useDispatchCalls, useOfficers, useCreateCall, useAssignCall, useDispatchRealtime } from './api';
import { LiveMap } from './LiveMap';
import { Card } from '../../ui/Card';
import { Input } from '../../ui/Input';
import { Button } from '../../ui/Button';
import { cn } from '../../ui/cn';
const PRIORITY_STYLE: Record<DispatchPriority, string> = {
low: 'bg-[var(--color-success)]/15 text-[var(--color-success)]',
medium: 'bg-[var(--color-warning)]/15 text-[var(--color-warning)]',
high: 'bg-[var(--color-danger)]/15 text-[var(--color-danger)]',
};
export function DispatchApp() {
useDispatchRealtime();
const user = useAuth((s) => s.user);
const dept: Department = DEPARTMENTS.find((d) => user?.roles.includes(d)) ?? 'police';
const { data: calls } = useDispatchCalls();
const { data: officers } = useOfficers();
const createCall = useCreateCall();
const assignCall = useAssignCall();
const [code, setCode] = useState('');
const [title, setTitle] = useState('');
const [location, setLocation] = useState('');
const [priority, setPriority] = useState<DispatchPriority>('medium');
const canCreate = code.trim() && title.trim() && !createCall.isPending;
function submit() {
createCall.mutate(
{ code: code.trim(), title: title.trim(), description: '', department: dept, priority, location: location.trim() || null, coords: null, callerCitizenid: null },
{
onSuccess: () => {
setCode('');
setTitle('');
setLocation('');
},
},
);
}
return (
<div className="flex h-full gap-4">
{/* Einsätze */}
<div className="flex w-96 shrink-0 flex-col gap-3 overflow-auto">
<Card className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Plus size={16} className="text-[var(--color-primary)]" /> Neuer Einsatz
</div>
<div className="flex gap-2">
<Input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Code (10-50)" className="w-28" />
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Titel" />
</div>
<Input value={location} onChange={(e) => setLocation(e.target.value)} placeholder="Ort" />
<div className="flex items-center gap-2">
<select
value={priority}
onChange={(e) => setPriority(e.target.value as DispatchPriority)}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-2 text-sm outline-none"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select>
<Button onClick={submit} disabled={!canCreate} className="flex-1">
{createCall.isPending ? 'Sendet…' : 'Einsatz auslösen'}
</Button>
</div>
</Card>
<div className="flex items-center gap-2 px-1 text-sm text-[var(--color-muted)]">
<Radio size={16} /> Aktive Einsätze ({calls?.length ?? 0})
</div>
{calls?.length === 0 && <p className="px-1 text-sm text-[var(--color-muted)]">Keine aktiven Einsätze.</p>}
{calls?.map((c) => {
const assignedToMe = user?.citizenid ? c.assignedOfficers.includes(user.citizenid) : false;
return (
<Card key={c.id} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-[var(--color-muted)]">{c.code}</span>
<span className="flex-1 text-sm font-medium">{c.title}</span>
<span className={cn('rounded-full px-2 py-0.5 text-xs capitalize', PRIORITY_STYLE[c.priority])}>
{c.priority}
</span>
</div>
{c.location && (
<p className="flex items-center gap-1 text-xs text-[var(--color-muted)]">
<MapPin size={12} /> {c.location}
</p>
)}
<div className="flex items-center gap-2">
<span className="flex-1 text-xs capitalize text-[var(--color-muted)]">
{c.status} · {c.assignedOfficers.length} zugeteilt
</span>
{!assignedToMe && user?.citizenid && (
<button
onClick={() =>
assignCall.mutate({
id: c.id,
input: { officers: [...c.assignedOfficers, user.citizenid!], status: 'assigned' },
})
}
className="flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline"
>
<UserPlus size={12} /> mir zuweisen
</button>
)}
</div>
</Card>
);
})}
</div>
{/* Live-Map */}
<div className="min-w-0 flex-1">
<LiveMap officers={officers ?? []} calls={calls ?? []} />
</div>
</div>
);
}
@@ -0,0 +1,128 @@
import { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { DispatchCall, Officer } from '@d4rk-tablet/shared';
import { config } from '../../core/config';
import { gameToLngLat, MAP_CENTER } from './mapProjection';
const PRIORITY_COLOR: Record<string, string> = {
low: '#30a46c',
medium: '#f5a524',
high: '#e5484d',
};
const escapeHtml = (s: string) =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
function officerEl(o: Officer): HTMLElement {
const el = document.createElement('div');
el.className = 'mdt-marker';
el.innerHTML = `
<span class="mdt-dot" style="background:#2f6df6"></span>
<span class="mdt-lbl">${escapeHtml(o.callsign ?? o.name)}</span>`;
return el;
}
function callEl(c: DispatchCall): HTMLElement {
const color = PRIORITY_COLOR[c.priority] ?? '#f5a524';
const el = document.createElement('div');
el.className = 'mdt-marker';
el.innerHTML = `
<span class="mdt-pulse" style="background:${color}33"></span>
<span class="mdt-dot" style="background:${color}"></span>
<span class="mdt-lbl">${escapeHtml(c.code)}</span>`;
return el;
}
export function LiveMap({ officers, calls }: { officers: Officer[]; calls: DispatchCall[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const officerMarkers = useRef(new Map<string, maplibregl.Marker>());
const callMarkers = useRef(new Map<number, maplibregl.Marker>());
// Map einmalig initialisieren
useEffect(() => {
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
center: MAP_CENTER,
zoom: 2.2,
minZoom: 1,
maxZoom: 6,
renderWorldCopies: false,
attributionControl: false,
style: {
version: 8,
sources: {
gta: {
type: 'raster',
tiles: [`${config.mapTilesUrl}/${config.mapStyle}/{z}/{x}/{y}.jpg`],
tileSize: 256,
minzoom: 0,
maxzoom: 5,
},
},
layers: [{ id: 'gta', type: 'raster', source: 'gta' }],
},
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
officerMarkers.current.clear();
callMarkers.current.clear();
};
}, []);
// Officer-Marker synchronisieren
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const seen = new Set<string>();
for (const o of officers) {
if (!o.coords) continue;
seen.add(o.citizenid);
const ll = gameToLngLat(o.coords.x, o.coords.y);
const existing = officerMarkers.current.get(o.citizenid);
if (existing) existing.setLngLat(ll);
else officerMarkers.current.set(o.citizenid, new maplibregl.Marker({ element: officerEl(o) }).setLngLat(ll).addTo(map));
}
for (const [id, m] of officerMarkers.current) {
if (!seen.has(id)) {
m.remove();
officerMarkers.current.delete(id);
}
}
}, [officers]);
// Einsatz-Marker synchronisieren
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const seen = new Set<number>();
for (const c of calls) {
if (!c.coords) continue;
seen.add(c.id);
const ll = gameToLngLat(c.coords.x, c.coords.y);
const existing = callMarkers.current.get(c.id);
if (existing) existing.setLngLat(ll);
else callMarkers.current.set(c.id, new maplibregl.Marker({ element: callEl(c) }).setLngLat(ll).addTo(map));
}
for (const [id, m] of callMarkers.current) {
if (!seen.has(id)) {
m.remove();
callMarkers.current.delete(id);
}
}
}, [calls]);
return (
<div className="relative h-full min-h-80 overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
<div ref={containerRef} className="h-full w-full" />
<div className="pointer-events-none absolute bottom-2 right-3 z-10 text-xs text-[var(--color-muted)]">
{officers.filter((o) => o.coords).length} Officer · {calls.filter((c) => c.coords).length} Einsätze
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
SOCKET_EVENTS,
type DispatchCall,
type Officer,
type CreateDispatchCallInput,
type AssignDispatchInput,
} from '@d4rk-tablet/shared';
import { api } from '../../core/api-client';
import { getSocket, connectSocket } from '../../core/socket';
const CALLS_KEY = ['dispatch', 'calls'];
const OFFICERS_KEY = ['dispatch', 'officers'];
export function useDispatchCalls() {
return useQuery({ queryKey: CALLS_KEY, queryFn: () => api<DispatchCall[]>('/dispatch/calls') });
}
export function useOfficers() {
return useQuery({ queryKey: OFFICERS_KEY, queryFn: () => api<Officer[]>('/dispatch/officers') });
}
export function useCreateCall() {
return useMutation({
mutationFn: (input: CreateDispatchCallInput) =>
api<DispatchCall>('/dispatch/calls', { method: 'POST', body: JSON.stringify(input) }),
// Kein manuelles Update nötig — das dispatch.created Socket-Event pflegt den Cache
});
}
export function useAssignCall() {
return useMutation({
mutationFn: ({ id, input }: { id: number; input: AssignDispatchInput }) =>
api<DispatchCall>(`/dispatch/calls/${id}/assign`, { method: 'PATCH', body: JSON.stringify(input) }),
});
}
/** Verbindet den Socket und spiegelt Realtime-Events in den Query-Cache. */
export function useDispatchRealtime(): void {
const qc = useQueryClient();
useEffect(() => {
connectSocket();
const socket = getSocket();
const upsertCall = (call: DispatchCall) =>
qc.setQueryData<DispatchCall[]>(CALLS_KEY, (prev = []) => {
const rest = prev.filter((c) => c.id !== call.id);
// geschlossene Einsätze aus der Live-Liste entfernen
return call.status === 'closed' ? rest : [call, ...rest];
});
const onSnapshot = (officers: Officer[]) => qc.setQueryData(OFFICERS_KEY, officers);
const onMoved = (u: { citizenid: string; coords: Officer['coords'] }) =>
qc.setQueryData<Officer[]>(OFFICERS_KEY, (prev = []) =>
prev.map((o) => (o.citizenid === u.citizenid ? { ...o, coords: u.coords } : o)),
);
// Dienststatus-Änderung: frische Liste holen (add/remove korrekt)
const onDuty = () => void qc.invalidateQueries({ queryKey: OFFICERS_KEY });
socket.on(SOCKET_EVENTS.DISPATCH_CREATED, upsertCall);
socket.on(SOCKET_EVENTS.DISPATCH_UPDATED, upsertCall);
socket.on(SOCKET_EVENTS.OFFICER_SNAPSHOT, onSnapshot);
socket.on(SOCKET_EVENTS.OFFICER_MOVED, onMoved);
socket.on(SOCKET_EVENTS.OFFICER_DUTY, onDuty);
return () => {
socket.off(SOCKET_EVENTS.DISPATCH_CREATED, upsertCall);
socket.off(SOCKET_EVENTS.DISPATCH_UPDATED, upsertCall);
socket.off(SOCKET_EVENTS.OFFICER_SNAPSHOT, onSnapshot);
socket.off(SOCKET_EVENTS.OFFICER_MOVED, onMoved);
socket.off(SOCKET_EVENTS.OFFICER_DUTY, onDuty);
};
}, [qc]);
}
@@ -0,0 +1,25 @@
/**
* Projektion GTA-Weltkoordinaten → MapLibre lng/lat.
*
* Abgeleitet aus dem alten Leaflet-Map-Projekt:
* - Tile-Bounds in Spielkoordinaten: ±4096
* - Die Tiles füllen die komplette XYZ-Pyramide (z5 = 32×32), d.h. den ganzen
* Web-Mercator-Raum → wir mappen Spielkoords über Mercator, damit Marker exakt
* auf den Kacheln sitzen. Das besiedelte Zentrum (Spiel-Y ≈ 0) liegt bei lat 0
* (verzerrungsarm); nur die Ozean-Ränder werden mercator-typisch gestreckt.
*/
const MIN = -4096;
const MAX = 4096;
const RANGE = MAX - MIN;
export function gameToLngLat(x: number, y: number): [number, number] {
const nx = (x - MIN) / RANGE; // 0..1
const ny = (y - MIN) / RANGE; // 0..1 (Spiel-Norden = MAX)
const lng = nx * 360 - 180;
// Mercator: worldY 0 = Norden. Spiel-Norden (ny=1) → worldY 0 → lat max
const lat = (Math.atan(Math.sinh(Math.PI * (2 * ny - 1))) * 180) / Math.PI;
return [lng, lat];
}
/** Ungefährer Kartenmittelpunkt (Los Santos City ≈ Spielkoords 0,0). */
export const MAP_CENTER: [number, number] = gameToLngLat(0, 0);
@@ -0,0 +1,282 @@
import { useEffect, useState } from 'react';
import { FolderPlus, FilePlus, Folder, FileText, Pin, Trash2, Pencil } from 'lucide-react';
import type { MdtDocument, Access } from '@d4rk-tablet/shared';
import {
useFolders,
useDocuments,
useDocument,
useCreateFolder,
useCreateDocument,
useUpdateDocument,
useDeleteDocument,
} from './api';
import { SharePanel, AccessBadge } from './SharePanel';
import { useCan } from '../../core/permissions';
import { RichTextEditor } from '../../ui/RichTextEditor';
import { Card, EmptyState } from '../../ui/Card';
import { Input } from '../../ui/Input';
import { Button } from '../../ui/Button';
import { cn } from '../../ui/cn';
type Mode = { kind: 'view'; id: number } | { kind: 'edit'; id: number } | { kind: 'new' } | { kind: 'empty' };
export function DocumentsApp() {
const [folder, setFolder] = useState<number | null>(null);
const [mode, setMode] = useState<Mode>({ kind: 'empty' });
const [folderForm, setFolderForm] = useState(false);
const canManage = useCan('mdt.documents.manage');
const { data: folders } = useFolders();
const { data: docs } = useDocuments(folder);
return (
<div className="flex h-full gap-4">
{/* Ordner + Dokumentliste */}
<div className="flex w-80 shrink-0 flex-col gap-3 overflow-auto">
{canManage && (
<div className="flex gap-2">
<Button className="flex-1" onClick={() => setMode({ kind: 'new' })}>
<span className="flex items-center justify-center gap-1.5">
<FilePlus size={14} /> Dokument
</span>
</Button>
<Button variant="ghost" onClick={() => setFolderForm((v) => !v)}>
<FolderPlus size={16} />
</Button>
</div>
)}
{folderForm && <FolderCreateForm onDone={() => setFolderForm(false)} />}
{/* Ordner */}
<div className="flex flex-col gap-0.5">
<FolderRow label="Alle Dokumente" active={folder === null} onClick={() => setFolder(null)} />
{folders?.map((f) => (
<FolderRow
key={f.id}
label={f.name}
count={f.docCount}
access={{ public: f.public, acl: f.acl }}
active={folder === f.id}
onClick={() => setFolder(f.id)}
/>
))}
</div>
<div className="border-t border-[var(--color-border)] pt-2">
<p className="px-2 pb-1 text-xs uppercase tracking-wide text-[var(--color-muted)]">Dokumente</p>
<div className="flex flex-col gap-0.5">
{docs?.length === 0 && <p className="px-2 py-2 text-sm text-[var(--color-muted)]">Keine Dokumente.</p>}
{docs?.map((d) => (
<button
key={d.id}
onClick={() => setMode({ kind: 'view', id: d.id })}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors',
(mode.kind === 'view' || mode.kind === 'edit') && mode.id === d.id
? 'bg-[var(--color-surface-2)]'
: 'hover:bg-[var(--color-surface-2)]',
)}
>
<FileText size={15} className="shrink-0 text-[var(--color-muted)]" />
<span className="min-w-0 flex-1 truncate">
{d.title}
<span className="block font-mono text-xs text-[var(--color-muted)]">{d.reference}</span>
</span>
<AccessBadge access={{ public: d.public, acl: d.acl }} />
{d.pinned && <Pin size={12} className="text-[var(--color-warning)]" />}
</button>
))}
</div>
</div>
</div>
{/* Viewer / Editor */}
<div className="min-w-0 flex-1 overflow-auto">
{mode.kind === 'empty' && <EmptyState title="Dokumente" hint="Links ein Dokument wählen oder neu anlegen." />}
{mode.kind === 'view' && (
<DocViewer
id={mode.id}
canManage={canManage}
onEdit={() => setMode({ kind: 'edit', id: mode.id })}
onDeleted={() => setMode({ kind: 'empty' })}
/>
)}
{(mode.kind === 'edit' || mode.kind === 'new') && (
<DocEditor
key={mode.kind === 'edit' ? mode.id : 'new'}
docId={mode.kind === 'edit' ? mode.id : null}
folderId={folder}
onSaved={(id) => setMode({ kind: 'view', id })}
onCancel={() => setMode(mode.kind === 'edit' ? { kind: 'view', id: mode.id } : { kind: 'empty' })}
/>
)}
</div>
</div>
);
}
function FolderRow({
label,
count,
access,
active,
onClick,
}: {
label: string;
count?: number;
access?: Access;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors',
active ? 'bg-[var(--color-surface-2)]' : 'hover:bg-[var(--color-surface-2)]',
)}
>
<Folder size={15} className="shrink-0 text-[var(--color-warning)]" />
<span className="min-w-0 flex-1 truncate">{label}</span>
{access && (access.public || access.acl.length > 0) && <AccessBadge access={access} />}
{count != null && <span className="text-xs text-[var(--color-muted)]">{count}</span>}
</button>
);
}
function FolderCreateForm({ onDone }: { onDone: () => void }) {
const createFolder = useCreateFolder();
const [name, setName] = useState('');
const [access, setAccess] = useState<Access>({ public: false, acl: [] });
return (
<Card className="flex flex-col gap-2">
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Ordnername" />
<SharePanel value={access} onChange={setAccess} />
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onDone}>
Abbrechen
</Button>
<Button
disabled={!name.trim() || createFolder.isPending}
onClick={() =>
createFolder.mutate({ name: name.trim(), public: access.public, acl: access.acl }, { onSuccess: onDone })
}
>
Ordner anlegen
</Button>
</div>
</Card>
);
}
function DocViewer({
id,
canManage,
onEdit,
onDeleted,
}: {
id: number;
canManage: boolean;
onEdit: () => void;
onDeleted: () => void;
}) {
const { data: doc, isLoading } = useDocument(id);
const update = useUpdateDocument();
const del = useDeleteDocument();
if (isLoading || !doc) return <p className="text-sm text-[var(--color-muted)]">Lädt</p>;
return (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<p className="font-mono text-xs text-[var(--color-muted)]">{doc.reference}</p>
<AccessBadge access={{ public: doc.public, acl: doc.acl }} />
</div>
<h2 className="text-xl font-semibold">{doc.title}</h2>
<p className="text-xs text-[var(--color-muted)]">
{doc.authorName ?? 'System'} · {new Date(doc.updatedAt).toLocaleString('de-DE')}
</p>
</div>
{canManage && doc.canWrite && (
<div className="flex gap-1">
<Button variant="ghost" onClick={() => update.mutate({ id, input: { pinned: !doc.pinned } })}>
<Pin size={15} className={doc.pinned ? 'text-[var(--color-warning)]' : ''} />
</Button>
<Button variant="ghost" onClick={onEdit}>
<span className="flex items-center gap-1.5">
<Pencil size={14} /> Bearbeiten
</span>
</Button>
<Button variant="danger" onClick={() => del.mutate(id, { onSuccess: onDeleted })}>
<Trash2 size={15} />
</Button>
</div>
)}
</div>
<RichTextEditor content={doc.content} editable={false} />
</div>
);
}
function DocEditor({
docId,
folderId,
onSaved,
onCancel,
}: {
docId: number | null;
folderId: number | null;
onSaved: (id: number) => void;
onCancel: () => void;
}) {
const { data: existing } = useDocument(docId);
const create = useCreateDocument();
const update = useUpdateDocument();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [access, setAccess] = useState<Access>({ public: false, acl: [] });
useEffect(() => {
if (existing) {
setTitle(existing.title);
setContent(existing.content);
setAccess({ public: existing.public, acl: existing.acl });
}
}, [existing]);
const saving = create.isPending || update.isPending;
function save() {
if (docId != null) {
update.mutate(
{ id: docId, input: { title, content, public: access.public, acl: access.acl } },
{ onSuccess: (d: MdtDocument) => onSaved(d.id) },
);
} else {
create.mutate(
{ title, content, folderId, public: access.public, acl: access.acl },
{ onSuccess: (d: MdtDocument) => onSaved(d.id) },
);
}
}
return (
<Card className="flex flex-col gap-3">
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Titel des Dokuments" />
<SharePanel value={access} onChange={setAccess} />
<RichTextEditor content={content} editable onChange={setContent} />
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>
Abbrechen
</Button>
<Button disabled={!title.trim() || saving} onClick={save}>
{saving ? 'Speichert…' : 'Speichern'}
</Button>
</div>
</Card>
);
}
@@ -0,0 +1,167 @@
import { useState } from 'react';
import { Globe, Lock, Building2, UsersRound, User, X, Search, Eye, Pencil } from 'lucide-react';
import type { Access, AclEntry, AclSubjectType } from '@d4rk-tablet/shared';
import { useAuthorities, useGroups } from '../admin/api';
import { useSearchPersons } from '../persons/api';
import { Input } from '../../ui/Input';
import { cn } from '../../ui/cn';
const TYPE_ICON: Record<AclSubjectType, typeof Building2> = {
authority: Building2,
group: UsersRound,
person: User,
};
/** Kompaktes Badge für Listen: öffentlich vs. eingeschränkt (+ Anzahl Freigaben). */
export function AccessBadge({ access }: { access: Access }) {
if (access.public) {
return (
<span className="flex items-center gap-1 rounded-full bg-[var(--color-success)]/15 px-2 py-0.5 text-[10px] font-medium text-[var(--color-success)]">
<Globe size={11} /> Öffentlich
</span>
);
}
return (
<span className="flex items-center gap-1 rounded-full bg-[var(--color-surface-2)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-muted)]">
<Lock size={11} /> {access.acl.length}
</span>
);
}
export function SharePanel({
value,
onChange,
visibilityOnly = false,
publicLabel,
}: {
value: Access;
onChange: (a: Access) => void;
/** Nur Sichtbarkeit steuern (kein Lesen/Schreiben-Umschalter) — z. B. für Bürgerakten. */
visibilityOnly?: boolean;
publicLabel?: string;
}) {
const [tab, setTab] = useState<AclSubjectType>('authority');
const [query, setQuery] = useState('');
const { data: authorities } = useAuthorities();
const { data: groups } = useGroups();
const { data: persons } = useSearchPersons(query);
const has = (type: AclSubjectType, id: string) => value.acl.some((e) => e.type === type && e.id === id);
const add = (entry: AclEntry) => {
if (!has(entry.type, entry.id)) onChange({ ...value, acl: [...value.acl, entry] });
};
const remove = (type: AclSubjectType, id: string) =>
onChange({ ...value, acl: value.acl.filter((e) => !(e.type === type && e.id === id)) });
const setLevel = (type: AclSubjectType, id: string, level: 'read' | 'write') =>
onChange({ ...value, acl: value.acl.map((e) => (e.type === type && e.id === id ? { ...e, level } : e)) });
return (
<div className="flex flex-col gap-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
{/* Öffentlich */}
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={value.public}
onChange={(e) => onChange({ ...value, public: e.target.checked })}
/>
<Globe size={15} className="text-[var(--color-muted)]" />
{publicLabel ?? (
<>
Öffentlich lesbar <span className="text-xs text-[var(--color-muted)]">(alle mit Dokumente-Recht)</span>
</>
)}
</label>
{/* Aktuelle Freigaben */}
<div className="flex flex-col gap-1">
{value.acl.length === 0 && (
<p className="text-xs text-[var(--color-muted)]">Keine gezielten Freigaben.</p>
)}
{value.acl.map((e) => {
const Icon = TYPE_ICON[e.type];
return (
<div key={`${e.type}:${e.id}`} className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-2)] px-2 py-1.5 text-sm">
<Icon size={15} className="shrink-0 text-[var(--color-muted)]" />
<span className="min-w-0 flex-1 truncate">{e.label}</span>
{!visibilityOnly && (
<div className="flex overflow-hidden rounded-md border border-[var(--color-border)]">
<button
onClick={() => setLevel(e.type, e.id, 'read')}
className={cn('flex items-center gap-1 px-2 py-0.5 text-xs', e.level === 'read' ? 'bg-[var(--color-primary)] text-white' : 'text-[var(--color-muted)]')}
>
<Eye size={11} /> Lesen
</button>
<button
onClick={() => setLevel(e.type, e.id, 'write')}
className={cn('flex items-center gap-1 px-2 py-0.5 text-xs', e.level === 'write' ? 'bg-[var(--color-primary)] text-white' : 'text-[var(--color-muted)]')}
>
<Pencil size={11} /> Schreiben
</button>
</div>
)}
<button onClick={() => remove(e.type, e.id)} className="rounded p-0.5 text-[var(--color-muted)] hover:text-[var(--color-danger)]">
<X size={14} />
</button>
</div>
);
})}
</div>
{/* Hinzufügen */}
<div className="rounded-lg border border-[var(--color-border)] p-2">
<div className="mb-2 flex gap-1">
{(['authority', 'group', 'person'] as AclSubjectType[]).map((t) => {
const Icon = TYPE_ICON[t];
const label = t === 'authority' ? 'Behörden' : t === 'group' ? 'Gruppen' : 'Personen';
return (
<button
key={t}
onClick={() => setTab(t)}
className={cn('flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs', tab === t ? 'bg-[var(--color-primary)] text-white' : 'bg-[var(--color-surface-2)] text-[var(--color-muted)]')}
>
<Icon size={13} /> {label}
</button>
);
})}
</div>
<div className="flex max-h-40 flex-col gap-0.5 overflow-auto">
{tab === 'authority' &&
authorities?.filter((a) => !has('authority', a.key)).map((a) => (
<AddRow key={a.key} color={a.color} label={a.name} onClick={() => add({ type: 'authority', id: a.key, label: a.name, level: 'read' })} />
))}
{tab === 'group' &&
groups?.filter((g) => !has('group', String(g.id))).map((g) => (
<AddRow key={g.id} color={g.color} label={g.name} onClick={() => add({ type: 'group', id: String(g.id), label: g.name, level: 'read' })} />
))}
{tab === 'person' && (
<>
<div className="relative mb-1">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-muted)]" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Person suchen…" className="pl-8" />
</div>
{persons?.filter((p) => !has('person', p.citizenid)).map((p) => (
<AddRow
key={p.citizenid}
label={`${p.firstname} ${p.lastname}`}
sub={p.citizenid}
onClick={() => add({ type: 'person', id: p.citizenid, label: `${p.firstname} ${p.lastname}`, level: 'read' })}
/>
))}
</>
)}
</div>
</div>
</div>
);
}
function AddRow({ color, label, sub, onClick }: { color?: string; label: string; sub?: string; onClick: () => void }) {
return (
<button onClick={onClick} className="flex items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-[var(--color-surface-2)]">
{color ? <span className="h-2.5 w-2.5 rounded-full" style={{ background: color }} /> : <span className="w-2.5" />}
<span className="flex-1 truncate">{label}</span>
{sub && <span className="font-mono text-xs text-[var(--color-muted)]">{sub}</span>}
</button>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
DocFolder,
DocumentSummary,
MdtDocument,
CreateDocumentInput,
UpdateDocumentInput,
CreateFolderInput,
} from '@d4rk-tablet/shared';
import { api } from '../../core/api-client';
export function useFolders() {
return useQuery({ queryKey: ['documents', 'folders'], queryFn: () => api<DocFolder[]>('/documents/folders') });
}
export function useDocuments(folderId: number | null) {
return useQuery({
queryKey: ['documents', 'list', folderId],
queryFn: () =>
api<DocumentSummary[]>(`/documents${folderId != null ? `?folderId=${folderId}` : ''}`),
});
}
export function useDocument(id: number | null) {
return useQuery({
queryKey: ['documents', 'detail', id],
queryFn: () => api<MdtDocument>(`/documents/${id}`),
enabled: id != null,
});
}
function useInvalidate() {
const qc = useQueryClient();
return () => qc.invalidateQueries({ queryKey: ['documents'] });
}
export function useCreateFolder() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (input: CreateFolderInput) =>
api<DocFolder>('/documents/folders', { method: 'POST', body: JSON.stringify(input) }),
onSuccess: invalidate,
});
}
export function useCreateDocument() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (input: CreateDocumentInput) =>
api<MdtDocument>('/documents', { method: 'POST', body: JSON.stringify(input) }),
onSuccess: invalidate,
});
}
export function useUpdateDocument() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdateDocumentInput }) =>
api<MdtDocument>(`/documents/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
onSuccess: invalidate,
});
}
export function useDeleteDocument() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: number) => api<{ ok: boolean }>(`/documents/${id}`, { method: 'DELETE' }),
onSuccess: invalidate,
});
}
+218
View File
@@ -0,0 +1,218 @@
import { useMemo, useState } from 'react';
import { Search, Scale, Plus, Pencil, Trash2 } from 'lucide-react';
import type { CreateLawInput, Law } from '@d4rk-tablet/shared';
import { useLaws, useCreateLaw, useUpdateLaw, useDeleteLaw } from './api';
import { useCan } from '../../core/permissions';
import { Card, EmptyState } from '../../ui/Card';
import { Input } from '../../ui/Input';
import { Button } from '../../ui/Button';
import { RichTextEditor } from '../../ui/RichTextEditor';
type Mode = { view: 'list' } | { view: 'create' } | { view: 'edit'; law: Law };
export function LawsApp() {
const { data: laws } = useLaws();
const canManage = useCan('mdt.laws.manage');
const [query, setQuery] = useState('');
const [mode, setMode] = useState<Mode>({ view: 'list' });
const create = useCreateLaw();
const update = useUpdateLaw();
const del = useDeleteLaw();
const grouped = useMemo(() => {
const q = query.trim().toLowerCase();
const filtered = (laws ?? []).filter(
(l) =>
!q ||
l.paragraph.toLowerCase().includes(q) ||
l.title.toLowerCase().includes(q) ||
l.category.toLowerCase().includes(q),
);
const map = new Map<string, Law[]>();
for (const l of filtered) {
const list = map.get(l.category) ?? [];
list.push(l);
map.set(l.category, list);
}
return [...map.entries()];
}, [laws, query]);
if (mode.view === 'create') {
return (
<LawForm
title="Neues Gesetz"
submitLabel="Anlegen"
submitting={create.isPending}
onCancel={() => setMode({ view: 'list' })}
onSubmit={(input) => create.mutate(input, { onSuccess: () => setMode({ view: 'list' }) })}
/>
);
}
if (mode.view === 'edit') {
return (
<LawForm
initial={mode.law}
title={`${mode.law.paragraph} bearbeiten`}
submitLabel="Speichern"
submitting={update.isPending}
onCancel={() => setMode({ view: 'list' })}
onSubmit={(input) =>
update.mutate({ id: mode.law.id, input }, { onSuccess: () => setMode({ view: 'list' }) })
}
/>
);
}
return (
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">Gesetze</h2>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-muted)]" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="§ / Titel / Kategorie…" className="pl-9" />
</div>
{canManage && (
<Button onClick={() => setMode({ view: 'create' })} className="flex shrink-0 items-center gap-1.5">
<Plus size={16} /> Neues Gesetz
</Button>
)}
</div>
</div>
{grouped.length === 0 && <EmptyState title="Keine Gesetze" hint="Noch keine Einträge vorhanden." />}
{grouped.map(([category, items]) => (
<div key={category} className="flex flex-col gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--color-muted)]">
<Scale size={14} /> {category}
</h3>
{items.map((l) => (
<Card key={l.id} className="flex flex-col gap-2">
<div className="flex items-start gap-3">
<span className="font-mono text-sm font-semibold text-[var(--color-primary)]">{l.paragraph}</span>
<span className="flex-1 text-sm font-medium">{l.title}</span>
<div className="flex items-center gap-1.5">
{l.fine != null && l.fine > 0 && (
<span className="rounded-full bg-[var(--color-warning)]/15 px-2 py-0.5 text-xs text-[var(--color-warning)]">
{l.fine}
</span>
)}
{l.jailTime != null && l.jailTime > 0 && (
<span className="rounded-full bg-[var(--color-danger)]/15 px-2 py-0.5 text-xs text-[var(--color-danger)]">
{l.jailTime} Mon.
</span>
)}
{canManage && (
<>
<button
onClick={() => setMode({ view: 'edit', law: l })}
className="rounded p-1 text-[var(--color-muted)] hover:text-[var(--color-text)]"
>
<Pencil size={14} />
</button>
<button
onClick={() => {
if (confirm(`${l.paragraph} ${l.title} löschen?`)) del.mutate(l.id);
}}
className="rounded p-1 text-[var(--color-muted)] hover:text-[var(--color-danger)]"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</div>
{l.description.replace(/<[^>]*>/g, '').trim() && (
<RichTextEditor content={l.description} editable={false} />
)}
</Card>
))}
</div>
))}
</div>
);
}
function LawForm({
initial,
title,
submitLabel,
submitting,
onSubmit,
onCancel,
}: {
initial?: Law;
title: string;
submitLabel: string;
submitting: boolean;
onSubmit: (input: CreateLawInput) => void;
onCancel: () => void;
}) {
const [category, setCategory] = useState(initial?.category ?? '');
const [paragraph, setParagraph] = useState(initial?.paragraph ?? '');
const [lawTitle, setLawTitle] = useState(initial?.title ?? '');
const [fine, setFine] = useState(initial?.fine != null ? String(initial.fine) : '');
const [jailTime, setJailTime] = useState(initial?.jailTime != null ? String(initial.jailTime) : '');
const [description, setDescription] = useState(initial?.description ?? '');
const canSubmit =
category.trim() && paragraph.trim() && lawTitle.trim() && !submitting;
return (
<div className="mx-auto flex max-w-2xl flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{title}</h2>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} disabled={submitting}>
Abbrechen
</Button>
<Button
disabled={!canSubmit}
onClick={() =>
onSubmit({
category: category.trim(),
paragraph: paragraph.trim(),
title: lawTitle.trim(),
description,
fine: fine.trim() ? Number(fine) : null,
jailTime: jailTime.trim() ? Number(jailTime) : null,
sortOrder: initial?.sortOrder ?? 0,
})
}
>
{submitting ? 'Speichert…' : submitLabel}
</Button>
</div>
</div>
<Card className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Kategorie *</span>
<Input value={category} onChange={(e) => setCategory(e.target.value)} placeholder="z. B. Gewaltdelikte" />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Paragraph *</span>
<Input value={paragraph} onChange={(e) => setParagraph(e.target.value)} placeholder="§ 242" />
</label>
<label className="col-span-2 flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Titel *</span>
<Input value={lawTitle} onChange={(e) => setLawTitle(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Bußgeld ()</span>
<Input value={fine} onChange={(e) => setFine(e.target.value)} inputMode="numeric" placeholder="0" />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Haftzeit (Monate)</span>
<Input value={jailTime} onChange={(e) => setJailTime(e.target.value)} inputMode="numeric" placeholder="0" />
</label>
</Card>
<Card className="flex flex-col gap-2">
<p className="text-sm font-medium">Gesetzestext</p>
<RichTextEditor content={description} onChange={setDescription} editable />
</Card>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Law, CreateLawInput, UpdateLawInput } from '@d4rk-tablet/shared';
import { api } from '../../core/api-client';
export function useLaws() {
return useQuery({
queryKey: ['laws'],
queryFn: () => api<Law[]>('/laws'),
});
}
export function useCreateLaw() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateLawInput) => api<Law>('/laws', { method: 'POST', body: JSON.stringify(input) }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['laws'] }),
});
}
export function useUpdateLaw() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdateLawInput }) =>
api<Law>(`/laws/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['laws'] }),
});
}
export function useDeleteLaw() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api<void>(`/laws/${id}`, { method: 'DELETE' }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['laws'] }),
});
}
@@ -0,0 +1,292 @@
import { useState, type ReactNode } from 'react';
import type { Access, CreateCitizenInput, Gender, LegalStatus, Person } from '@d4rk-tablet/shared';
import { Card } from '../../ui/Card';
import { Input } from '../../ui/Input';
import { Button } from '../../ui/Button';
import { cn } from '../../ui/cn';
import { SharePanel } from '../documents/SharePanel';
import { GENDER_OPTIONS, LEGAL_STATUS_OPTIONS, LICENSE_CATALOG } from './labels';
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">{label}</span>
{children}
</label>
);
}
function Select({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-primary)]"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
const listToStr = (a: string[] | undefined) => (a ?? []).join(', ');
const strToList = (s: string) =>
s
.split(',')
.map((x) => x.trim())
.filter(Boolean);
export function CitizenForm({
initial,
title,
submitLabel,
submitting,
error,
onSubmit,
onCancel,
}: {
initial?: Person;
title: string;
submitLabel: string;
submitting: boolean;
error?: string | null;
onSubmit: (input: CreateCitizenInput) => void;
onCancel: () => void;
}) {
const [firstname, setFirstname] = useState(initial?.firstname ?? '');
const [lastname, setLastname] = useState(initial?.lastname ?? '');
const [dob, setDob] = useState(initial?.dob ?? '');
const [gender, setGender] = useState<Gender>(initial?.gender ?? 'unknown');
const [phone, setPhone] = useState(initial?.phone ?? '');
const [nationality, setNationality] = useState(initial?.nationality ?? '');
const [legalStatus, setLegalStatus] = useState<LegalStatus>(initial?.legalStatus ?? 'citizen');
const [address, setAddress] = useState(initial?.address ?? '');
const [occupation, setOccupation] = useState(initial?.occupation ?? '');
const [height, setHeight] = useState(initial?.height ?? '');
const [eyeColor, setEyeColor] = useState(initial?.eyeColor ?? '');
const [hairColor, setHairColor] = useState(initial?.hairColor ?? '');
const [marks, setMarks] = useState(initial?.distinguishingMarks ?? '');
const [aliases, setAliases] = useState(listToStr(initial?.aliases));
const [mugshotUrl, setMugshotUrl] = useState(initial?.mugshotUrl ?? '');
const [flags, setFlags] = useState(listToStr(initial?.flags));
const [notes, setNotes] = useState(initial?.notes ?? '');
const [isWanted, setIsWanted] = useState(initial?.isWanted ?? false);
const [access, setAccess] = useState<Access>({
public: initial?.public ?? true,
acl: initial?.acl ?? [],
});
const [licenses, setLicenses] = useState<Set<string>>(
() => new Set((initial?.licenses ?? []).filter((l) => l.active).map((l) => l.type)),
);
const toggleLicense = (type: string) => {
setLicenses((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
};
const canSubmit = firstname.trim().length > 0 && lastname.trim().length > 0 && !submitting;
function submit() {
onSubmit({
firstname: firstname.trim(),
lastname: lastname.trim(),
dob: dob.trim() || null,
gender,
phone: phone.trim() || null,
nationality: nationality.trim() || null,
legalStatus,
address: address.trim() || null,
occupation: occupation.trim() || null,
height: height.trim() || null,
eyeColor: eyeColor.trim() || null,
hairColor: hairColor.trim() || null,
distinguishingMarks: marks,
aliases: strToList(aliases),
licenses: LICENSE_CATALOG.filter((l) => licenses.has(l.type)).map((l) => ({
type: l.type,
label: l.label,
active: true,
})),
mugshotUrl: mugshotUrl.trim() || null,
flags: strToList(flags),
isWanted,
notes,
public: access.public,
acl: access.acl,
});
}
return (
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{title}</h2>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} disabled={submitting}>
Abbrechen
</Button>
<Button onClick={submit} disabled={!canSubmit}>
{submitting ? 'Speichert…' : submitLabel}
</Button>
</div>
</div>
{error && <p className="text-sm text-[var(--color-danger)]">{error}</p>}
{/* Stammdaten */}
<Card className="flex flex-col gap-3">
<p className="text-sm font-medium">Stammdaten</p>
<div className="grid grid-cols-2 gap-3">
<Field label="Vorname *">
<Input value={firstname} onChange={(e) => setFirstname(e.target.value)} />
</Field>
<Field label="Nachname *">
<Input value={lastname} onChange={(e) => setLastname(e.target.value)} />
</Field>
<Field label="Geburtsdatum">
<Input value={dob} onChange={(e) => setDob(e.target.value)} placeholder="1990-05-14" />
</Field>
<Field label="Geschlecht">
<Select value={gender} onChange={(v) => setGender(v as Gender)} options={GENDER_OPTIONS} />
</Field>
<Field label="Telefon">
<Input value={phone} onChange={(e) => setPhone(e.target.value)} />
</Field>
<Field label="Nationalität">
<Input value={nationality} onChange={(e) => setNationality(e.target.value)} />
</Field>
<Field label="Aufenthaltsstatus">
<Select
value={legalStatus}
onChange={(v) => setLegalStatus(v as LegalStatus)}
options={LEGAL_STATUS_OPTIONS}
/>
</Field>
<Field label="Beruf">
<Input value={occupation} onChange={(e) => setOccupation(e.target.value)} />
</Field>
<div className="col-span-2">
<Field label="Adresse">
<Input value={address} onChange={(e) => setAddress(e.target.value)} />
</Field>
</div>
</div>
</Card>
{/* Erscheinung + Mugshot */}
<Card className="flex flex-col gap-3">
<p className="text-sm font-medium">Erscheinungsbild</p>
<div className="grid grid-cols-3 gap-3">
<Field label="Größe">
<Input value={height} onChange={(e) => setHeight(e.target.value)} placeholder="182 cm" />
</Field>
<Field label="Augenfarbe">
<Input value={eyeColor} onChange={(e) => setEyeColor(e.target.value)} />
</Field>
<Field label="Haarfarbe">
<Input value={hairColor} onChange={(e) => setHairColor(e.target.value)} />
</Field>
</div>
<Field label="Besondere Merkmale">
<textarea
value={marks}
onChange={(e) => setMarks(e.target.value)}
rows={2}
className="w-full resize-none rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-sm outline-none focus:border-[var(--color-primary)]"
/>
</Field>
<Field label="Mugshot-URL (Bild-Link)">
<Input
value={mugshotUrl}
onChange={(e) => setMugshotUrl(e.target.value)}
placeholder="https://…"
/>
</Field>
{mugshotUrl.trim() && (
<img
src={mugshotUrl}
alt="Mugshot-Vorschau"
className="h-32 w-32 rounded-lg object-cover"
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
</Card>
{/* Lizenzen + Aliasse */}
<Card className="flex flex-col gap-3">
<p className="text-sm font-medium">Lizenzen & Kennzeichnungen</p>
<div className="flex flex-wrap gap-2">
{LICENSE_CATALOG.map((l) => {
const on = licenses.has(l.type);
return (
<button
key={l.type}
type="button"
onClick={() => toggleLicense(l.type)}
className={cn(
'rounded-full px-3 py-1 text-xs transition-colors',
on
? 'bg-[var(--color-success)]/15 text-[var(--color-success)]'
: 'bg-[var(--color-surface-2)] text-[var(--color-muted)]',
)}
>
{l.label}
</button>
);
})}
</div>
<Field label="Aliasse (Komma-getrennt)">
<Input value={aliases} onChange={(e) => setAliases(e.target.value)} placeholder="El Fantasma, Slim" />
</Field>
<Field label="Flags (Komma-getrennt)">
<Input value={flags} onChange={(e) => setFlags(e.target.value)} placeholder="bewaffnet, gewalttätig" />
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isWanted} onChange={(e) => setIsWanted(e.target.checked)} />
Zur Fahndung ausgeschrieben
</label>
</Card>
{/* Freigabe / Sichtbarkeit */}
<Card className="flex flex-col gap-2">
<p className="text-sm font-medium">Freigabe</p>
<p className="text-xs text-[var(--color-muted)]">
Wer diese Akte sehen darf. Öffentlich = alle Mitarbeiter mit Bürgerakten-Recht. Sonst nur
ausgewählte Behörden/Gruppen/Personen (+ Admins).
</p>
<SharePanel
value={access}
onChange={setAccess}
visibilityOnly
publicLabel="Für alle Behörden sichtbar"
/>
</Card>
{/* Notizen */}
<Card>
<Field label="Interne Notizen">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
className="w-full resize-none rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-sm outline-none focus:border-[var(--color-primary)]"
/>
</Field>
</Card>
</div>
);
}
@@ -0,0 +1,375 @@
import { useState } from 'react';
import { ShieldAlert, ShieldCheck, Briefcase, FileText, Gavel, HeartPulse, Car, User, Pencil, Trash2 } from 'lucide-react';
import type { Person } from '@d4rk-tablet/shared';
import { usePerson, useUpdateCitizen, useDeleteCitizen } from './api';
import { useCases } from '../charges/api';
import { useTreatments } from '../treatment/api';
import { useCan } from '../../core/permissions';
import { Card } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { cn } from '../../ui/cn';
import { CitizenForm } from './CitizenForm';
import { AccessBadge } from '../documents/SharePanel';
import { GENDER, LEGAL_STATUS, PLATE_STATUS } from './labels';
const CASE_STATUS: Record<string, string> = { open: 'Offen', closed: 'Geschlossen', dismissed: 'Eingestellt' };
export function PersonDetail({
citizenid,
onDeleted,
}: {
citizenid: string;
onDeleted: () => void;
}) {
const { data: person, isLoading } = usePerson(citizenid);
const update = useUpdateCitizen(citizenid);
const del = useDeleteCitizen();
const canEdit = useCan('mdt.persons.edit');
const canDelete = useCan('mdt.persons.delete');
const [editing, setEditing] = useState(false);
if (isLoading) return <p className="text-sm text-[var(--color-muted)]">Lädt</p>;
if (!person) return <p className="text-sm text-[var(--color-muted)]">Nicht gefunden.</p>;
if (editing) {
return (
<CitizenForm
initial={person}
title={`${person.firstname} ${person.lastname} bearbeiten`}
submitLabel="Speichern"
submitting={update.isPending}
error={update.error instanceof Error ? update.error.message : null}
onCancel={() => setEditing(false)}
onSubmit={(input) => update.mutate(input, { onSuccess: () => setEditing(false) })}
/>
);
}
return (
<PersonView
person={person}
canEdit={canEdit}
canDelete={canDelete}
deleting={del.isPending}
onEdit={() => setEditing(true)}
onDelete={() => {
if (!confirm(`${person.firstname} ${person.lastname} wirklich löschen?`)) return;
del.mutate(citizenid, { onSuccess: onDeleted });
}}
/>
);
}
function StatusBadge({ person }: { person: Person }) {
const legal = LEGAL_STATUS[person.legalStatus];
const tone =
legal.tone === 'danger'
? 'bg-[var(--color-danger)]/15 text-[var(--color-danger)]'
: legal.tone === 'info'
? 'bg-[var(--color-primary)]/15 text-[var(--color-primary)]'
: 'bg-[var(--color-surface-2)] text-[var(--color-muted)]';
return <span className={cn('rounded-full px-3 py-1 text-xs', tone)}>{legal.label}</span>;
}
function PersonView({
person,
canEdit,
canDelete,
deleting,
onEdit,
onDelete,
}: {
person: Person;
canEdit: boolean;
canDelete: boolean;
deleting: boolean;
onEdit: () => void;
onDelete: () => void;
}) {
const canCases = useCan('mdt.cases.view');
const canTreatment = useCan('mdt.treatment.view');
const infoRows: [string, string | null][] = [
['Geschlecht', GENDER[person.gender]],
['Nationalität', person.nationality],
['Beruf', person.occupation],
['Adresse', person.address],
['Größe', person.height],
['Augenfarbe', person.eyeColor],
['Haarfarbe', person.hairColor],
];
return (
<div className="flex flex-col gap-4">
{/* Kopf mit Mugshot */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
{person.mugshotUrl ? (
<img
src={person.mugshotUrl}
alt="Mugshot"
className="h-20 w-20 rounded-lg object-cover"
onError={(e) => (e.currentTarget.style.visibility = 'hidden')}
/>
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-lg bg-[var(--color-surface-2)]">
<User size={28} className="text-[var(--color-muted)]" />
</div>
)}
<div>
<h2 className="text-xl font-semibold">
{person.firstname} {person.lastname}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{person.citizenid} · geb. {person.dob ?? '—'} · {person.phone ?? '—'}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<StatusBadge person={person} />
<AccessBadge access={{ public: person.public, acl: person.acl }} />
{person.isWanted ? (
<span className="flex items-center gap-1 rounded-full bg-[var(--color-danger)]/15 px-3 py-1 text-xs text-[var(--color-danger)]">
<ShieldAlert size={13} /> Gesucht
</span>
) : (
<span className="flex items-center gap-1 rounded-full bg-[var(--color-success)]/15 px-3 py-1 text-xs text-[var(--color-success)]">
<ShieldCheck size={13} /> Unauffällig
</span>
)}
{person.aliases.map((a) => (
<span
key={a}
className="rounded-full bg-[var(--color-surface-2)] px-2 py-0.5 text-xs text-[var(--color-muted)]"
>
aka {a}
</span>
))}
</div>
</div>
</div>
<div className="flex shrink-0 gap-2">
{canEdit && (
<Button variant="ghost" onClick={onEdit} className="flex items-center gap-1.5">
<Pencil size={14} /> Bearbeiten
</Button>
)}
{canDelete && (
<Button
variant="danger"
onClick={onDelete}
disabled={deleting}
className="flex items-center gap-1.5"
>
<Trash2 size={14} /> {deleting ? '…' : 'Löschen'}
</Button>
)}
</div>
</div>
{/* Kennzahlen */}
<div className="grid grid-cols-3 gap-3">
<Card className="flex items-center gap-3">
<Briefcase size={20} className="text-[var(--color-primary)]" />
<div>
<p className="text-xs text-[var(--color-muted)]">Beruf</p>
<p className="text-sm">{person.occupation ?? '—'}</p>
</div>
</Card>
<Card className="flex items-center gap-3">
<Gavel size={20} className="text-[var(--color-warning)]" />
<div>
<p className="text-xs text-[var(--color-muted)]">Offene Haftbefehle</p>
<p className="text-sm">{person.openWarrants}</p>
</div>
</Card>
<Card className="flex items-center gap-3">
<FileText size={20} className="text-[var(--color-muted)]" />
<div>
<p className="text-xs text-[var(--color-muted)]">Akten</p>
<p className="text-sm">{person.totalCases}</p>
</div>
</Card>
</div>
{/* Personalien */}
<Card>
<p className="mb-3 text-sm font-medium">Personalien</p>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
{infoRows.map(([label, value]) => (
<div
key={label}
className="flex justify-between border-b border-[var(--color-border)]/50 pb-1"
>
<dt className="text-[var(--color-muted)]">{label}</dt>
<dd className="text-right">{value ?? '—'}</dd>
</div>
))}
</dl>
{person.distinguishingMarks && (
<p className="mt-3 text-sm">
<span className="text-[var(--color-muted)]">Besondere Merkmale: </span>
{person.distinguishingMarks}
</p>
)}
{person.flags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{person.flags.map((f) => (
<span
key={f}
className="rounded-full bg-[var(--color-warning)]/15 px-2 py-0.5 text-xs text-[var(--color-warning)]"
>
{f}
</span>
))}
</div>
)}
</Card>
{/* Lizenzen */}
<Card>
<p className="mb-2 text-sm font-medium">Lizenzen</p>
<div className="flex flex-wrap gap-2">
{person.licenses.length === 0 && (
<span className="text-sm text-[var(--color-muted)]">Keine hinterlegt.</span>
)}
{person.licenses.map((l) => (
<span
key={l.type}
className={
l.active
? 'rounded-full bg-[var(--color-success)]/15 px-3 py-1 text-xs text-[var(--color-success)]'
: 'rounded-full bg-[var(--color-border)] px-3 py-1 text-xs text-[var(--color-muted)] line-through'
}
>
{l.label}
</span>
))}
</div>
</Card>
{/* Fahrzeuge */}
<Card>
<p className="mb-2 flex items-center gap-2 text-sm font-medium">
<Car size={16} className="text-[var(--color-muted)]" /> Fahrzeuge ({person.vehicles.length})
</p>
<div className="flex flex-wrap gap-2">
{person.vehicles.length === 0 && (
<span className="text-sm text-[var(--color-muted)]">Keine registrierten Fahrzeuge.</span>
)}
{person.vehicles.map((v) => {
const st = PLATE_STATUS[v.plateStatus];
return (
<span
key={v.plate}
className="flex items-center gap-2 rounded-lg bg-[var(--color-surface-2)] px-3 py-1.5 text-sm"
>
<span className="font-mono font-medium">{v.plate}</span>
<span className="text-xs text-[var(--color-muted)]">{v.model ?? 'Unbekannt'}</span>
{(st.danger || v.isStolen) && (
<span className="rounded-full bg-[var(--color-danger)]/15 px-2 py-0.5 text-[10px] text-[var(--color-danger)]">
{v.isStolen && v.plateStatus !== 'stolen' ? 'Gestohlen' : st.label}
</span>
)}
</span>
);
})}
</div>
</Card>
{/* Strafakten (nur mit Recht) */}
{canCases && <CitizenCases citizenid={person.citizenid} />}
{/* Behandlungsakten (nur mit Recht) */}
{canTreatment && <CitizenTreatments citizenid={person.citizenid} />}
{/* Notizen */}
<Card>
<p className="mb-2 text-sm font-medium">Interne Notizen</p>
<p className="whitespace-pre-wrap text-sm text-[var(--color-muted)]">
{person.notes || 'Keine Notizen.'}
</p>
</Card>
</div>
);
}
/** Strafakten des Bürgers direkt in der Akte (schneller Zugriff). */
function CitizenCases({ citizenid }: { citizenid: string }) {
const { data: cases } = useCases(citizenid);
return (
<Card>
<p className="mb-2 flex items-center gap-2 text-sm font-medium">
<Gavel size={16} className="text-[var(--color-warning)]" /> Strafakten ({cases?.length ?? 0})
</p>
{(!cases || cases.length === 0) && (
<span className="text-sm text-[var(--color-muted)]">Keine Strafakten.</span>
)}
<div className="flex flex-col gap-2">
{cases?.map((c) => (
<div key={c.id} className="rounded-lg bg-[var(--color-surface-2)] px-3 py-2">
<div className="flex items-center gap-2">
<span className="flex-1 text-sm font-medium">{c.title}</span>
<span className="rounded-full bg-[var(--color-border)] px-2 py-0.5 text-xs text-[var(--color-muted)]">
{CASE_STATUS[c.status] ?? c.status}
</span>
</div>
{c.charges.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{c.charges.map((ch, i) => (
<span key={i} className="rounded bg-[var(--color-border)] px-1.5 py-0.5 text-[11px]">
{ch.count}× {ch.code}
</span>
))}
</div>
)}
<p className="mt-1 text-xs text-[var(--color-muted)]">
{c.totalFine} · {c.totalJailTime} Monate · {new Date(c.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
))}
</div>
</Card>
);
}
/** Behandlungsakten des Bürgers direkt in der Akte (schneller Zugriff, EMS). */
function CitizenTreatments({ citizenid }: { citizenid: string }) {
const { data: records } = useTreatments(citizenid);
return (
<Card>
<p className="mb-2 flex items-center gap-2 text-sm font-medium">
<HeartPulse size={16} className="text-[var(--color-danger)]" /> Behandlungsakten ({records?.length ?? 0})
</p>
{(!records || records.length === 0) && (
<span className="text-sm text-[var(--color-muted)]">Keine Behandlungsakten.</span>
)}
<div className="flex flex-col gap-2">
{records?.map((r) => (
<div key={r.id} className="rounded-lg bg-[var(--color-surface-2)] px-3 py-2">
<div className="flex items-center gap-2">
<span className="flex-1 text-sm font-medium">{r.title}</span>
<span className="font-mono text-xs text-[var(--color-muted)]">{r.reference}</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
r.status === 'open'
? 'bg-[var(--color-warning)]/15 text-[var(--color-warning)]'
: 'bg-[var(--color-success)]/15 text-[var(--color-success)]',
)}
>
{r.status === 'open' ? 'Offen' : 'Abgeschlossen'}
</span>
</div>
{r.diagnosis.trim() && (
<p className="mt-1 text-xs">
<span className="text-[var(--color-muted)]">Diagnose: </span>
{r.diagnosis}
</p>
)}
<p className="mt-1 text-xs text-[var(--color-muted)]">
{r.authorName ?? '—'} · {new Date(r.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
))}
</div>
</Card>
);
}
@@ -0,0 +1,216 @@
import { useState } from 'react';
import { Search, ShieldAlert, ChevronLeft, ChevronRight, ArrowLeft, UserPlus, User } from 'lucide-react';
import { useCitizens, useCreateCitizen } from './api';
import { PersonDetail } from './PersonDetail';
import { CitizenForm } from './CitizenForm';
import { Input } from '../../ui/Input';
import { Button } from '../../ui/Button';
import { cn } from '../../ui/cn';
import { useCan } from '../../core/permissions';
import { AccessBadge } from '../documents/SharePanel';
import { LEGAL_STATUS } from './labels';
function ageFrom(dob: string | null): string {
if (!dob) return '—';
const d = new Date(dob);
if (isNaN(d.getTime())) return dob;
const age = Math.floor((Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000));
return `${dob} (${age})`;
}
type Mode = { view: 'list' } | { view: 'detail'; id: string } | { view: 'create' };
export function PersonsApp() {
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [mode, setMode] = useState<Mode>({ view: 'list' });
const canCreate = useCan('mdt.persons.create');
const create = useCreateCitizen();
if (mode.view === 'detail') {
return (
<div className="flex flex-col gap-3">
<button
onClick={() => setMode({ view: 'list' })}
className="flex w-fit items-center gap-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-text)]"
>
<ArrowLeft size={16} /> Zurück zur Übersicht
</button>
<PersonDetail citizenid={mode.id} onDeleted={() => setMode({ view: 'list' })} />
</div>
);
}
if (mode.view === 'create') {
return (
<CitizenForm
title="Neuen Bürger anlegen"
submitLabel="Anlegen"
submitting={create.isPending}
error={create.error instanceof Error ? create.error.message : null}
onCancel={() => setMode({ view: 'list' })}
onSubmit={(input) =>
create.mutate(input, { onSuccess: (person) => setMode({ view: 'detail', id: person.citizenid }) })
}
/>
);
}
return (
<CitizenRegistry
query={query}
setQuery={setQuery}
page={page}
setPage={setPage}
canCreate={canCreate}
onSelect={(id) => setMode({ view: 'detail', id })}
onCreate={() => setMode({ view: 'create' })}
/>
);
}
function CitizenRegistry({
query,
setQuery,
page,
setPage,
canCreate,
onSelect,
onCreate,
}: {
query: string;
setQuery: (q: string) => void;
page: number;
setPage: (p: number) => void;
canCreate: boolean;
onSelect: (id: string) => void;
onCreate: () => void;
}) {
const { data, isFetching } = useCitizens(page, query);
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
return (
<div className="mx-auto flex max-w-4xl flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">Bürgerakten</h2>
<div className="flex items-center gap-2">
<div className="relative w-72">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-muted)]" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
placeholder="Name oder ID…"
className="pl-9"
/>
</div>
{canCreate && (
<Button onClick={onCreate} className="flex shrink-0 items-center gap-1.5">
<UserPlus size={16} /> Neuer Bürger
</Button>
)}
</div>
</div>
<div className="overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--color-border)] bg-[var(--color-surface)] text-left text-xs uppercase tracking-wide text-[var(--color-muted)]">
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Geburtsdatum</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Fahndung</th>
</tr>
</thead>
<tbody>
{data?.items.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-[var(--color-muted)]">
Keine Bürger gefunden.
</td>
</tr>
)}
{data?.items.map((p) => {
const legal = LEGAL_STATUS[p.legalStatus];
return (
<tr
key={p.citizenid}
onClick={() => onSelect(p.citizenid)}
className="cursor-pointer border-b border-[var(--color-border)] last:border-0 hover:bg-[var(--color-surface-2)]"
>
<td className="px-4 py-2">
<div className="flex items-center gap-3">
{p.mugshotUrl ? (
<img
src={p.mugshotUrl}
alt=""
className="h-8 w-8 rounded object-cover"
onError={(e) => (e.currentTarget.style.visibility = 'hidden')}
/>
) : (
<span className="flex h-8 w-8 items-center justify-center rounded bg-[var(--color-surface-2)]">
<User size={15} className="text-[var(--color-muted)]" />
</span>
)}
<span className="flex items-center gap-2">
<span>
{p.firstname} {p.lastname}
<span className="ml-2 font-mono text-xs text-[var(--color-muted)]">{p.citizenid}</span>
</span>
{!p.public && <AccessBadge access={{ public: p.public, acl: p.acl }} />}
</span>
</div>
</td>
<td className="px-4 py-2 text-[var(--color-muted)]">{ageFrom(p.dob)}</td>
<td className="px-4 py-2">
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
legal.tone === 'danger'
? 'bg-[var(--color-danger)]/15 text-[var(--color-danger)]'
: legal.tone === 'info'
? 'bg-[var(--color-primary)]/15 text-[var(--color-primary)]'
: 'text-[var(--color-muted)]',
)}
>
{legal.label}
</span>
</td>
<td className="px-4 py-2">
{p.isWanted ? (
<span className="flex w-fit items-center gap-1 rounded-full bg-[var(--color-danger)]/15 px-2 py-0.5 text-xs text-[var(--color-danger)]">
<ShieldAlert size={12} /> Gesucht
</span>
) : (
<span className="text-xs text-[var(--color-muted)]"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between text-sm text-[var(--color-muted)]">
<span>
{data?.total ?? 0} Bürger{isFetching ? ' · lädt…' : ''}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" disabled={page <= 1} onClick={() => setPage(page - 1)}>
<ChevronLeft size={16} />
</Button>
<span className={cn('tabular-nums')}>
Seite {page} / {totalPages}
</span>
<Button variant="ghost" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
<ChevronRight size={16} />
</Button>
</div>
</div>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Person, PersonSummary, CreateCitizenInput, UpdateCitizenInput } from '@d4rk-tablet/shared';
import { api } from '../../core/api-client';
export interface CitizenPage {
items: PersonSummary[];
total: number;
page: number;
pageSize: number;
}
/** Paginierte Bürger-Registry (query optional, ab 2 Zeichen als Filter). */
export function useCitizens(page: number, query: string) {
const q = query.trim();
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
if (q.length >= 2) params.set('query', q);
return useQuery({
queryKey: ['citizens', page, q.length >= 2 ? q : ''],
queryFn: () => api<CitizenPage>(`/citizens?${params.toString()}`),
placeholderData: (prev) => prev,
});
}
export function useSearchPersons(query: string) {
return useQuery({
queryKey: ['persons', 'search', query],
queryFn: () => api<PersonSummary[]>(`/persons?query=${encodeURIComponent(query)}`),
enabled: query.trim().length >= 2,
});
}
export function usePerson(citizenid: string | null) {
return useQuery({
queryKey: ['persons', 'detail', citizenid],
queryFn: () => api<Person>(`/persons/${citizenid}`),
enabled: !!citizenid,
});
}
function invalidateAll(qc: ReturnType<typeof useQueryClient>, citizenid?: string) {
void qc.invalidateQueries({ queryKey: ['citizens'] });
void qc.invalidateQueries({ queryKey: ['persons', 'search'] });
if (citizenid) void qc.invalidateQueries({ queryKey: ['persons', 'detail', citizenid] });
}
export function useCreateCitizen() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateCitizenInput) =>
api<Person>('/citizens', { method: 'POST', body: JSON.stringify(input) }),
onSuccess: () => invalidateAll(qc),
});
}
export function useUpdateCitizen(citizenid: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: UpdateCitizenInput) =>
api<Person>(`/persons/${citizenid}`, { method: 'PATCH', body: JSON.stringify(input) }),
onSuccess: () => invalidateAll(qc, citizenid),
});
}
export function useDeleteCitizen() {
const qc = useQueryClient();
return useMutation({
mutationFn: (citizenid: string) =>
api<void>(`/persons/${citizenid}`, { method: 'DELETE' }),
onSuccess: () => invalidateAll(qc),
});
}
@@ -0,0 +1,44 @@
import type { Gender, LegalStatus, PlateStatus } from '@d4rk-tablet/shared';
export const LEGAL_STATUS: Record<LegalStatus, { label: string; tone: 'neutral' | 'info' | 'danger' | 'muted' }> = {
citizen: { label: 'Staatsbürger', tone: 'neutral' },
resident: { label: 'Aufenthaltsberechtigt', tone: 'info' },
visitor: { label: 'Besucher', tone: 'muted' },
illegal: { label: 'Illegale Einreise', tone: 'danger' },
unknown: { label: 'Unbekannt', tone: 'muted' },
};
export const LEGAL_STATUS_OPTIONS = Object.entries(LEGAL_STATUS).map(([value, v]) => ({
value: value as LegalStatus,
label: v.label,
}));
export const GENDER: Record<Gender, string> = {
male: 'Männlich',
female: 'Weiblich',
divers: 'Divers',
unknown: 'Unbekannt',
};
export const GENDER_OPTIONS = Object.entries(GENDER).map(([value, label]) => ({
value: value as Gender,
label,
}));
export const PLATE_STATUS: Record<PlateStatus, { label: string; danger: boolean }> = {
registered: { label: 'Registriert', danger: false },
forged: { label: 'Gefälscht', danger: true },
stolen: { label: 'Gestohlen', danger: true },
unknown: { label: 'Unbekannt', danger: false },
};
export const PLATE_STATUS_OPTIONS = Object.entries(PLATE_STATUS).map(([value, v]) => ({
value: value as PlateStatus,
label: v.label,
}));
/** Standard-Scheine, die als Lizenzen geführt werden können. */
export const LICENSE_CATALOG: { type: string; label: string }[] = [
{ type: 'driver', label: 'Führerschein' },
{ type: 'weapon', label: 'Waffenschein' },
{ type: 'business', label: 'Gewerbeschein' },
{ type: 'pilot', label: 'Pilotenschein' },
{ type: 'boat', label: 'Bootsführerschein' },
];
+35
View File
@@ -0,0 +1,35 @@
import type { ComponentType } from 'react';
import { LayoutDashboard, Users, Car, Gavel, HeartPulse, Scale, CalendarDays, Radio, FileText, ShieldCheck, type LucideIcon } from 'lucide-react';
import type { Permission } from '@d4rk-tablet/shared';
import { StartApp } from './dashboard/StartApp';
import { PersonsApp } from './persons/PersonsApp';
import { VehiclesApp } from './vehicles/VehiclesApp';
import { ChargesApp } from './charges/ChargesApp';
import { TreatmentApp } from './treatment/TreatmentApp';
import { LawsApp } from './laws/LawsApp';
import { CalendarApp } from './calendar/CalendarApp';
import { DispatchApp } from './dispatch/DispatchApp';
import { DocumentsApp } from './documents/DocumentsApp';
import { AdminApp } from './admin/AdminApp';
export interface AppModule {
id: string;
label: string;
icon: LucideIcon;
component: ComponentType;
/** Permission, die zum Öffnen nötig ist (clientseitige UI-Filterung). */
permission: Permission;
}
export const APPS: AppModule[] = [
{ id: 'start', label: 'Start', icon: LayoutDashboard, component: StartApp, permission: 'mdt.board.view' },
{ id: 'persons', label: 'Bürgerakten', icon: Users, component: PersonsApp, permission: 'mdt.persons.view' },
{ id: 'vehicles', label: 'Fahrzeuge', icon: Car, component: VehiclesApp, permission: 'mdt.vehicles.view' },
{ id: 'charges', label: 'Strafakten', icon: Gavel, component: ChargesApp, permission: 'mdt.cases.view' },
{ id: 'treatment', label: 'Behandlungsakten', icon: HeartPulse, component: TreatmentApp, permission: 'mdt.treatment.view' },
{ id: 'laws', label: 'Gesetze', icon: Scale, component: LawsApp, permission: 'mdt.laws.view' },
{ id: 'calendar', label: 'Kalender', icon: CalendarDays, component: CalendarApp, permission: 'mdt.calendar.view' },
{ id: 'dispatch', label: 'Dispatch', icon: Radio, component: DispatchApp, permission: 'cad.dispatch.view' },
{ id: 'documents', label: 'Dokumente', icon: FileText, component: DocumentsApp, permission: 'mdt.documents.view' },
{ id: 'admin', label: 'Verwaltung', icon: ShieldCheck, component: AdminApp, permission: 'admin.users.manage' },
];
@@ -0,0 +1,161 @@
import { useState } from 'react';
import { Search, User, HeartPulse, Plus, CheckCircle2 } from 'lucide-react';
import { useSearchPersons } from '../persons/api';
import { useTreatments, useCreateTreatment, useUpdateTreatment } from './api';
import { useCan } from '../../core/permissions';
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 Patient {
citizenid: string;
name: string;
}
export function TreatmentApp() {
const [query, setQuery] = useState('');
const [patient, setPatient] = useState<Patient | null>(null);
const { data: results } = useSearchPersons(query);
return (
<div className="flex h-full gap-4">
<div className="flex w-72 shrink-0 flex-col gap-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-muted)]" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Patient suchen…" className="pl-9" />
</div>
<div className="flex flex-col gap-1 overflow-auto">
{results?.map((p) => (
<button
key={p.citizenid}
onClick={() => setPatient({ citizenid: p.citizenid, name: `${p.firstname} ${p.lastname}` })}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors',
patient?.citizenid === p.citizenid ? 'bg-[var(--color-surface-2)]' : 'hover:bg-[var(--color-surface-2)]',
)}
>
<User size={16} className="text-[var(--color-muted)]" />
<span className="flex-1">
{p.firstname} {p.lastname}
<span className="block text-xs text-[var(--color-muted)]">{p.citizenid}</span>
</span>
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto">
{patient ? (
<PatientView key={patient.citizenid} patient={patient} />
) : (
<EmptyState title="Behandlungsakten" hint="Patient links auswählen." />
)}
</div>
</div>
);
}
function PatientView({ patient }: { patient: Patient }) {
const { citizenid, name } = patient;
const { data: records } = useTreatments(citizenid);
const canCreate = useCan('mdt.treatment.create');
const canEdit = useCan('mdt.treatment.edit');
const update = useUpdateTreatment(citizenid);
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">{name}</h2>
{canCreate && <TreatmentCreator citizenid={citizenid} />}
<Card className="flex flex-col gap-3">
<p className="text-sm font-medium">Behandlungsakten ({records?.length ?? 0})</p>
{records?.length === 0 && <p className="text-sm text-[var(--color-muted)]">Keine Akten.</p>}
{records?.map((r) => (
<div key={r.id} className="rounded-lg bg-[var(--color-surface-2)] px-3 py-2">
<div className="flex items-center gap-2">
<HeartPulse size={16} className="text-[var(--color-danger)]" />
<span className="flex-1 text-sm font-medium">{r.title}</span>
<span className="font-mono text-xs text-[var(--color-muted)]">{r.reference}</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
r.status === 'open'
? 'bg-[var(--color-warning)]/15 text-[var(--color-warning)]'
: 'bg-[var(--color-success)]/15 text-[var(--color-success)]',
)}
>
{r.status === 'open' ? 'Offen' : 'Abgeschlossen'}
</span>
</div>
{r.diagnosis.trim() && (
<p className="mt-1 pl-6 text-sm">
<span className="text-[var(--color-muted)]">Diagnose: </span>
{r.diagnosis}
</p>
)}
{r.treatment.replace(/<[^>]*>/g, '').trim() && (
<div className="mt-2 pl-6">
<RichTextEditor content={r.treatment} editable={false} />
</div>
)}
<div className="mt-2 flex items-center gap-2 pl-6 text-xs text-[var(--color-muted)]">
<span>{r.authorName ?? '—'}</span>
<span>·</span>
<span>{new Date(r.createdAt).toLocaleDateString('de-DE')}</span>
{canEdit && r.status === 'open' && (
<button
onClick={() => update.mutate({ id: r.id, input: { status: 'closed' } })}
className="ml-auto flex items-center gap-1 text-[var(--color-primary)] hover:underline"
>
<CheckCircle2 size={13} /> abschließen
</button>
)}
</div>
</div>
))}
</Card>
</div>
);
}
function TreatmentCreator({ citizenid }: { citizenid: string }) {
const create = useCreateTreatment(citizenid);
const [title, setTitle] = useState('');
const [diagnosis, setDiagnosis] = useState('');
const [treatment, setTreatment] = useState('');
const reset = () => {
setTitle('');
setDiagnosis('');
setTreatment('');
};
return (
<Card className="flex flex-col gap-3">
<p className="flex items-center gap-2 text-sm font-medium">
<Plus size={16} /> Neue Behandlungsakte
</p>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Titel (z. B. Schussverletzung Bein)" />
<Input value={diagnosis} onChange={(e) => setDiagnosis(e.target.value)} placeholder="Diagnose" />
<div>
<p className="mb-1 text-xs font-medium text-[var(--color-muted)]">Behandlung / Verlauf</p>
<RichTextEditor content={treatment} onChange={setTreatment} editable />
</div>
<Button
className="self-start"
disabled={title.trim().length === 0 || create.isPending}
onClick={() =>
create.mutate(
{ patientCitizenid: citizenid, title: title.trim(), diagnosis: diagnosis.trim(), treatment },
{ onSuccess: reset },
)
}
>
{create.isPending ? 'Speichert…' : 'Akte anlegen'}
</Button>
</Card>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { TreatmentRecord, CreateTreatmentInput, UpdateTreatmentInput } from '@d4rk-tablet/shared';
import { api } from '../../core/api-client';
export function useTreatments(citizenid: string | null) {
return useQuery({
queryKey: ['treatments', citizenid],
queryFn: () => api<TreatmentRecord[]>(`/treatments?citizenid=${encodeURIComponent(citizenid!)}`),
enabled: !!citizenid,
});
}
export function useCreateTreatment(citizenid: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateTreatmentInput) =>
api<TreatmentRecord>('/treatments', { method: 'POST', body: JSON.stringify(input) }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['treatments', citizenid] }),
});
}
export function useUpdateTreatment(citizenid: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdateTreatmentInput }) =>
api<TreatmentRecord>(`/treatments/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['treatments', citizenid] }),
});
}
@@ -0,0 +1,420 @@
import { useState } from 'react';
import {
Search,
Car,
TriangleAlert,
User,
Plus,
ChevronLeft,
ChevronRight,
ArrowLeft,
Pencil,
Trash2,
} from 'lucide-react';
import type { CreateVehicleInput, PlateStatus, Vehicle } from '@d4rk-tablet/shared';
import {
useVehicles,
useCreateVehicle,
useUpdateVehicle,
useDeleteVehicle,
useFlagVehicle,
} from './api';
import { useCan } from '../../core/permissions';
import { Card } from '../../ui/Card';
import { Input } from '../../ui/Input';
import { Button } from '../../ui/Button';
import { cn } from '../../ui/cn';
import { PLATE_STATUS, PLATE_STATUS_OPTIONS } from '../persons/labels';
type Mode = { view: 'list' } | { view: 'detail'; vehicle: Vehicle } | { view: 'edit'; vehicle: Vehicle } | { view: 'create' };
export function VehiclesApp() {
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [mode, setMode] = useState<Mode>({ view: 'list' });
const canCreate = useCan('mdt.vehicles.create');
const create = useCreateVehicle();
if (mode.view === 'create') {
return (
<VehicleForm
title="Neues Fahrzeug anlegen"
submitLabel="Anlegen"
submitting={create.isPending}
error={create.error instanceof Error ? create.error.message : null}
onCancel={() => setMode({ view: 'list' })}
onSubmit={(input) =>
create.mutate(input, { onSuccess: (v) => setMode({ view: 'detail', vehicle: v }) })
}
/>
);
}
if (mode.view === 'edit') {
return <EditVehicle vehicle={mode.vehicle} onDone={(v) => setMode({ view: 'detail', vehicle: v })} onCancel={() => setMode({ view: 'detail', vehicle: mode.vehicle })} />;
}
if (mode.view === 'detail') {
return (
<div className="mx-auto flex max-w-2xl flex-col gap-3">
<button
onClick={() => setMode({ view: 'list' })}
className="flex w-fit items-center gap-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-text)]"
>
<ArrowLeft size={16} /> Zurück zur Registry
</button>
<VehicleDetail
vehicle={mode.vehicle}
onEdit={() => setMode({ view: 'edit', vehicle: mode.vehicle })}
onDeleted={() => setMode({ view: 'list' })}
/>
</div>
);
}
return (
<VehicleRegistry
query={query}
setQuery={setQuery}
page={page}
setPage={setPage}
canCreate={canCreate}
onSelect={(v) => setMode({ view: 'detail', vehicle: v })}
onCreate={() => setMode({ view: 'create' })}
/>
);
}
function PlateBadge({ status }: { status: PlateStatus }) {
const s = PLATE_STATUS[status];
return (
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
s.danger ? 'bg-[var(--color-danger)]/15 text-[var(--color-danger)]' : 'text-[var(--color-muted)]',
)}
>
{s.label}
</span>
);
}
function VehicleRegistry({
query,
setQuery,
page,
setPage,
canCreate,
onSelect,
onCreate,
}: {
query: string;
setQuery: (q: string) => void;
page: number;
setPage: (p: number) => void;
canCreate: boolean;
onSelect: (v: Vehicle) => void;
onCreate: () => void;
}) {
const { data, isFetching } = useVehicles(page, query);
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
return (
<div className="mx-auto flex max-w-4xl flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">Fahrzeug-Registry</h2>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-muted)]" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
placeholder="Kennzeichen oder Modell…"
className="pl-9 uppercase"
/>
</div>
{canCreate && (
<Button onClick={onCreate} className="flex shrink-0 items-center gap-1.5">
<Plus size={16} /> Neues Fahrzeug
</Button>
)}
</div>
</div>
<div className="overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--color-border)] bg-[var(--color-surface)] text-left text-xs uppercase tracking-wide text-[var(--color-muted)]">
<th className="px-4 py-2 font-medium">Kennzeichen</th>
<th className="px-4 py-2 font-medium">Modell</th>
<th className="px-4 py-2 font-medium">Halter</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{data?.items.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-6 text-center text-[var(--color-muted)]">
Keine Fahrzeuge gefunden.
</td>
</tr>
)}
{data?.items.map((v) => (
<tr
key={v.id}
onClick={() => onSelect(v)}
className="cursor-pointer border-b border-[var(--color-border)] last:border-0 hover:bg-[var(--color-surface-2)]"
>
<td className="px-4 py-2 font-mono font-medium">{v.plate}</td>
<td className="px-4 py-2 text-[var(--color-muted)]">{v.model ?? '—'}</td>
<td className="px-4 py-2 text-[var(--color-muted)]">{v.ownerName ?? 'Unbekannt'}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-1.5">
<PlateBadge status={v.plateStatus} />
{v.isStolen && v.plateStatus !== 'stolen' && (
<span className="rounded-full bg-[var(--color-danger)]/15 px-2 py-0.5 text-xs text-[var(--color-danger)]">
Gestohlen
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm text-[var(--color-muted)]">
<span>
{data?.total ?? 0} Fahrzeuge{isFetching ? ' · lädt…' : ''}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" disabled={page <= 1} onClick={() => setPage(page - 1)}>
<ChevronLeft size={16} />
</Button>
<span className="tabular-nums">
Seite {page} / {totalPages}
</span>
<Button variant="ghost" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
<ChevronRight size={16} />
</Button>
</div>
</div>
</div>
);
}
function VehicleDetail({
vehicle,
onEdit,
onDeleted,
}: {
vehicle: Vehicle;
onEdit: () => void;
onDeleted: () => void;
}) {
const flag = useFlagVehicle(vehicle.plate);
const del = useDeleteVehicle();
const canFlag = useCan('mdt.vehicles.flag');
const canEdit = useCan('mdt.vehicles.flag');
const canDelete = useCan('mdt.vehicles.delete');
// Nach dem Flaggen kann sich isStolen ändern → lokale Sicht aktualisieren
const current = flag.data ?? vehicle;
return (
<Card className="flex flex-col gap-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Car size={24} className="text-[var(--color-primary)]" />
<div>
<p className="text-lg font-semibold">{current.plate}</p>
<p className="text-sm text-[var(--color-muted)]">{current.model ?? 'Unbekannt'}</p>
</div>
</div>
<div className="flex gap-2">
{canEdit && (
<Button variant="ghost" onClick={onEdit} className="flex items-center gap-1.5">
<Pencil size={14} /> Bearbeiten
</Button>
)}
{canDelete && (
<Button
variant="danger"
disabled={del.isPending}
onClick={() => {
if (!confirm(`Fahrzeug ${current.plate} wirklich löschen?`)) return;
del.mutate(current.id, { onSuccess: onDeleted });
}}
className="flex items-center gap-1.5"
>
<Trash2 size={14} /> Löschen
</Button>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<User size={16} className="text-[var(--color-muted)]" />
<span>{current.ownerName ?? 'Kein Halter'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[var(--color-muted)]">Kennzeichen-Status:</span>
<PlateBadge status={current.plateStatus} />
</div>
<div className="text-[var(--color-muted)]">Farbe: {current.color ?? '—'}</div>
{current.isStolen && (
<span className="flex items-center gap-1.5 text-[var(--color-danger)]">
<TriangleAlert size={16} /> Als gestohlen gemeldet
</span>
)}
</div>
{current.notes && (
<p className="whitespace-pre-wrap rounded-lg bg-[var(--color-surface-2)] p-3 text-sm">{current.notes}</p>
)}
{canFlag && (
<Button
variant={current.isStolen ? 'ghost' : 'danger'}
onClick={() => flag.mutate({ isStolen: !current.isStolen })}
disabled={flag.isPending}
className="self-start"
>
{current.isStolen ? 'Fahndung aufheben' : 'Als gestohlen markieren'}
</Button>
)}
</Card>
);
}
function EditVehicle({
vehicle,
onDone,
onCancel,
}: {
vehicle: Vehicle;
onDone: (v: Vehicle) => void;
onCancel: () => void;
}) {
const update = useUpdateVehicle(vehicle.id);
return (
<VehicleForm
initial={vehicle}
title={`${vehicle.plate} bearbeiten`}
submitLabel="Speichern"
submitting={update.isPending}
error={update.error instanceof Error ? update.error.message : null}
onCancel={onCancel}
onSubmit={(input) => update.mutate(input, { onSuccess: (v) => onDone(v) })}
/>
);
}
function VehicleForm({
initial,
title,
submitLabel,
submitting,
error,
onSubmit,
onCancel,
}: {
initial?: Vehicle;
title: string;
submitLabel: string;
submitting: boolean;
error?: string | null;
onSubmit: (input: CreateVehicleInput) => void;
onCancel: () => void;
}) {
const [plate, setPlate] = useState(initial?.plate ?? '');
const [model, setModel] = useState(initial?.model ?? '');
const [color, setColor] = useState(initial?.color ?? '');
const [owner, setOwner] = useState(initial?.ownerCitizenid ?? '');
const [plateStatus, setPlateStatus] = useState<PlateStatus>(initial?.plateStatus ?? 'registered');
const [isStolen, setIsStolen] = useState(initial?.isStolen ?? false);
const [notes, setNotes] = useState(initial?.notes ?? '');
const canSubmit = plate.trim().length > 0 && !submitting;
return (
<div className="mx-auto flex max-w-2xl flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{title}</h2>
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} disabled={submitting}>
Abbrechen
</Button>
<Button
onClick={() =>
onSubmit({
plate: plate.trim().toUpperCase(),
model: model.trim() || null,
color: color.trim() || null,
ownerCitizenid: owner.trim() || null,
plateStatus,
isStolen,
notes,
})
}
disabled={!canSubmit}
>
{submitting ? 'Speichert…' : submitLabel}
</Button>
</div>
</div>
{error && <p className="text-sm text-[var(--color-danger)]">{error}</p>}
<Card className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Kennzeichen *</span>
<Input value={plate} onChange={(e) => setPlate(e.target.value)} className="uppercase" />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Modell</span>
<Input value={model} onChange={(e) => setModel(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Farbe</span>
<Input value={color} onChange={(e) => setColor(e.target.value)} />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Halter (citizenid, optional)</span>
<Input value={owner} onChange={(e) => setOwner(e.target.value)} placeholder="LS-100001" />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Kennzeichen-Status</span>
<select
value={plateStatus}
onChange={(e) => setPlateStatus(e.target.value as PlateStatus)}
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-sm outline-none focus:border-[var(--color-primary)]"
>
{PLATE_STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 self-end text-sm">
<input type="checkbox" checked={isStolen} onChange={(e) => setIsStolen(e.target.checked)} />
Als gestohlen markiert
</label>
<label className="col-span-2 flex flex-col gap-1 text-sm">
<span className="text-xs font-medium text-[var(--color-muted)]">Notizen</span>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full resize-none rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-sm outline-none focus:border-[var(--color-primary)]"
/>
</label>
</Card>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { Vehicle, FlagVehicleInput, CreateVehicleInput, UpdateVehicleInput } from '@d4rk-tablet/shared';
import { api } from '../../core/api-client';
export interface VehiclePage {
items: Vehicle[];
total: number;
page: number;
pageSize: number;
}
/** Paginierte Fahrzeug-Registry (query optional, ab 2 Zeichen als Filter). */
export function useVehicles(page: number, query: string) {
const q = query.trim();
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
if (q.length >= 2) params.set('query', q);
return useQuery({
queryKey: ['vehicles', 'list', page, q.length >= 2 ? q : ''],
queryFn: () => api<VehiclePage>(`/vehicles?${params.toString()}`),
placeholderData: (prev) => prev,
});
}
export function useVehicle(plate: string | null) {
return useQuery({
queryKey: ['vehicles', 'plate', plate],
queryFn: () => api<Vehicle>(`/vehicles/${encodeURIComponent(plate!)}`),
enabled: !!plate,
retry: false,
});
}
function invalidate(qc: ReturnType<typeof useQueryClient>) {
void qc.invalidateQueries({ queryKey: ['vehicles'] });
}
export function useCreateVehicle() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateVehicleInput) =>
api<Vehicle>('/vehicles', { method: 'POST', body: JSON.stringify(input) }),
onSuccess: () => invalidate(qc),
});
}
export function useUpdateVehicle(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: UpdateVehicleInput) =>
api<Vehicle>(`/vehicles/id/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
onSuccess: () => invalidate(qc),
});
}
export function useDeleteVehicle() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => api<void>(`/vehicles/id/${id}`, { method: 'DELETE' }),
onSuccess: () => invalidate(qc),
});
}
export function useFlagVehicle(plate: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: FlagVehicleInput) =>
api<Vehicle>(`/vehicles/${encodeURIComponent(plate)}/flag`, {
method: 'PATCH',
body: JSON.stringify(input),
}),
onSuccess: (data) => {
qc.setQueryData(['vehicles', 'plate', plate], data);
invalidate(qc);
},
});
}
+41
View File
@@ -0,0 +1,41 @@
import { config } from './config';
import { useAuth } from './auth';
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
/** Zentraler fetch-Wrapper: hängt JWT an, parst JSON, wirft ApiError. */
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = useAuth.getState().token;
const headers = new Headers(init.headers);
// Content-Type nur bei vorhandenem Body — sonst lehnt Fastify leere JSON-Bodies
// (z. B. bei DELETE ohne Body) mit 400 ab.
if (init.body != null) headers.set('Content-Type', 'application/json');
if (token) headers.set('Authorization', `Bearer ${token}`);
const res = await fetch(`${config.apiUrl}${path}`, {
...init,
headers,
credentials: 'include',
});
if (res.status === 401) {
useAuth.getState().clear();
throw new ApiError(401, 'Nicht authentifiziert');
}
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { message?: string } | null;
throw new ApiError(res.status, body?.message ?? `Request fehlgeschlagen (${res.status})`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
+57
View File
@@ -0,0 +1,57 @@
import { create } from 'zustand';
import type { AuthUser, AuthTokenResponse } from '@d4rk-tablet/shared';
import { config } from './config';
interface AuthState {
token: string | null;
user: AuthUser | null;
ready: boolean;
setAuth: (token: string, user: AuthUser) => void;
clear: () => void;
logout: () => void;
init: () => void;
}
export const useAuth = create<AuthState>((set) => ({
token: null,
user: null,
ready: false,
setAuth: (token, user) => set({ token, user, ready: true }),
clear: () => set({ token: null, user: null, ready: true }),
logout: () => {
set({ token: null, user: null, ready: true });
// Im NUI: der Bridge signalisieren, dass das Tablet geschlossen werden soll
window.parent?.postMessage({ action: 'mdt:close' }, '*');
},
init: () => {
// 1) FiveM-NUI: Token kommt per postMessage von der Bridge-Shell
window.addEventListener('message', (event: MessageEvent) => {
const data = event.data as { action?: string; token?: string; user?: AuthUser };
if (data?.action === 'auth' && data.token && data.user) {
set({ token: data.token, user: data.user, ready: true });
}
});
// Läuft die App in einem iframe (NUI-Shell)? Dann der Shell signalisieren,
// dass wir bereit sind, den Auth-Token zu empfangen.
if (window.parent && window.parent !== window) {
window.parent.postMessage({ action: 'mdt:ready' }, '*');
}
// 2) Browser-Dev: echtes JWT vom Backend holen (/auth/dev)
if (config.devAuthBypass) {
void fetch(`${config.apiUrl}/auth/dev`, { method: 'POST' })
.then((res) => (res.ok ? (res.json() as Promise<AuthTokenResponse>) : null))
.then((data) => {
if (data) set({ token: data.token, user: data.user, ready: true });
else set({ ready: true });
})
.catch(() => set({ ready: true }));
return;
}
// 3) Browser-Prod: Discord-OAuth-Redirect
set({ ready: true });
},
}));
+55
View File
@@ -0,0 +1,55 @@
import { Shield, Plus, Flame, type LucideIcon } from 'lucide-react';
import { DEPARTMENTS, type Department, type Role } from '@d4rk-tablet/shared';
export interface Brand {
id: Department | 'default';
name: string;
subtitle: string;
/** Akzentfarbe — überschreibt --color-primary im Shell-Wrapper. */
accent: string;
accentHover: string;
icon: LucideIcon;
}
export const BRANDS: Record<Department | 'default', Brand> = {
police: {
id: 'police',
name: 'Los Santos Police Dept.',
subtitle: 'Mobile Data Terminal',
accent: '#2f6df6',
accentHover: '#4a82ff',
icon: Shield,
},
ems: {
id: 'ems',
name: 'Los Santos Medical Dept.',
subtitle: 'Office of Emergency Information',
accent: '#e5484d',
accentHover: '#f0605f',
icon: Plus,
},
fire: {
id: 'fire',
name: 'Los Santos Fire Dept.',
subtitle: 'Emergency Response',
accent: '#f5a524',
accentHover: '#ffb63c',
icon: Flame,
},
default: {
id: 'default',
name: 'Behörden-MDT',
subtitle: 'Terminal',
accent: '#2f6df6',
accentHover: '#4a82ff',
icon: Shield,
},
};
/** Leitet die Behörde aus den Rollen des Users ab (police > ems > fire). */
export function getBrand(roles: readonly Role[]): Brand {
for (const dept of DEPARTMENTS) {
if (roles.includes(dept)) return BRANDS[dept];
}
return BRANDS.default;
}
+12
View File
@@ -0,0 +1,12 @@
export const config = {
apiUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:3000',
socketUrl: import.meta.env.VITE_SOCKET_URL ?? 'http://localhost:3000',
/** Basis-URL der GTA-Map-Tiles ({style}/{z}/{x}/{y}.jpg). */
mapTilesUrl: import.meta.env.VITE_MAP_TILES_URL ?? 'http://localhost:3000/tiles',
mapStyle: import.meta.env.VITE_MAP_STYLE ?? 'satellite',
/** Dev-Only: umgeht Login im Browser. Im NUI/Prod immer false. */
devAuthBypass: import.meta.env.VITE_DEV_AUTH_BYPASS === 'true',
};
/** True, wenn die App im FiveM-NUI-iframe läuft (nicht im normalen Browser). */
export const isNui = typeof GetParentResourceName === 'function';
+11
View File
@@ -0,0 +1,11 @@
import { can, type Permission } from '@d4rk-tablet/shared';
import { useAuth } from './auth';
/** Effektive Rechte des eingeloggten Users (aus dem JWT, server-seitig berechnet). */
export function usePermissions(): Permission[] {
return useAuth((s) => s.user?.permissions ?? []);
}
export function useCan(perm: Permission): boolean {
return useAuth((s) => can(s.user?.permissions ?? [], perm));
}
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
+24
View File
@@ -0,0 +1,24 @@
import { io, type Socket } from 'socket.io-client';
import type { ServerToClientEvents, ClientToServerEvents } from '@d4rk-tablet/shared';
import { config } from './config';
import { useAuth } from './auth';
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
let socket: AppSocket | null = null;
/** Lazy Singleton — verbindet mit JWT aus dem Auth-Store. */
export function getSocket(): AppSocket {
if (socket) return socket;
socket = io(config.socketUrl, {
autoConnect: false,
auth: (cb) => cb({ token: useAuth.getState().token ?? '' }),
transports: ['websocket'],
});
return socket;
}
export function connectSocket(): void {
const s = getSocket();
if (!s.connected) s.connect();
}
+192
View File
@@ -0,0 +1,192 @@
@import 'tailwindcss';
/* ── Design-Tokens (CSS-first, Tailwind 4) — dunkles Behörden-Theme ── */
@theme {
--color-bg: #0b0f1a;
--color-surface: #131a2b;
--color-surface-2: #1b2438;
--color-border: #263149;
--color-text: #e6ebf5;
--color-muted: #8b97b3;
--color-primary: #2f6df6;
--color-primary-hover: #4a82ff;
--color-danger: #e5484d;
--color-warning: #f5a524;
--color-success: #30a46c;
--radius-card: 0.75rem;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* ── LiveMap-Marker (MapLibre HTML-Marker) ──
Die verankerte Box ist NUR der Punkt (feste Größe → anchor 'center' sitzt exakt
auf der Koordinate, auch beim Zoomen). Label + Puls sind absolut positioniert und
beeinflussen die Box-Größe/den Anker NICHT. */
.mdt-marker {
position: relative;
width: 14px;
height: 14px;
}
.mdt-dot {
position: absolute;
inset: 0;
border-radius: 9999px;
box-shadow: 0 0 0 2px #fff, 0 1px 3px rgba(0, 0, 0, 0.55);
}
.mdt-pulse {
position: absolute;
left: 50%;
top: 50%;
width: 34px;
height: 34px;
margin-left: -17px;
margin-top: -17px;
border-radius: 9999px;
animation: mdt-pulse 1.8s ease-out infinite;
}
@keyframes mdt-pulse {
0% { transform: scale(0.5); opacity: 0.9; }
100% { transform: scale(1.6); opacity: 0; }
}
.mdt-lbl {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
font-size: 11px;
font-weight: 600;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
white-space: nowrap;
pointer-events: none;
}
/* ── Tiptap Rich-Text ── */
.tiptap {
font-size: 0.9rem;
line-height: 1.6;
}
.tiptap p {
margin: 0 0 0.5rem;
}
.tiptap h1,
.tiptap h2,
.tiptap h3 {
font-weight: 600;
margin: 0.8rem 0 0.4rem;
line-height: 1.3;
}
.tiptap h1 { font-size: 1.4rem; }
.tiptap h2 { font-size: 1.2rem; }
.tiptap h3 { font-size: 1.05rem; }
.tiptap ul,
.tiptap ol {
padding-left: 1.4rem;
margin: 0 0 0.5rem;
}
.tiptap ul { list-style: disc; }
.tiptap ol { list-style: decimal; }
.tiptap blockquote {
border-left: 3px solid var(--color-border);
padding-left: 0.8rem;
color: var(--color-muted);
margin: 0 0 0.5rem;
}
.tiptap:focus {
outline: none;
}
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--color-muted);
float: left;
height: 0;
pointer-events: none;
}
.tiptap a {
color: var(--color-primary);
text-decoration: underline;
cursor: pointer;
}
.tiptap mark {
background: #f5a52455;
border-radius: 0.2rem;
padding: 0 0.1rem;
color: inherit;
}
.tiptap code {
background: var(--color-surface-2);
border-radius: 0.25rem;
padding: 0.1rem 0.3rem;
font-size: 0.85em;
}
.tiptap pre {
background: var(--color-surface-2);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
overflow-x: auto;
margin: 0 0 0.5rem;
}
.tiptap pre code {
background: none;
padding: 0;
}
.tiptap hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 1rem 0;
}
.tiptap img {
max-width: 100%;
border-radius: 0.5rem;
}
/* Aufgabenliste */
.tiptap ul[data-type='taskList'] {
list-style: none;
padding-left: 0.2rem;
}
.tiptap ul[data-type='taskList'] li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.tiptap ul[data-type='taskList'] li > label {
margin-top: 0.15rem;
}
.tiptap ul[data-type='taskList'] li > div {
flex: 1;
}
/* Tabellen */
.tiptap table {
border-collapse: collapse;
width: 100%;
margin: 0 0 0.75rem;
overflow: hidden;
}
.tiptap th,
.tiptap td {
border: 1px solid var(--color-border);
padding: 0.4rem 0.6rem;
text-align: left;
vertical-align: top;
min-width: 4rem;
}
.tiptap th {
background: var(--color-surface-2);
font-weight: 600;
}
.tiptap .selectedCell {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+95
View File
@@ -0,0 +1,95 @@
import { useMemo, useState, type CSSProperties } from 'react';
import { can } from '@d4rk-tablet/shared';
import { useAuth } from '../core/auth';
import { getBrand } from '../core/branding';
import { APPS } from '../apps/registry';
import { StatusBar } from './StatusBar';
import { cn } from '../ui/cn';
import { EmptyState } from '../ui/Card';
export function Shell() {
const user = useAuth((s) => s.user);
const roles = user?.roles ?? [];
const permissions = user?.permissions ?? [];
const brand = useMemo(() => getBrand(roles), [roles]);
const BrandIcon = brand.icon;
const apps = useMemo(
() => APPS.filter((app) => can(permissions, app.permission)),
[permissions],
);
const [activeId, setActiveId] = useState<string | null>(apps[0]?.id ?? null);
const active = apps.find((a) => a.id === activeId);
const ActiveComponent = active?.component;
// Akzentfarbe der Behörde in die CSS-Variablen des Shells injizieren
const accentStyle = {
'--color-primary': brand.accent,
'--color-primary-hover': brand.accentHover,
} as CSSProperties;
return (
<div style={accentStyle} className="flex h-full w-full flex-col bg-[var(--color-bg)]">
<StatusBar brand={brand} />
<div className="flex min-h-0 flex-1">
{/* Sidebar */}
<aside className="flex w-60 shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]">
{/* Branding-Header */}
<div className="flex flex-col items-center gap-2 border-b border-[var(--color-border)] px-4 py-5 text-center">
<div
className="flex h-14 w-14 items-center justify-center rounded-full"
style={{ background: `color-mix(in srgb, ${brand.accent} 18%, transparent)` }}
>
<BrandIcon size={28} className="text-[var(--color-primary)]" />
</div>
<div>
<p className="text-sm font-semibold leading-tight text-[var(--color-text)]">
{brand.name}
</p>
<p className="text-xs text-[var(--color-muted)]">{brand.subtitle}</p>
</div>
</div>
{/* Navigation */}
<nav className="flex flex-1 flex-col gap-1 p-2">
{apps.map((app) => {
const Icon = app.icon;
const isActive = app.id === activeId;
return (
<button
key={app.id}
onClick={() => setActiveId(app.id)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
isActive
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--color-muted)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text)]',
)}
>
<Icon size={18} />
{app.label}
</button>
);
})}
</nav>
<div className="border-t border-[var(--color-border)] px-4 py-3 text-xs text-[var(--color-muted)]">
<p className="truncate text-[var(--color-text)]">{user?.name}</p>
<p className="capitalize">{roles.join(' · ') || 'keine Rolle'}</p>
</div>
</aside>
{/* Content */}
<main className="min-w-0 flex-1 overflow-auto p-6">
{ActiveComponent ? (
<ActiveComponent />
) : (
<EmptyState title="Keine Berechtigung" hint="Für kein Modul freigeschaltet." />
)}
</main>
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
import { Wifi, BatteryMedium, LogOut } from 'lucide-react';
import { useAuth } from '../core/auth';
import type { Brand } from '../core/branding';
function useClock(): string {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
return now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
export function StatusBar({ brand }: { brand: Brand }) {
const time = useClock();
const user = useAuth((s) => s.user);
const logout = useAuth((s) => s.logout);
const Icon = brand.icon;
return (
<header className="flex h-10 items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Icon size={16} className="text-[var(--color-primary)]" />
<span className="font-medium text-[var(--color-text)]">{brand.name}</span>
</div>
<div className="flex items-center gap-4 text-[var(--color-muted)]">
<Wifi size={16} />
<BatteryMedium size={16} />
<span className="tabular-nums text-[var(--color-text)]">{time}</span>
<span className="text-[var(--color-border)]">|</span>
<span className="text-[var(--color-text)]">{user?.name}</span>
<button
onClick={logout}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[var(--color-muted)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-danger)]"
title="Ausloggen"
>
<LogOut size={16} />
</button>
</div>
</header>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { ButtonHTMLAttributes } from 'react';
import { cn } from './cn';
type Variant = 'primary' | 'ghost' | 'danger';
const VARIANTS: Record<Variant, string> = {
primary: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)]',
ghost:
'bg-[var(--color-surface-2)] text-[var(--color-text)] hover:bg-[var(--color-border)]',
danger: 'bg-[var(--color-danger)] text-white hover:opacity-90',
};
export function Button({
variant = 'primary',
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant }) {
return (
<button
className={cn(
'rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50',
VARIANTS[variant],
className,
)}
{...props}
/>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from 'react';
import { cn } from './cn';
export function Card({ children, className }: { children: ReactNode; className?: string }) {
return (
<div
className={cn(
'rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4',
className,
)}
>
{children}
</div>
);
}
export function EmptyState({ title, hint }: { title: string; hint?: string }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-[var(--color-muted)]">
<p className="text-lg font-medium text-[var(--color-text)]">{title}</p>
{hint && <p className="text-sm">{hint}</p>}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { InputHTMLAttributes } from 'react';
import { cn } from './cn';
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
'w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-sm text-[var(--color-text)] placeholder:text-[var(--color-muted)] outline-none focus:border-[var(--color-primary)]',
className,
)}
{...props}
/>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { useEffect } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Highlight from '@tiptap/extension-highlight';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Table from '@tiptap/extension-table';
import TableRow from '@tiptap/extension-table-row';
import TableHeader from '@tiptap/extension-table-header';
import TableCell from '@tiptap/extension-table-cell';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Placeholder from '@tiptap/extension-placeholder';
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Highlighter, Code,
Heading1, Heading2, Heading3, List, ListOrdered, ListChecks,
AlignLeft, AlignCenter, AlignRight, Link2, Image as ImageIcon, Table as TableIcon,
Quote, Minus, Undo, Redo,
} from 'lucide-react';
import { cn } from './cn';
const EXTENSIONS = [
StarterKit,
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Highlight,
Link.configure({ openOnClick: false, autolink: true }),
Image,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
TaskList,
TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: 'Text eingeben…' }),
];
function Btn({ onClick, active, title, children }: { onClick: () => void; active?: boolean; title: string; children: React.ReactNode }) {
return (
<button
type="button"
title={title}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={cn(
'rounded p-1.5 transition-colors',
active ? 'bg-[var(--color-primary)] text-white' : 'text-[var(--color-muted)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text)]',
)}
>
{children}
</button>
);
}
const Divider = () => <div className="mx-1 h-5 w-px bg-[var(--color-border)]" />;
function Toolbar({ editor }: { editor: Editor }) {
const setLink = () => {
const url = window.prompt('Link-URL', (editor.getAttributes('link').href as string) ?? 'https://');
if (url === null) return;
if (url === '') editor.chain().focus().extendMarkRange('link').unsetLink().run();
else editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
};
const addImage = () => {
const url = window.prompt('Bild-URL', 'https://');
if (url) editor.chain().focus().setImage({ src: url }).run();
};
return (
<div className="flex flex-wrap items-center gap-0.5 border-b border-[var(--color-border)] p-1">
<Btn title="Fett" onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')}><Bold size={16} /></Btn>
<Btn title="Kursiv" onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')}><Italic size={16} /></Btn>
<Btn title="Unterstrichen" onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')}><UnderlineIcon size={16} /></Btn>
<Btn title="Durchgestrichen" onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')}><Strikethrough size={16} /></Btn>
<Btn title="Hervorheben" onClick={() => editor.chain().focus().toggleHighlight().run()} active={editor.isActive('highlight')}><Highlighter size={16} /></Btn>
<Btn title="Code" onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive('code')}><Code size={16} /></Btn>
<Divider />
<Btn title="Überschrift 1" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })}><Heading1 size={16} /></Btn>
<Btn title="Überschrift 2" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })}><Heading2 size={16} /></Btn>
<Btn title="Überschrift 3" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })}><Heading3 size={16} /></Btn>
<Divider />
<Btn title="Aufzählung" onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')}><List size={16} /></Btn>
<Btn title="Nummeriert" onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')}><ListOrdered size={16} /></Btn>
<Btn title="Aufgabenliste" onClick={() => editor.chain().focus().toggleTaskList().run()} active={editor.isActive('taskList')}><ListChecks size={16} /></Btn>
<Btn title="Zitat" onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')}><Quote size={16} /></Btn>
<Divider />
<Btn title="Linksbündig" onClick={() => editor.chain().focus().setTextAlign('left').run()} active={editor.isActive({ textAlign: 'left' })}><AlignLeft size={16} /></Btn>
<Btn title="Zentriert" onClick={() => editor.chain().focus().setTextAlign('center').run()} active={editor.isActive({ textAlign: 'center' })}><AlignCenter size={16} /></Btn>
<Btn title="Rechtsbündig" onClick={() => editor.chain().focus().setTextAlign('right').run()} active={editor.isActive({ textAlign: 'right' })}><AlignRight size={16} /></Btn>
<Divider />
<Btn title="Link" onClick={setLink} active={editor.isActive('link')}><Link2 size={16} /></Btn>
<Btn title="Bild (URL)" onClick={addImage}><ImageIcon size={16} /></Btn>
<Btn title="Tabelle einfügen" onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}><TableIcon size={16} /></Btn>
<Btn title="Trennlinie" onClick={() => editor.chain().focus().setHorizontalRule().run()}><Minus size={16} /></Btn>
<Divider />
<Btn title="Rückgängig" onClick={() => editor.chain().focus().undo().run()}><Undo size={16} /></Btn>
<Btn title="Wiederholen" onClick={() => editor.chain().focus().redo().run()}><Redo size={16} /></Btn>
</div>
);
}
function TB({ onClick, danger, children }: { onClick: () => void; danger?: boolean; children: React.ReactNode }) {
return (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={cn(
'rounded px-1.5 py-0.5 transition-colors',
danger
? 'text-[var(--color-danger)] hover:bg-[var(--color-danger)]/15'
: 'text-[var(--color-muted)] hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]',
)}
>
{children}
</button>
);
}
/** Kontextuelle Tabellen-Steuerung — erscheint, wenn der Cursor in einer Tabelle steht. */
function TableControls({ editor }: { editor: Editor }) {
const c = () => editor.chain().focus();
return (
<div className="flex flex-wrap items-center gap-0.5 border-b border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-1 text-xs">
<span className="pr-1 font-medium text-[var(--color-muted)]">Tabelle</span>
<TB onClick={() => c().addColumnBefore().run()}>+Spalte links</TB>
<TB onClick={() => c().addColumnAfter().run()}>+Spalte rechts</TB>
<TB onClick={() => c().deleteColumn().run()}>Spalte</TB>
<span className="mx-1 h-4 w-px bg-[var(--color-border)]" />
<TB onClick={() => c().addRowBefore().run()}>+Zeile oben</TB>
<TB onClick={() => c().addRowAfter().run()}>+Zeile unten</TB>
<TB onClick={() => c().deleteRow().run()}>Zeile</TB>
<span className="mx-1 h-4 w-px bg-[var(--color-border)]" />
<TB onClick={() => c().toggleHeaderRow().run()}>Kopfzeile</TB>
<TB onClick={() => c().mergeOrSplit().run()}>Zellen verbinden/teilen</TB>
<TB danger onClick={() => c().deleteTable().run()}>Tabelle löschen</TB>
</div>
);
}
export function RichTextEditor({
content,
editable = true,
onChange,
}: {
content: string;
editable?: boolean;
onChange?: (html: string) => void;
}) {
const editor = useEditor({
extensions: EXTENSIONS,
content,
editable,
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
editorProps: { attributes: { class: 'tiptap min-h-40 px-3 py-2 outline-none' } },
});
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content, editor]);
useEffect(() => {
editor?.setEditable(editable);
}, [editable, editor]);
if (!editor) return null;
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
{editable && <Toolbar editor={editor} />}
{editable && editor.isActive('table') && <TableControls editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { clsx, type ClassValue } from 'clsx';
export function cn(...inputs: ClassValue[]): string {
return clsx(inputs);
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_SOCKET_URL: string;
readonly VITE_MAP_TILES_URL: string;
readonly VITE_MAP_STYLE: string;
readonly VITE_DEV_AUTH_BYPASS: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** Nur im FiveM-NUI-Kontext vorhanden. */
declare function GetParentResourceName(): string;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": [],
"noEmit": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"types": ["node"],
"noEmit": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
// .env liegt im Monorepo-Root (gemeinsam mit dem Backend)
envDir: '../../',
server: {
port: 5200,
strictPort: false,
host: true,
},
// Relative Base, damit die gebauten Assets auch im FiveM-NUI-iframe laden
base: './',
build: {
outDir: 'dist',
sourcemap: true,
},
});
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'drizzle-kit';
import { env } from './src/config/env';
/**
* WICHTIG: Nur mdt_*-Tabellen werden migriert.
* Die QBox-Tabellen (players, player_vehicles) werden per tablesFilter ausgeschlossen —
* Drizzle darf sie niemals anfassen.
*/
export default defineConfig({
dialect: 'mysql',
schema: './src/db/schema/mdt.ts',
out: './drizzle',
dbCredentials: {
host: env.DB_HOST,
port: env.DB_PORT,
user: env.DB_USER,
password: env.DB_PASSWORD,
database: env.DB_NAME,
},
tablesFilter: ['mdt_*'],
});
@@ -0,0 +1,121 @@
CREATE TABLE `mdt_audit_log` (
`id` int AUTO_INCREMENT NOT NULL,
`actor_citizenid` varchar(50),
`actor_name` varchar(255),
`action` varchar(64) NOT NULL,
`target_type` varchar(32),
`target_id` varchar(64),
`meta` json,
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `mdt_audit_log_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_bolos` (
`id` int AUTO_INCREMENT NOT NULL,
`kind` enum('person','vehicle') NOT NULL,
`reference` varchar(64) NOT NULL,
`label` varchar(255) NOT NULL,
`reason` text,
`active` boolean NOT NULL DEFAULT true,
`created_by_citizenid` varchar(50),
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `mdt_bolos_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_case_charges` (
`id` int AUTO_INCREMENT NOT NULL,
`case_id` int NOT NULL,
`catalog_id` int NOT NULL,
`count` int NOT NULL DEFAULT 1,
`fine` int NOT NULL DEFAULT 0,
`jail_time` int NOT NULL DEFAULT 0,
`points` int NOT NULL DEFAULT 0,
CONSTRAINT `mdt_case_charges_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_cases` (
`id` int AUTO_INCREMENT NOT NULL,
`title` varchar(255) NOT NULL,
`suspect_citizenid` varchar(50) NOT NULL,
`status` enum('open','closed','dismissed') NOT NULL DEFAULT 'open',
`narrative` text,
`total_fine` int NOT NULL DEFAULT 0,
`total_jail_time` int NOT NULL DEFAULT 0,
`officer_citizenid` varchar(50),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_cases_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_charges_catalog` (
`id` int AUTO_INCREMENT NOT NULL,
`code` varchar(32) NOT NULL,
`title` varchar(255) NOT NULL,
`category` varchar(64) NOT NULL DEFAULT 'sonstiges',
`fine` int NOT NULL DEFAULT 0,
`jail_time` int NOT NULL DEFAULT 0,
`points` int NOT NULL DEFAULT 0,
`active` boolean NOT NULL DEFAULT true,
CONSTRAINT `mdt_charges_catalog_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_dispatch_calls` (
`id` int AUTO_INCREMENT NOT NULL,
`code` varchar(32) NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`department` enum('police','ems','fire') NOT NULL DEFAULT 'police',
`priority` enum('low','medium','high') NOT NULL DEFAULT 'medium',
`status` enum('pending','assigned','enroute','onscene','closed') NOT NULL DEFAULT 'pending',
`location` varchar(255),
`coords_x` varchar(32),
`coords_y` varchar(32),
`coords_z` varchar(32),
`caller_citizenid` varchar(50),
`assigned_officers` json NOT NULL DEFAULT ('[]'),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_dispatch_calls_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_person_records` (
`citizenid` varchar(50) NOT NULL,
`notes` text,
`flags` json NOT NULL DEFAULT ('[]'),
`is_wanted` boolean NOT NULL DEFAULT false,
`mugshot_url` varchar(512),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
`updated_by` varchar(50),
CONSTRAINT `mdt_person_records_citizenid` PRIMARY KEY(`citizenid`)
);
--> statement-breakpoint
CREATE TABLE `mdt_users` (
`id` int AUTO_INCREMENT NOT NULL,
`citizenid` varchar(50),
`discord_id` varchar(50),
`name` varchar(255) NOT NULL,
`callsign` varchar(32),
`roles` json NOT NULL DEFAULT ('[]'),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_users_id` PRIMARY KEY(`id`),
CONSTRAINT `mdt_users_citizenid_unique` UNIQUE(`citizenid`),
CONSTRAINT `mdt_users_discord_id_unique` UNIQUE(`discord_id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_warrants` (
`id` int AUTO_INCREMENT NOT NULL,
`citizenid` varchar(50) NOT NULL,
`reason` text NOT NULL,
`status` enum('active','served','revoked','expired') NOT NULL DEFAULT 'active',
`case_id` int,
`issued_by_citizenid` varchar(50),
`issued_at` timestamp NOT NULL DEFAULT (now()),
`expires_at` timestamp,
CONSTRAINT `mdt_warrants_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `audit_actor_idx` ON `mdt_audit_log` (`actor_citizenid`);--> statement-breakpoint
CREATE INDEX `audit_action_idx` ON `mdt_audit_log` (`action`);--> statement-breakpoint
CREATE INDEX `suspect_idx` ON `mdt_cases` (`suspect_citizenid`);--> statement-breakpoint
CREATE INDEX `warrant_citizen_idx` ON `mdt_warrants` (`citizenid`);
@@ -0,0 +1,12 @@
CREATE TABLE `mdt_ranks` (
`id` int AUTO_INCREMENT NOT NULL,
`department` enum('police','ems','fire') NOT NULL,
`grade` int NOT NULL,
`label` varchar(64) NOT NULL,
`permissions` json NOT NULL DEFAULT ('[]'),
CONSTRAINT `mdt_ranks_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `mdt_users` ADD `department` enum('police','ems','fire');--> statement-breakpoint
ALTER TABLE `mdt_users` ADD `grade` int;--> statement-breakpoint
CREATE INDEX `rank_dept_grade_idx` ON `mdt_ranks` (`department`,`grade`);
+2
View File
@@ -0,0 +1,2 @@
DROP INDEX `rank_dept_grade_idx` ON `mdt_ranks`;--> statement-breakpoint
ALTER TABLE `mdt_ranks` ADD CONSTRAINT `rank_dept_grade_idx` UNIQUE(`department`,`grade`);
@@ -0,0 +1,11 @@
CREATE TABLE `mdt_announcements` (
`id` int AUTO_INCREMENT NOT NULL,
`title` varchar(255) NOT NULL,
`body` text,
`pinned` boolean NOT NULL DEFAULT false,
`important` boolean NOT NULL DEFAULT false,
`author_citizenid` varchar(50),
`author_name` varchar(255),
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `mdt_announcements_id` PRIMARY KEY(`id`)
);
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE `mdt_doc_folders` (
`id` int AUTO_INCREMENT NOT NULL,
`name` varchar(128) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `mdt_doc_folders_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_documents` (
`id` int AUTO_INCREMENT NOT NULL,
`reference` varchar(32) NOT NULL,
`title` varchar(255) NOT NULL,
`content` text,
`folder_id` int,
`pinned` boolean NOT NULL DEFAULT false,
`author_citizenid` varchar(50),
`author_name` varchar(255),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_documents_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `doc_folder_idx` ON `mdt_documents` (`folder_id`);
@@ -0,0 +1,2 @@
ALTER TABLE `mdt_doc_folders` ADD `departments` json DEFAULT ('[]') NOT NULL;--> statement-breakpoint
ALTER TABLE `mdt_documents` ADD `departments` json DEFAULT ('[]') NOT NULL;
@@ -0,0 +1,12 @@
CREATE TABLE `mdt_authorities` (
`id` int AUTO_INCREMENT NOT NULL,
`key` varchar(32) NOT NULL,
`name` varchar(128) NOT NULL,
`color` varchar(16) NOT NULL DEFAULT '#2f6df6',
`department` enum('police','ems','fire') NOT NULL,
`jobs` json NOT NULL DEFAULT ('[]'),
CONSTRAINT `mdt_authorities_id` PRIMARY KEY(`id`),
CONSTRAINT `mdt_authorities_key_unique` UNIQUE(`key`)
);
--> statement-breakpoint
ALTER TABLE `mdt_users` ADD `job` varchar(64);
+18
View File
@@ -0,0 +1,18 @@
CREATE TABLE `mdt_group_members` (
`id` int AUTO_INCREMENT NOT NULL,
`group_id` int NOT NULL,
`citizenid` varchar(50) NOT NULL,
`name` varchar(255) NOT NULL,
CONSTRAINT `mdt_group_members_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_groups` (
`id` int AUTO_INCREMENT NOT NULL,
`name` varchar(128) NOT NULL,
`color` varchar(16) NOT NULL DEFAULT '#8b5cf6',
`created_at` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `mdt_groups_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `group_member_group_idx` ON `mdt_group_members` (`group_id`);--> statement-breakpoint
CREATE INDEX `group_member_citizen_idx` ON `mdt_group_members` (`citizenid`);
@@ -0,0 +1,49 @@
CREATE TABLE `mdt_citizens` (
`citizenid` varchar(50) NOT NULL,
`firstname` varchar(128) NOT NULL,
`lastname` varchar(128) NOT NULL,
`dob` varchar(32),
`gender` enum('male','female','divers','unknown') NOT NULL DEFAULT 'unknown',
`phone` varchar(32),
`nationality` varchar(64),
`legal_status` enum('citizen','resident','visitor','illegal','unknown') NOT NULL DEFAULT 'citizen',
`address` varchar(255),
`occupation` varchar(128),
`height` varchar(32),
`eye_color` varchar(32),
`hair_color` varchar(32),
`distinguishing_marks` text,
`aliases` json NOT NULL DEFAULT ('[]'),
`licenses` json NOT NULL DEFAULT ('[]'),
`mugshot_url` varchar(512),
`notes` text,
`flags` json NOT NULL DEFAULT ('[]'),
`is_wanted` boolean NOT NULL DEFAULT false,
`created_by_citizenid` varchar(50),
`created_by_name` varchar(255),
`updated_by` varchar(50),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_citizens_citizenid` PRIMARY KEY(`citizenid`)
);
--> statement-breakpoint
CREATE TABLE `mdt_vehicles` (
`id` int AUTO_INCREMENT NOT NULL,
`plate` varchar(32) NOT NULL,
`model` varchar(64),
`color` varchar(64),
`owner_citizenid` varchar(50),
`plate_status` enum('registered','forged','stolen','unknown') NOT NULL DEFAULT 'registered',
`is_stolen` boolean NOT NULL DEFAULT false,
`has_bolo` boolean NOT NULL DEFAULT false,
`notes` text,
`created_by_citizenid` varchar(50),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_vehicles_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `citizen_name_idx` ON `mdt_citizens` (`lastname`,`firstname`);--> statement-breakpoint
CREATE INDEX `citizen_wanted_idx` ON `mdt_citizens` (`is_wanted`);--> statement-breakpoint
CREATE INDEX `vehicle_plate_idx` ON `mdt_vehicles` (`plate`);--> statement-breakpoint
CREATE INDEX `vehicle_owner_idx` ON `mdt_vehicles` (`owner_citizenid`);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `mdt_citizens` ADD `public` boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE `mdt_citizens` ADD `acl` json DEFAULT ('[]') NOT NULL;
@@ -0,0 +1,16 @@
CREATE TABLE `mdt_treatment_records` (
`id` int AUTO_INCREMENT NOT NULL,
`reference` varchar(32) NOT NULL,
`patient_citizenid` varchar(50) NOT NULL,
`title` varchar(255) NOT NULL,
`diagnosis` text,
`treatment` text,
`status` enum('open','closed') NOT NULL DEFAULT 'open',
`author_citizenid` varchar(50),
`author_name` varchar(255),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_treatment_records_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `treatment_patient_idx` ON `mdt_treatment_records` (`patient_citizenid`);
+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE `mdt_laws` (
`id` int AUTO_INCREMENT NOT NULL,
`category` varchar(64) NOT NULL DEFAULT 'Allgemein',
`paragraph` varchar(32) NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`fine` int,
`jail_time` int,
`sort_order` int NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_laws_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `law_category_idx` ON `mdt_laws` (`category`);
+30
View File
@@ -0,0 +1,30 @@
CREATE TABLE `mdt_calendar_events` (
`id` int AUTO_INCREMENT NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`category` enum('dienst','schulung','meeting','event','sonstiges') NOT NULL DEFAULT 'sonstiges',
`start_at` datetime NOT NULL,
`end_at` datetime,
`all_day` boolean NOT NULL DEFAULT false,
`location` varchar(255),
`open_signup` boolean NOT NULL DEFAULT true,
`organizer_citizenid` varchar(50),
`organizer_name` varchar(255),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `mdt_calendar_events_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `mdt_calendar_attendees` (
`id` int AUTO_INCREMENT NOT NULL,
`event_id` int NOT NULL,
`citizenid` varchar(50) NOT NULL,
`name` varchar(255) NOT NULL,
`status` enum('invited','accepted','declined','maybe') NOT NULL DEFAULT 'invited',
`self` boolean NOT NULL DEFAULT false,
CONSTRAINT `mdt_calendar_attendees_id` PRIMARY KEY(`id`),
CONSTRAINT `attendee_event_citizen_idx` UNIQUE(`event_id`,`citizenid`)
);
--> statement-breakpoint
CREATE INDEX `event_start_idx` ON `mdt_calendar_events` (`start_at`);--> statement-breakpoint
CREATE INDEX `attendee_event_idx` ON `mdt_calendar_attendees` (`event_id`);
+803
View File
@@ -0,0 +1,803 @@
{
"version": "5",
"dialect": "mysql",
"id": "5406bac0-8a25-40d0-85fe-e0aaae21e670",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"mdt_audit_log": {
"name": "mdt_audit_log",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"actor_citizenid": {
"name": "actor_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actor_name": {
"name": "actor_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_type": {
"name": "target_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta": {
"name": "meta",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"audit_actor_idx": {
"name": "audit_actor_idx",
"columns": [
"actor_citizenid"
],
"isUnique": false
},
"audit_action_idx": {
"name": "audit_action_idx",
"columns": [
"action"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_audit_log_id": {
"name": "mdt_audit_log_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_bolos": {
"name": "mdt_bolos",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"kind": {
"name": "kind",
"type": "enum('person','vehicle')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_by_citizenid": {
"name": "created_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_bolos_id": {
"name": "mdt_bolos_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_case_charges": {
"name": "mdt_case_charges",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"catalog_id": {
"name": "catalog_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_case_charges_id": {
"name": "mdt_case_charges_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_cases": {
"name": "mdt_cases",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"suspect_citizenid": {
"name": "suspect_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('open','closed','dismissed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"narrative": {
"name": "narrative",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_fine": {
"name": "total_fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_jail_time": {
"name": "total_jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"officer_citizenid": {
"name": "officer_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"suspect_idx": {
"name": "suspect_idx",
"columns": [
"suspect_citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_cases_id": {
"name": "mdt_cases_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_charges_catalog": {
"name": "mdt_charges_catalog",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'sonstiges'"
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_charges_catalog_id": {
"name": "mdt_charges_catalog_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_dispatch_calls": {
"name": "mdt_dispatch_calls",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'police'"
},
"priority": {
"name": "priority",
"type": "enum('low','medium','high')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"status": {
"name": "status",
"type": "enum('pending','assigned','enroute','onscene','closed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"location": {
"name": "location",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_x": {
"name": "coords_x",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_y": {
"name": "coords_y",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_z": {
"name": "coords_z",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caller_citizenid": {
"name": "caller_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"assigned_officers": {
"name": "assigned_officers",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_dispatch_calls_id": {
"name": "mdt_dispatch_calls_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_person_records": {
"name": "mdt_person_records",
"columns": {
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"flags": {
"name": "flags",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"is_wanted": {
"name": "is_wanted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"mugshot_url": {
"name": "mugshot_url",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"updated_by": {
"name": "updated_by",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_person_records_citizenid": {
"name": "mdt_person_records_citizenid",
"columns": [
"citizenid"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_users": {
"name": "mdt_users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"roles": {
"name": "roles",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_users_id": {
"name": "mdt_users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"mdt_users_citizenid_unique": {
"name": "mdt_users_citizenid_unique",
"columns": [
"citizenid"
]
},
"mdt_users_discord_id_unique": {
"name": "mdt_users_discord_id_unique",
"columns": [
"discord_id"
]
}
},
"checkConstraint": {}
},
"mdt_warrants": {
"name": "mdt_warrants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('active','served','revoked','expired')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_by_citizenid": {
"name": "issued_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_at": {
"name": "issued_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"warrant_citizen_idx": {
"name": "warrant_citizen_idx",
"columns": [
"citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_warrants_id": {
"name": "mdt_warrants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
+879
View File
@@ -0,0 +1,879 @@
{
"version": "5",
"dialect": "mysql",
"id": "75763478-717d-4d51-9a00-3b0239524f59",
"prevId": "5406bac0-8a25-40d0-85fe-e0aaae21e670",
"tables": {
"mdt_audit_log": {
"name": "mdt_audit_log",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"actor_citizenid": {
"name": "actor_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actor_name": {
"name": "actor_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_type": {
"name": "target_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta": {
"name": "meta",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"audit_actor_idx": {
"name": "audit_actor_idx",
"columns": [
"actor_citizenid"
],
"isUnique": false
},
"audit_action_idx": {
"name": "audit_action_idx",
"columns": [
"action"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_audit_log_id": {
"name": "mdt_audit_log_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_bolos": {
"name": "mdt_bolos",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"kind": {
"name": "kind",
"type": "enum('person','vehicle')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_by_citizenid": {
"name": "created_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_bolos_id": {
"name": "mdt_bolos_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_case_charges": {
"name": "mdt_case_charges",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"catalog_id": {
"name": "catalog_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_case_charges_id": {
"name": "mdt_case_charges_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_cases": {
"name": "mdt_cases",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"suspect_citizenid": {
"name": "suspect_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('open','closed','dismissed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"narrative": {
"name": "narrative",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_fine": {
"name": "total_fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_jail_time": {
"name": "total_jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"officer_citizenid": {
"name": "officer_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"suspect_idx": {
"name": "suspect_idx",
"columns": [
"suspect_citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_cases_id": {
"name": "mdt_cases_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_charges_catalog": {
"name": "mdt_charges_catalog",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'sonstiges'"
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_charges_catalog_id": {
"name": "mdt_charges_catalog_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_dispatch_calls": {
"name": "mdt_dispatch_calls",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'police'"
},
"priority": {
"name": "priority",
"type": "enum('low','medium','high')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"status": {
"name": "status",
"type": "enum('pending','assigned','enroute','onscene','closed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"location": {
"name": "location",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_x": {
"name": "coords_x",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_y": {
"name": "coords_y",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_z": {
"name": "coords_z",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caller_citizenid": {
"name": "caller_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"assigned_officers": {
"name": "assigned_officers",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_dispatch_calls_id": {
"name": "mdt_dispatch_calls_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_person_records": {
"name": "mdt_person_records",
"columns": {
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"flags": {
"name": "flags",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"is_wanted": {
"name": "is_wanted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"mugshot_url": {
"name": "mugshot_url",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"updated_by": {
"name": "updated_by",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_person_records_citizenid": {
"name": "mdt_person_records_citizenid",
"columns": [
"citizenid"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_ranks": {
"name": "mdt_ranks",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permissions": {
"name": "permissions",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
}
},
"indexes": {
"rank_dept_grade_idx": {
"name": "rank_dept_grade_idx",
"columns": [
"department",
"grade"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_ranks_id": {
"name": "mdt_ranks_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_users": {
"name": "mdt_users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"roles": {
"name": "roles",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_users_id": {
"name": "mdt_users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"mdt_users_citizenid_unique": {
"name": "mdt_users_citizenid_unique",
"columns": [
"citizenid"
]
},
"mdt_users_discord_id_unique": {
"name": "mdt_users_discord_id_unique",
"columns": [
"discord_id"
]
}
},
"checkConstraint": {}
},
"mdt_warrants": {
"name": "mdt_warrants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('active','served','revoked','expired')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_by_citizenid": {
"name": "issued_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_at": {
"name": "issued_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"warrant_citizen_idx": {
"name": "warrant_citizen_idx",
"columns": [
"citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_warrants_id": {
"name": "mdt_warrants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
+879
View File
@@ -0,0 +1,879 @@
{
"version": "5",
"dialect": "mysql",
"id": "31cbfdaa-9652-4c1c-b4eb-2e11fd1d60b4",
"prevId": "75763478-717d-4d51-9a00-3b0239524f59",
"tables": {
"mdt_audit_log": {
"name": "mdt_audit_log",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"actor_citizenid": {
"name": "actor_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actor_name": {
"name": "actor_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_type": {
"name": "target_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta": {
"name": "meta",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"audit_actor_idx": {
"name": "audit_actor_idx",
"columns": [
"actor_citizenid"
],
"isUnique": false
},
"audit_action_idx": {
"name": "audit_action_idx",
"columns": [
"action"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_audit_log_id": {
"name": "mdt_audit_log_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_bolos": {
"name": "mdt_bolos",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"kind": {
"name": "kind",
"type": "enum('person','vehicle')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_by_citizenid": {
"name": "created_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_bolos_id": {
"name": "mdt_bolos_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_case_charges": {
"name": "mdt_case_charges",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"catalog_id": {
"name": "catalog_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_case_charges_id": {
"name": "mdt_case_charges_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_cases": {
"name": "mdt_cases",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"suspect_citizenid": {
"name": "suspect_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('open','closed','dismissed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"narrative": {
"name": "narrative",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_fine": {
"name": "total_fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_jail_time": {
"name": "total_jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"officer_citizenid": {
"name": "officer_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"suspect_idx": {
"name": "suspect_idx",
"columns": [
"suspect_citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_cases_id": {
"name": "mdt_cases_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_charges_catalog": {
"name": "mdt_charges_catalog",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'sonstiges'"
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_charges_catalog_id": {
"name": "mdt_charges_catalog_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_dispatch_calls": {
"name": "mdt_dispatch_calls",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'police'"
},
"priority": {
"name": "priority",
"type": "enum('low','medium','high')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"status": {
"name": "status",
"type": "enum('pending','assigned','enroute','onscene','closed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"location": {
"name": "location",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_x": {
"name": "coords_x",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_y": {
"name": "coords_y",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_z": {
"name": "coords_z",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caller_citizenid": {
"name": "caller_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"assigned_officers": {
"name": "assigned_officers",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_dispatch_calls_id": {
"name": "mdt_dispatch_calls_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_person_records": {
"name": "mdt_person_records",
"columns": {
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"flags": {
"name": "flags",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"is_wanted": {
"name": "is_wanted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"mugshot_url": {
"name": "mugshot_url",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"updated_by": {
"name": "updated_by",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_person_records_citizenid": {
"name": "mdt_person_records_citizenid",
"columns": [
"citizenid"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_ranks": {
"name": "mdt_ranks",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permissions": {
"name": "permissions",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
}
},
"indexes": {
"rank_dept_grade_idx": {
"name": "rank_dept_grade_idx",
"columns": [
"department",
"grade"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_ranks_id": {
"name": "mdt_ranks_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_users": {
"name": "mdt_users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"roles": {
"name": "roles",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_users_id": {
"name": "mdt_users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"mdt_users_citizenid_unique": {
"name": "mdt_users_citizenid_unique",
"columns": [
"citizenid"
]
},
"mdt_users_discord_id_unique": {
"name": "mdt_users_discord_id_unique",
"columns": [
"discord_id"
]
}
},
"checkConstraint": {}
},
"mdt_warrants": {
"name": "mdt_warrants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('active','served','revoked','expired')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_by_citizenid": {
"name": "issued_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_at": {
"name": "issued_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"warrant_citizen_idx": {
"name": "warrant_citizen_idx",
"columns": [
"citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_warrants_id": {
"name": "mdt_warrants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
+955
View File
@@ -0,0 +1,955 @@
{
"version": "5",
"dialect": "mysql",
"id": "21dfb804-392a-435c-804b-f5bc613ea2b2",
"prevId": "31cbfdaa-9652-4c1c-b4eb-2e11fd1d60b4",
"tables": {
"mdt_announcements": {
"name": "mdt_announcements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pinned": {
"name": "pinned",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"important": {
"name": "important",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"author_citizenid": {
"name": "author_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_name": {
"name": "author_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_announcements_id": {
"name": "mdt_announcements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_audit_log": {
"name": "mdt_audit_log",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"actor_citizenid": {
"name": "actor_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actor_name": {
"name": "actor_name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"action": {
"name": "action",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_type": {
"name": "target_type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta": {
"name": "meta",
"type": "json",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {
"audit_actor_idx": {
"name": "audit_actor_idx",
"columns": [
"actor_citizenid"
],
"isUnique": false
},
"audit_action_idx": {
"name": "audit_action_idx",
"columns": [
"action"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_audit_log_id": {
"name": "mdt_audit_log_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_bolos": {
"name": "mdt_bolos",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"kind": {
"name": "kind",
"type": "enum('person','vehicle')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reference": {
"name": "reference",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_by_citizenid": {
"name": "created_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_bolos_id": {
"name": "mdt_bolos_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_case_charges": {
"name": "mdt_case_charges",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"catalog_id": {
"name": "catalog_id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_case_charges_id": {
"name": "mdt_case_charges_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_cases": {
"name": "mdt_cases",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"suspect_citizenid": {
"name": "suspect_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('open','closed','dismissed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"narrative": {
"name": "narrative",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_fine": {
"name": "total_fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"total_jail_time": {
"name": "total_jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"officer_citizenid": {
"name": "officer_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {
"suspect_idx": {
"name": "suspect_idx",
"columns": [
"suspect_citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_cases_id": {
"name": "mdt_cases_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_charges_catalog": {
"name": "mdt_charges_catalog",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'sonstiges'"
},
"fine": {
"name": "fine",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"jail_time": {
"name": "jail_time",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"points": {
"name": "points",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_charges_catalog_id": {
"name": "mdt_charges_catalog_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_dispatch_calls": {
"name": "mdt_dispatch_calls",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'police'"
},
"priority": {
"name": "priority",
"type": "enum('low','medium','high')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"status": {
"name": "status",
"type": "enum('pending','assigned','enroute','onscene','closed')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"location": {
"name": "location",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_x": {
"name": "coords_x",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_y": {
"name": "coords_y",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"coords_z": {
"name": "coords_z",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caller_citizenid": {
"name": "caller_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"assigned_officers": {
"name": "assigned_officers",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_dispatch_calls_id": {
"name": "mdt_dispatch_calls_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_person_records": {
"name": "mdt_person_records",
"columns": {
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"flags": {
"name": "flags",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"is_wanted": {
"name": "is_wanted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"mugshot_url": {
"name": "mugshot_url",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"updated_by": {
"name": "updated_by",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_person_records_citizenid": {
"name": "mdt_person_records_citizenid",
"columns": [
"citizenid"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_ranks": {
"name": "mdt_ranks",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permissions": {
"name": "permissions",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
}
},
"indexes": {
"rank_dept_grade_idx": {
"name": "rank_dept_grade_idx",
"columns": [
"department",
"grade"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_ranks_id": {
"name": "mdt_ranks_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"mdt_users": {
"name": "mdt_users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"discord_id": {
"name": "discord_id",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"callsign": {
"name": "callsign",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"department": {
"name": "department",
"type": "enum('police','ems','fire')",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"grade": {
"name": "grade",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"roles": {
"name": "roles",
"type": "json",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('[]')"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_users_id": {
"name": "mdt_users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"mdt_users_citizenid_unique": {
"name": "mdt_users_citizenid_unique",
"columns": [
"citizenid"
]
},
"mdt_users_discord_id_unique": {
"name": "mdt_users_discord_id_unique",
"columns": [
"discord_id"
]
}
},
"checkConstraint": {}
},
"mdt_warrants": {
"name": "mdt_warrants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"citizenid": {
"name": "citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('active','served','revoked','expired')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"case_id": {
"name": "case_id",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_by_citizenid": {
"name": "issued_by_citizenid",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"issued_at": {
"name": "issued_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"warrant_citizen_idx": {
"name": "warrant_citizen_idx",
"columns": [
"citizenid"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"mdt_warrants_id": {
"name": "mdt_warrants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1783309072685,
"tag": "0000_classy_speedball",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1783326830033,
"tag": "0001_whole_rafael_vega",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1783326982981,
"tag": "0002_curly_chat",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1783350650336,
"tag": "0003_breezy_rick_jones",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1783368008674,
"tag": "0004_past_shiva",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1783369524417,
"tag": "0005_crazy_quentin_quire",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1783370360413,
"tag": "0006_watery_sphinx",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1783385686508,
"tag": "0007_first_kabuki",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1783472000000,
"tag": "0008_standalone_citizens",
"breakpoints": true
},
{
"idx": 9,
"version": "5",
"when": 1783472500000,
"tag": "0009_citizen_acl",
"breakpoints": true
},
{
"idx": 10,
"version": "5",
"when": 1783473000000,
"tag": "0010_treatment_records",
"breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1783473500000,
"tag": "0011_laws",
"breakpoints": true
},
{
"idx": 12,
"version": "5",
"when": 1783474000000,
"tag": "0012_calendar",
"breakpoints": true
}
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@d4rk-tablet/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/server.ts",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "tsx src/server.ts",
"typecheck": "tsc",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:introspect": "drizzle-kit introspect",
"db:setup:dev": "tsx src/db/setup-dev.ts",
"test": "vitest run"
},
"dependencies": {
"@d4rk-tablet/shared": "workspace:*",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.0.1",
"@fastify/jwt": "^9.0.1",
"@fastify/static": "^8.0.3",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.2",
"fastify": "^5.2.0",
"mysql2": "^3.11.5",
"socket.io": "^4.8.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^20.17.10",
"drizzle-kit": "^0.30.1",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
}
}
+80
View File
@@ -0,0 +1,80 @@
import Fastify, { type FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { env, isDev } from './config/env';
import { registerAuth } from './plugins/auth';
import { healthRoutes } from './modules/health/health.route';
import { authRoutes } from './modules/auth/auth.route';
import { personsRoutes } from './modules/persons/persons.route';
import { vehiclesRoutes } from './modules/vehicles/vehicles.route';
import { casesRoutes } from './modules/cases/cases.route';
import { treatmentRoutes } from './modules/treatment/treatment.route';
import { lawsRoutes } from './modules/laws/laws.route';
import { calendarRoutes } from './modules/calendar/calendar.route';
import { dispatchRoutes } from './modules/dispatch/dispatch.route';
import { bridgeRoutes } from './modules/bridge/bridge.route';
import { adminRoutes } from './modules/admin/admin.route';
import { boardRoutes } from './modules/board/board.route';
import { documentsRoutes } from './modules/documents/documents.route';
import { authoritiesRoutes } from './modules/authorities/authorities.route';
import { ensureAuthoritiesSeeded } from './modules/authorities/authorities.service';
import { groupsRoutes } from './modules/groups/groups.route';
import { initSocket } from './realtime/socket';
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
logger: isDev
? { transport: undefined, level: 'debug' }
: { level: 'info' },
});
await app.register(cors, {
origin: env.CORS_ORIGINS,
credentials: true,
});
// GTA-Map-Tiles statisch servieren (öffentlich, kein Auth) — /tiles/{style}/{z}/{x}/{y}.jpg
const tilesRoot = resolve(process.cwd(), env.TILES_DIR);
if (existsSync(tilesRoot)) {
await app.register(fastifyStatic, {
root: tilesRoot,
prefix: '/tiles/',
decorateReply: false,
cacheControl: true,
maxAge: 7 * 24 * 60 * 60 * 1000,
});
app.log.info(`Map-Tiles: ${tilesRoot} → /tiles/`);
} else {
app.log.warn(`TILES_DIR ${tilesRoot} nicht gefunden — /tiles/ inaktiv`);
}
// JWT + Cookie + RBAC-Guards (app-weite Decorators)
await registerAuth(app);
// ── Module ──
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(personsRoutes);
await app.register(vehiclesRoutes);
await app.register(casesRoutes);
await app.register(treatmentRoutes);
await app.register(lawsRoutes);
await app.register(calendarRoutes);
await app.register(dispatchRoutes);
await app.register(bridgeRoutes);
await app.register(adminRoutes);
await app.register(boardRoutes);
await app.register(documentsRoutes);
await app.register(authoritiesRoutes);
await app.register(groupsRoutes);
// Behörden-Registry beim Start seeden (falls leer)
await ensureAuthoritiesSeeded().catch((err) => app.log.warn({ err }, 'Behörden-Seed übersprungen'));
// Realtime (Socket.io) an den HTTP-Server hängen
initSocket(app);
return app;
}
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { defaultPermissionsFor } from '../rankDefaults';
describe('defaultPermissionsFor', () => {
it('gibt die Rechte des exakten Grades', () => {
expect(defaultPermissionsFor('police', 0)).toEqual([
'mdt.board.view',
'mdt.persons.view',
'mdt.vehicles.view',
'mdt.cases.view',
'mdt.laws.view',
'mdt.calendar.view',
'mdt.documents.view',
'cad.dispatch.view',
]);
});
it('nimmt den höchsten definierten Rang ≤ grade (Grade über Maximum)', () => {
// police max default = grade 4; grade 9 → grade-4-Rechte
const g9 = defaultPermissionsFor('police', 9);
expect(g9).toContain('mdt.warrants.create');
expect(g9).toContain('mdt.charges.manage');
});
it('höhere Grade haben mehr Rechte als niedrigere', () => {
expect(defaultPermissionsFor('police', 3).length).toBeGreaterThan(
defaultPermissionsFor('police', 0).length,
);
});
it('unbekannt niedriger Grade fällt auf niedrigsten Rang zurück bzw. leer', () => {
// grade -1 gibt es nicht → kein Rang ≤ -1 → leer
expect(defaultPermissionsFor('police', -1)).toEqual([]);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { config as loadDotenv } from 'dotenv';
import { z } from 'zod';
import { resolve } from 'node:path';
// .env liegt im Monorepo-Root
loadDotenv({ path: resolve(process.cwd(), '../../.env') });
loadDotenv(); // Fallback: lokale .env
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
API_PORT: z.coerce.number().int().default(3000),
API_HOST: z.string().default('0.0.0.0'),
CORS_ORIGINS: z
.string()
.default('http://localhost:5173')
.transform((s) => s.split(',').map((o) => o.trim()).filter(Boolean)),
DB_HOST: z.string().default('127.0.0.1'),
DB_PORT: z.coerce.number().int().default(3306),
DB_USER: z.string().default('root'),
DB_PASSWORD: z.string().default(''),
DB_NAME: z.string().default('qbox'),
DB_QBOX_PLAYERS_TABLE: z.string().default('players'),
DB_QBOX_VEHICLES_TABLE: z.string().default('player_vehicles'),
// Verzeichnis mit den GTA-Map-Tiles ({style}/{z}/{x}/{y}.jpg), served unter /tiles/
TILES_DIR: z.string().default('./tiles'),
JWT_SECRET: z.string().default('dev-insecure-jwt-secret'),
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
BRIDGE_HMAC_SECRET: z.string().default('dev-insecure-bridge-secret'),
DISCORD_CLIENT_ID: z.string().default(''),
DISCORD_CLIENT_SECRET: z.string().default(''),
DISCORD_REDIRECT_URI: z.string().default('http://localhost:3000/auth/discord/callback'),
});
export const env = envSchema.parse(process.env);
export type Env = typeof env;
export const isDev = env.NODE_ENV === 'development';
+18
View File
@@ -0,0 +1,18 @@
import type { Role } from '@d4rk-tablet/shared';
/** QBox job.name → Basis-Rolle im MDT. */
const JOB_ROLE_MAP: Record<string, Role> = {
police: 'police',
bcso: 'police',
sheriff: 'police',
ambulance: 'ems',
ems: 'ems',
fire: 'fire',
};
export function baseRoleForJob(job: string): Role | null {
return JOB_ROLE_MAP[job.toLowerCase()] ?? null;
}
/** Rollen, die NICHT aus dem Job kommen, sondern manuell in mdt_users vergeben werden. */
export const ELEVATED_ROLES: Role[] = ['admin', 'dispatch'];
+55
View File
@@ -0,0 +1,55 @@
import { ROLE_TEMPLATES, type Department, type Permission } from '@d4rk-tablet/shared';
export interface RankDefault {
grade: number;
label: string;
permissions: Permission[];
}
const OFFICER = ROLE_TEMPLATES.Officer!;
const SUPERVISOR = ROLE_TEMPLATES.Supervisor!;
const DISPATCH = ROLE_TEMPLATES.Dispatcher!;
// EMS-Sanitäter: Dispatch-Basis + Behandlungsakten + Gesetze
const EMS_MED: Permission[] = [
...DISPATCH,
'mdt.laws.view',
'mdt.calendar.view',
'mdt.treatment.view',
'mdt.treatment.create',
'mdt.treatment.edit',
];
/**
* Default-Rang-Matrix je Behörde (Code-Fallback, greift wenn mdt_ranks leer ist
* bzw. für Grades ohne DB-Eintrag). Über die Admin-UI überschreibbar.
*/
export const DEFAULT_RANKS: Record<Department, RankDefault[]> = {
police: [
{ grade: 0, label: 'Anwärter', permissions: ['mdt.board.view', 'mdt.persons.view', 'mdt.vehicles.view', 'mdt.cases.view', 'mdt.laws.view', 'mdt.calendar.view', 'mdt.documents.view', 'cad.dispatch.view'] },
{ grade: 1, label: 'Officer', permissions: OFFICER },
{ grade: 2, label: 'Senior Officer', permissions: [...OFFICER, 'cad.dispatch.manage', 'mdt.warrants.create'] },
{ grade: 3, label: 'Sergeant', permissions: SUPERVISOR },
{ grade: 4, label: 'Lieutenant', permissions: [...SUPERVISOR, 'mdt.charges.manage', 'admin.audit.view'] },
],
ems: [
{ grade: 0, label: 'Rettungshelfer', permissions: ['mdt.board.view', 'mdt.persons.view', 'mdt.vehicles.view', 'mdt.laws.view', 'mdt.calendar.view', 'mdt.treatment.view', 'mdt.documents.view', 'cad.dispatch.view'] },
{ grade: 1, label: 'Sanitäter', permissions: EMS_MED },
{ grade: 2, label: 'Notfallsanitäter', permissions: EMS_MED },
{ grade: 3, label: 'Ärztl. Leitung', permissions: [...EMS_MED, 'admin.audit.view'] },
],
fire: [
{ grade: 0, label: 'Anwärter', permissions: ['mdt.board.view', 'mdt.persons.view', 'mdt.vehicles.view', 'mdt.laws.view', 'mdt.calendar.view', 'mdt.documents.view', 'cad.dispatch.view'] },
{ grade: 1, label: 'Feuerwehrmann', permissions: [...DISPATCH, 'mdt.laws.view', 'mdt.calendar.view'] },
{ grade: 2, label: 'Zugführer', permissions: [...DISPATCH, 'mdt.laws.view', 'mdt.calendar.view', 'admin.audit.view'] },
],
};
/** Default-Permissions für (Department, Grade) — nimmt den höchsten definierten Rang ≤ grade. */
export function defaultPermissionsFor(department: Department, grade: number): Permission[] {
const ranks = DEFAULT_RANKS[department];
let best: RankDefault | null = null;
for (const r of ranks) {
if (r.grade <= grade && (!best || r.grade > best.grade)) best = r;
}
return best?.permissions ?? [];
}
+21
View File
@@ -0,0 +1,21 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import { env } from '../config/env';
import * as schema from './schema';
/**
* Verbindungspool zur bestehenden QBox-MariaDB.
* Wird sowohl für QBox-Reads als auch für mdt_*-Writes genutzt.
*/
export const pool = mysql.createPool({
host: env.DB_HOST,
port: env.DB_PORT,
user: env.DB_USER,
password: env.DB_PASSWORD,
database: env.DB_NAME,
connectionLimit: 10,
namedPlaceholders: true,
});
export const db = drizzle(pool, { schema, mode: 'default' });
export type Database = typeof db;
+2
View File
@@ -0,0 +1,2 @@
export * from './qbox';
export * from './mdt';
+433
View File
@@ -0,0 +1,433 @@
/**
* MDT-eigene Tabellen (mdt_*). Von Drizzle-Kit verwaltet/migriert.
* Liegen in derselben QBox-Datenbank.
*/
import {
mysqlTable,
varchar,
int,
boolean,
text,
timestamp,
datetime,
mysqlEnum,
index,
uniqueIndex,
customType,
} from 'drizzle-orm/mysql-core';
/**
* MariaDB's JSON-Typ ist intern LONGTEXT → der mysql2-Treiber parst ihn NICHT
* automatisch (anders als echtes MySQL). Dieser Custom-Type parst beim Lesen
* und serialisiert beim Schreiben. dataType bleibt 'json' → identisches DDL.
*/
function jsonType<T>() {
return customType<{ data: T; driverData: string }>({
dataType() {
return 'json';
},
toDriver(value: T): string {
return JSON.stringify(value);
},
fromDriver(value: string | T): T {
if (typeof value === 'string') {
try {
return JSON.parse(value) as T;
} catch {
return value as unknown as T;
}
}
return value;
},
});
}
const jsonStringArray = jsonType<string[]>();
const jsonRecord = jsonType<Record<string, unknown>>();
const jsonAcl = jsonType<import('@d4rk-tablet/shared').AclEntry[]>();
const jsonLicenses = jsonType<import('@d4rk-tablet/shared').License[]>();
// ── Users & RBAC ──
export const mdtUsers = mysqlTable('mdt_users', {
id: int('id').autoincrement().primaryKey(),
citizenid: varchar('citizenid', { length: 50 }).unique(),
discordId: varchar('discord_id', { length: 50 }).unique(),
name: varchar('name', { length: 255 }).notNull(),
callsign: varchar('callsign', { length: 32 }),
// Letzter bekannter Behörden-Rang (aus QBox-Job), für Discord-Sessions + Anzeige
department: mysqlEnum('department', ['police', 'ems', 'fire']),
grade: int('grade'),
job: varchar('job', { length: 64 }), // roher QBox-Job (für Behörden-Zuordnung)
// MDT-Override-Rollen (admin/dispatch) — zusätzlich zum Rang
roles: jsonStringArray('roles').notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
});
// ── Gruppen / Teams ──
export const mdtGroups = mysqlTable('mdt_groups', {
id: int('id').autoincrement().primaryKey(),
name: varchar('name', { length: 128 }).notNull(),
color: varchar('color', { length: 16 }).notNull().default('#8b5cf6'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const mdtGroupMembers = mysqlTable(
'mdt_group_members',
{
id: int('id').autoincrement().primaryKey(),
groupId: int('group_id').notNull(),
citizenid: varchar('citizenid', { length: 50 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
},
(t) => ({
groupIdx: index('group_member_group_idx').on(t.groupId),
citizenIdx: index('group_member_citizen_idx').on(t.citizenid),
}),
);
// ── Behörden-Registry (LSPD, BCSO, DOJ, SAMS, LSFD …) ──
export const mdtAuthorities = mysqlTable('mdt_authorities', {
id: int('id').autoincrement().primaryKey(),
key: varchar('key', { length: 32 }).notNull().unique(),
name: varchar('name', { length: 128 }).notNull(),
color: varchar('color', { length: 16 }).notNull().default('#2f6df6'),
department: mysqlEnum('department', ['police', 'ems', 'fire']).notNull(),
jobs: jsonStringArray('jobs').notNull().default([]),
});
// ── Rang → Rechte-Matrix (editierbar über Admin-UI) ──
export const mdtRanks = mysqlTable(
'mdt_ranks',
{
id: int('id').autoincrement().primaryKey(),
department: mysqlEnum('department', ['police', 'ems', 'fire']).notNull(),
grade: int('grade').notNull(),
label: varchar('label', { length: 64 }).notNull(),
permissions: jsonStringArray('permissions').notNull().default([]),
},
(t) => ({
deptGradeIdx: uniqueIndex('rank_dept_grade_idx').on(t.department, t.grade),
}),
);
/**
* @deprecated Alt-Tabelle: früher der editierbare Overlay über QBox-Spieler.
* Seit dem Umstieg auf die eigenständige `mdt_citizens`-Registry ungenutzt.
* Bleibt definiert, damit Drizzle keinen Rename/Drop generiert (Daten unangetastet).
*/
export const mdtPersonRecords = mysqlTable('mdt_person_records', {
citizenid: varchar('citizenid', { length: 50 }).primaryKey(),
notes: text('notes'),
flags: jsonStringArray('flags').notNull().default([]),
isWanted: boolean('is_wanted').notNull().default(false),
mugshotUrl: varchar('mugshot_url', { length: 512 }),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
updatedBy: varchar('updated_by', { length: 50 }),
});
// ── Bürgerakten (MDT-eigene Personen-Registry, unabhängig von QBox) ──
export const mdtCitizens = mysqlTable(
'mdt_citizens',
{
citizenid: varchar('citizenid', { length: 50 }).primaryKey(), // MDT-generiert (z. B. LS-A1B2C3)
firstname: varchar('firstname', { length: 128 }).notNull(),
lastname: varchar('lastname', { length: 128 }).notNull(),
dob: varchar('dob', { length: 32 }),
gender: mysqlEnum('gender', ['male', 'female', 'divers', 'unknown']).notNull().default('unknown'),
phone: varchar('phone', { length: 32 }),
nationality: varchar('nationality', { length: 64 }),
legalStatus: mysqlEnum('legal_status', ['citizen', 'resident', 'visitor', 'illegal', 'unknown'])
.notNull()
.default('citizen'),
address: varchar('address', { length: 255 }),
occupation: varchar('occupation', { length: 128 }),
height: varchar('height', { length: 32 }),
eyeColor: varchar('eye_color', { length: 32 }),
hairColor: varchar('hair_color', { length: 32 }),
distinguishingMarks: text('distinguishing_marks'),
aliases: jsonStringArray('aliases').notNull().default([]),
licenses: jsonLicenses('licenses').notNull().default([]),
mugshotUrl: varchar('mugshot_url', { length: 512 }),
notes: text('notes'),
flags: jsonStringArray('flags').notNull().default([]),
isWanted: boolean('is_wanted').notNull().default(false),
// Freigabe (ACL) — Sichtbarkeit der Akte
public: boolean('public').notNull().default(true),
acl: jsonAcl('acl').notNull().default([]),
createdByCitizenid: varchar('created_by_citizenid', { length: 50 }),
createdByName: varchar('created_by_name', { length: 255 }),
updatedBy: varchar('updated_by', { length: 50 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
nameIdx: index('citizen_name_idx').on(t.lastname, t.firstname),
wantedIdx: index('citizen_wanted_idx').on(t.isWanted),
}),
);
// ── Fahrzeug-Registry (MDT-eigen — inkl. gefälschter/gestohlener Kennzeichen) ──
export const mdtVehicles = mysqlTable(
'mdt_vehicles',
{
id: int('id').autoincrement().primaryKey(),
plate: varchar('plate', { length: 32 }).notNull(), // NICHT unique: gefälschte Doppel-Kennzeichen möglich
model: varchar('model', { length: 64 }),
color: varchar('color', { length: 64 }),
ownerCitizenid: varchar('owner_citizenid', { length: 50 }), // null = unbekannter Halter
plateStatus: mysqlEnum('plate_status', ['registered', 'forged', 'stolen', 'unknown'])
.notNull()
.default('registered'),
isStolen: boolean('is_stolen').notNull().default(false),
hasBolo: boolean('has_bolo').notNull().default(false),
notes: text('notes'),
createdByCitizenid: varchar('created_by_citizenid', { length: 50 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
plateIdx: index('vehicle_plate_idx').on(t.plate),
ownerIdx: index('vehicle_owner_idx').on(t.ownerCitizenid),
}),
);
// ── Strafenkatalog ──
export const mdtChargesCatalog = mysqlTable('mdt_charges_catalog', {
id: int('id').autoincrement().primaryKey(),
code: varchar('code', { length: 32 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
category: varchar('category', { length: 64 }).notNull().default('sonstiges'),
fine: int('fine').notNull().default(0),
jailTime: int('jail_time').notNull().default(0),
points: int('points').notNull().default(0),
active: boolean('active').notNull().default(true),
});
// ── Strafakten ──
export const mdtCases = mysqlTable(
'mdt_cases',
{
id: int('id').autoincrement().primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
suspectCitizenid: varchar('suspect_citizenid', { length: 50 }).notNull(),
status: mysqlEnum('status', ['open', 'closed', 'dismissed']).notNull().default('open'),
narrative: text('narrative'),
totalFine: int('total_fine').notNull().default(0),
totalJailTime: int('total_jail_time').notNull().default(0),
officerCitizenid: varchar('officer_citizenid', { length: 50 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
suspectIdx: index('suspect_idx').on(t.suspectCitizenid),
}),
);
export const mdtCaseCharges = mysqlTable('mdt_case_charges', {
id: int('id').autoincrement().primaryKey(),
caseId: int('case_id').notNull(),
catalogId: int('catalog_id').notNull(),
count: int('count').notNull().default(1),
fine: int('fine').notNull().default(0),
jailTime: int('jail_time').notNull().default(0),
points: int('points').notNull().default(0),
});
// ── Kalender: Termine/Events + Teilnehmer ──
export const mdtCalendarEvents = mysqlTable(
'mdt_calendar_events',
{
id: int('id').autoincrement().primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'), // HTML aus Tiptap
category: mysqlEnum('category', ['dienst', 'schulung', 'meeting', 'event', 'sonstiges'])
.notNull()
.default('sonstiges'),
startAt: datetime('start_at', { mode: 'date' }).notNull(),
endAt: datetime('end_at', { mode: 'date' }),
allDay: boolean('all_day').notNull().default(false),
location: varchar('location', { length: 255 }),
openSignup: boolean('open_signup').notNull().default(true),
organizerCitizenid: varchar('organizer_citizenid', { length: 50 }),
organizerName: varchar('organizer_name', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
startIdx: index('event_start_idx').on(t.startAt),
}),
);
export const mdtCalendarAttendees = mysqlTable(
'mdt_calendar_attendees',
{
id: int('id').autoincrement().primaryKey(),
eventId: int('event_id').notNull(),
citizenid: varchar('citizenid', { length: 50 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
status: mysqlEnum('status', ['invited', 'accepted', 'declined', 'maybe']).notNull().default('invited'),
self: boolean('self').notNull().default(false), // hat sich selbst eingetragen
},
(t) => ({
eventIdx: index('attendee_event_idx').on(t.eventId),
uniqueMember: uniqueIndex('attendee_event_citizen_idx').on(t.eventId, t.citizenid),
}),
);
// ── Gesetze (Strafgesetzbuch-Nachschlagewerk) ──
export const mdtLaws = mysqlTable(
'mdt_laws',
{
id: int('id').autoincrement().primaryKey(),
category: varchar('category', { length: 64 }).notNull().default('Allgemein'),
paragraph: varchar('paragraph', { length: 32 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'), // HTML aus Tiptap
fine: int('fine'),
jailTime: int('jail_time'),
sortOrder: int('sort_order').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
categoryIdx: index('law_category_idx').on(t.category),
}),
);
// ── Behandlungsakten (EMS-Pendant zu den Strafakten) ──
export const mdtTreatmentRecords = mysqlTable(
'mdt_treatment_records',
{
id: int('id').autoincrement().primaryKey(),
reference: varchar('reference', { length: 32 }).notNull(), // TR-JJJJ-MM-TT-###
patientCitizenid: varchar('patient_citizenid', { length: 50 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
diagnosis: text('diagnosis'),
treatment: text('treatment'), // HTML aus Tiptap
status: mysqlEnum('status', ['open', 'closed']).notNull().default('open'),
authorCitizenid: varchar('author_citizenid', { length: 50 }),
authorName: varchar('author_name', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
patientIdx: index('treatment_patient_idx').on(t.patientCitizenid),
}),
);
// ── Haftbefehle ──
export const mdtWarrants = mysqlTable(
'mdt_warrants',
{
id: int('id').autoincrement().primaryKey(),
citizenid: varchar('citizenid', { length: 50 }).notNull(),
reason: text('reason').notNull(),
status: mysqlEnum('status', ['active', 'served', 'revoked', 'expired'])
.notNull()
.default('active'),
caseId: int('case_id'),
issuedByCitizenid: varchar('issued_by_citizenid', { length: 50 }),
issuedAt: timestamp('issued_at').defaultNow().notNull(),
expiresAt: timestamp('expires_at'),
},
(t) => ({
citizenIdx: index('warrant_citizen_idx').on(t.citizenid),
}),
);
// ── BOLOs / Fahndungen ──
export const mdtBolos = mysqlTable('mdt_bolos', {
id: int('id').autoincrement().primaryKey(),
kind: mysqlEnum('kind', ['person', 'vehicle']).notNull(),
reference: varchar('reference', { length: 64 }).notNull(), // citizenid oder plate
label: varchar('label', { length: 255 }).notNull(),
reason: text('reason'),
active: boolean('active').notNull().default(true),
createdByCitizenid: varchar('created_by_citizenid', { length: 50 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// ── Dispatch ──
export const mdtDispatchCalls = mysqlTable('mdt_dispatch_calls', {
id: int('id').autoincrement().primaryKey(),
code: varchar('code', { length: 32 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
department: mysqlEnum('department', ['police', 'ems', 'fire']).notNull().default('police'),
priority: mysqlEnum('priority', ['low', 'medium', 'high']).notNull().default('medium'),
status: mysqlEnum('status', ['pending', 'assigned', 'enroute', 'onscene', 'closed'])
.notNull()
.default('pending'),
location: varchar('location', { length: 255 }),
coordsX: varchar('coords_x', { length: 32 }),
coordsY: varchar('coords_y', { length: 32 }),
coordsZ: varchar('coords_z', { length: 32 }),
callerCitizenid: varchar('caller_citizenid', { length: 50 }),
assignedOfficers: jsonStringArray('assigned_officers').notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
});
// ── Schwarzes Brett ──
export const mdtAnnouncements = mysqlTable('mdt_announcements', {
id: int('id').autoincrement().primaryKey(),
title: varchar('title', { length: 255 }).notNull(),
body: text('body'),
pinned: boolean('pinned').notNull().default(false),
important: boolean('important').notNull().default(false),
authorCitizenid: varchar('author_citizenid', { length: 50 }),
authorName: varchar('author_name', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// ── Dokumente ──
export const mdtDocFolders = mysqlTable('mdt_doc_folders', {
id: int('id').autoincrement().primaryKey(),
name: varchar('name', { length: 128 }).notNull(),
// Freigabe (ACL)
public: boolean('public').notNull().default(false),
acl: jsonAcl('acl').notNull().default([]),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const mdtDocuments = mysqlTable(
'mdt_documents',
{
id: int('id').autoincrement().primaryKey(),
reference: varchar('reference', { length: 32 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
content: text('content'), // HTML aus Tiptap
folderId: int('folder_id'),
// Freigabe (ACL)
public: boolean('public').notNull().default(false),
acl: jsonAcl('acl').notNull().default([]),
pinned: boolean('pinned').notNull().default(false),
authorCitizenid: varchar('author_citizenid', { length: 50 }),
authorName: varchar('author_name', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
},
(t) => ({
folderIdx: index('doc_folder_idx').on(t.folderId),
}),
);
// ── Audit-Log ──
export const mdtAuditLog = mysqlTable(
'mdt_audit_log',
{
id: int('id').autoincrement().primaryKey(),
actorCitizenid: varchar('actor_citizenid', { length: 50 }),
actorName: varchar('actor_name', { length: 255 }),
action: varchar('action', { length: 64 }).notNull(), // z. B. "person.read"
targetType: varchar('target_type', { length: 32 }),
targetId: varchar('target_id', { length: 64 }),
meta: jsonRecord('meta'),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => ({
actorIdx: index('audit_actor_idx').on(t.actorCitizenid),
actionIdx: index('audit_action_idx').on(t.action),
}),
);
+29
View File
@@ -0,0 +1,29 @@
/**
* Read-only Mapping der bestehenden QBox-Tabellen.
* NIEMALS via Drizzle-Migration verändern — diese Tabellen gehören qbx_core.
* Nur die Spalten, die das MDT liest. JSON-Spalten kommen als string/unknown und
* werden im Service geparst (charinfo, job, metadata, mods).
*/
import { mysqlTable, varchar, int, longtext, timestamp, tinyint } from 'drizzle-orm/mysql-core';
export const qboxPlayers = mysqlTable('players', {
citizenid: varchar('citizenid', { length: 50 }).primaryKey(),
license: varchar('license', { length: 255 }),
name: varchar('name', { length: 255 }),
// JSON-Blobs (QBox speichert als longtext/JSON)
charinfo: longtext('charinfo'),
job: longtext('job'),
metadata: longtext('metadata'),
phone: varchar('phone_number', { length: 50 }),
lastUpdated: timestamp('last_updated'),
});
export const qboxVehicles = mysqlTable('player_vehicles', {
id: int('id').primaryKey(),
citizenid: varchar('citizenid', { length: 50 }),
plate: varchar('plate', { length: 50 }),
vehicle: varchar('vehicle', { length: 50 }), // model spawn name
mods: longtext('mods'),
garage: varchar('garage', { length: 50 }),
state: tinyint('state'), // 0 = out, 1 = garaged (QBox)
});
+247
View File
@@ -0,0 +1,247 @@
/**
* Dev-Setup: legt eine ISOLIERTE Test-DB an und seedet MDT-eigene Testdaten.
*
* pnpm db:setup:dev
*
* Voraussetzung: in .env eine DEDIZIERTE Test-DB setzen, z. B. DB_NAME=d4rk_mdt_dev.
* Das MDT ist komplett eigenständig — es liest KEINE QBox-Tabellen mehr, sondern
* führt Bürger (mdt_citizens) und Fahrzeuge (mdt_vehicles) selbst. Das Script:
* 1) spielt die mdt_*-Migrationen ein (Drizzle)
* 2) seedet Test-Bürger (inkl. illegaler Einreise) + Fahrzeuge (inkl. gefälschtem Kennzeichen)
*
* Non-destruktiv: INSERT ... ON DUPLICATE KEY UPDATE bzw. Seed nur wenn leer.
*/
import mysql from 'mysql2/promise';
import { migrate } from 'drizzle-orm/mysql2/migrator';
import { count } from 'drizzle-orm';
import { pool, db } from './client';
import { mdtCitizens, mdtVehicles, mdtLaws, mdtCalendarEvents, mdtCalendarAttendees } from './schema';
import { env } from '../config/env';
/** Legt die Ziel-DB an (separate Verbindung ohne selektierte DB). */
async function createDatabaseIfMissing(): Promise<void> {
const conn = await mysql.createConnection({
host: env.DB_HOST,
port: env.DB_PORT,
user: env.DB_USER,
password: env.DB_PASSWORD,
});
await conn.query(`CREATE DATABASE IF NOT EXISTS \`${env.DB_NAME}\``);
await conn.end();
}
async function main(): Promise<void> {
if (env.NODE_ENV === 'production') {
throw new Error('db:setup:dev darf nicht in production laufen.');
}
console.log(`[setup-dev] Ziel-DB: ${env.DB_NAME} @ ${env.DB_HOST}:${env.DB_PORT}`);
await createDatabaseIfMissing();
console.log('[setup-dev] Datenbank sichergestellt.');
// 1) mdt_*-Migrationen
await migrate(db, { migrationsFolder: './drizzle' });
console.log('[setup-dev] mdt_*-Migrationen eingespielt.');
// 2) Seed: Test-Bürger (MDT-eigen, feste IDs → idempotent)
const citizens = [
{
citizenid: 'LS-100001',
firstname: 'Max',
lastname: 'Mustermann',
dob: '1990-05-14',
gender: 'male' as const,
phone: '555-0101',
nationality: 'USA',
legalStatus: 'citizen' as const,
address: 'Alta St 12, Los Santos',
occupation: 'Mechaniker',
height: '182 cm',
eyeColor: 'braun',
hairColor: 'schwarz',
licenses: [
{ type: 'driver', label: 'Führerschein', active: true },
{ type: 'weapon', label: 'Waffenschein', active: false },
],
flags: [],
isWanted: true,
},
{
citizenid: 'LS-100002',
firstname: 'Erika',
lastname: 'Schmidt',
dob: '1988-11-02',
gender: 'female' as const,
phone: '555-0202',
nationality: 'Deutschland',
legalStatus: 'resident' as const,
address: 'Vinewood Blvd 8',
occupation: 'Anwältin',
licenses: [{ type: 'driver', label: 'Führerschein', active: true }],
flags: [],
isWanted: false,
},
{
citizenid: 'LS-100003',
firstname: 'John',
lastname: 'Doe',
dob: '1995-07-21',
gender: 'male' as const,
phone: '555-0303',
nationality: 'unbekannt',
legalStatus: 'unknown' as const,
occupation: null,
licenses: [],
flags: ['bewaffnet'],
isWanted: false,
},
{
citizenid: 'LS-100004',
firstname: 'Juan',
lastname: 'Sinpapeles',
dob: '1992-03-09',
gender: 'male' as const,
phone: null,
nationality: 'Mexiko',
legalStatus: 'illegal' as const,
address: null,
occupation: null,
aliases: ['El Fantasma'],
licenses: [],
flags: ['illegale Einreise'],
isWanted: true,
},
];
for (const c of citizens) {
await db
.insert(mdtCitizens)
.values({ ...c, createdByName: 'System-Seed' })
.onDuplicateKeyUpdate({
set: {
firstname: c.firstname,
lastname: c.lastname,
legalStatus: c.legalStatus,
isWanted: c.isWanted,
},
});
}
// Fahrzeuge (nur seeden wenn Registry leer — gefälschtes/gestohlenes Kennzeichen dabei)
const [vehCount] = await db.select({ c: count() }).from(mdtVehicles);
if ((vehCount?.c ?? 0) === 0) {
await db.insert(mdtVehicles).values([
{ plate: 'MAX 001', model: 'Sultan RS', color: 'schwarz', ownerCitizenid: 'LS-100001', plateStatus: 'registered' },
{ plate: 'JD 42', model: 'Blista', color: 'blau', ownerCitizenid: 'LS-100003', plateStatus: 'registered' },
{ plate: 'COP 007', model: 'Police Cruiser', color: 'weiß', ownerCitizenid: null, plateStatus: 'forged', notes: 'Gefälschtes Behördenkennzeichen' },
{ plate: 'GH0 5T', model: 'Kuruma', color: 'grau', ownerCitizenid: 'LS-100004', plateStatus: 'stolen', isStolen: true },
]);
}
// Gesetze (nur seeden wenn leer)
const [lawCount] = await db.select({ c: count() }).from(mdtLaws);
if ((lawCount?.c ?? 0) === 0) {
await db.insert(mdtLaws).values([
{
category: 'Eigentumsdelikte',
paragraph: '§ 242',
title: 'Diebstahl',
description:
'<p>Wer eine fremde bewegliche Sache einem anderen in der Absicht wegnimmt, sie sich rechtswidrig zuzueignen, wird bestraft.</p>',
fine: 500,
jailTime: 5,
sortOrder: 1,
},
{
category: 'Verkehrsdelikte',
paragraph: '§ 315c',
title: 'Gefährdung des Straßenverkehrs',
description: '<p>Rücksichtsloses Fahren mit Gefährdung von Leib, Leben oder Sachwerten.</p>',
fine: 800,
jailTime: 10,
sortOrder: 1,
},
{
category: 'Gewaltdelikte',
paragraph: '§ 223',
title: 'Körperverletzung',
description: '<p>Wer eine andere Person <strong>körperlich misshandelt</strong> oder an der Gesundheit schädigt.</p>',
fine: 1000,
jailTime: 15,
sortOrder: 1,
},
{
category: 'Gewaltdelikte',
paragraph: '§ 211',
title: 'Mord',
description:
'<p>Wer aus niedrigen Beweggründen, heimtückisch oder grausam einen Menschen tötet.</p><ul><li>Höchststrafe</li></ul>',
fine: 0,
jailTime: 120,
sortOrder: 2,
},
]);
}
// Kalender-Termine (nur seeden wenn leer) — Daten relativ zu heute
const [evCount] = await db.select({ c: count() }).from(mdtCalendarEvents);
if ((evCount?.c ?? 0) === 0) {
const at = (dayOffset: number, hour: number) => {
const d = new Date();
d.setDate(d.getDate() + dayOffset);
d.setHours(hour, 0, 0, 0);
return d;
};
const [ev1] = await db
.insert(mdtCalendarEvents)
.values({
title: 'Dienstbesprechung',
description: '<p>Wöchentliche Lagebesprechung im Präsidium.</p>',
category: 'meeting',
startAt: at(0, 14),
endAt: at(0, 15),
location: 'Mission Row PD',
openSignup: true,
organizerName: 'System-Seed',
})
.$returningId();
const [ev2] = await db
.insert(mdtCalendarEvents)
.values({
title: 'Schießtraining',
description: '<p>Pflichttraining am Schießstand. Bitte selbst eintragen.</p>',
category: 'schulung',
startAt: at(3, 18),
endAt: at(3, 20),
location: 'Schießstand',
openSignup: true,
organizerName: 'System-Seed',
})
.$returningId();
await db.insert(mdtCalendarAttendees).values([
{ eventId: ev1!.id, citizenid: 'LS-100002', name: 'Erika Schmidt', status: 'invited', self: false },
{ eventId: ev2!.id, citizenid: 'LS-100002', name: 'Erika Schmidt', status: 'accepted', self: true },
]);
}
// Beispiel-Strafenkatalog (idempotent: nur seeden wenn leer)
const [catRows] = await pool.query('SELECT COUNT(*) AS c FROM mdt_charges_catalog');
const catCount = Array.isArray(catRows) ? Number((catRows[0] as { c: number }).c) : 0;
if (catCount === 0) {
await pool.query(`
INSERT INTO mdt_charges_catalog (code, title, category, fine, jail_time, points)
VALUES
('§ 242', 'Diebstahl', 'eigentum', 500, 5, 2),
('§ 315c', 'Gefährdung Straßenverkehr', 'verkehr', 800, 10, 3),
('§ 223', 'Körperverletzung', 'gewalt', 1000, 15, 4)
`);
}
console.log('[setup-dev] Seed abgeschlossen ✅ (4 Bürger inkl. illegaler Einreise, 4 Fahrzeuge inkl. Fälschung, 3 Strafen)');
await pool.end();
}
main().catch((err) => {
console.error('[setup-dev] Fehler:', err);
process.exit(1);
});
@@ -0,0 +1,51 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { updateRankSchema, updateUserRolesSchema, DEPARTMENTS } from '@d4rk-tablet/shared';
import { getRankMatrix, updateRank, listUsers, updateUserRoles } from './admin.service';
const deptParam = z.object({ department: z.enum(DEPARTMENTS) });
export async function adminRoutes(app: FastifyInstance): Promise<void> {
// Rechte-Matrix einer Behörde
app.get<{ Params: { department: string } }>(
'/admin/ranks/:department',
{ preHandler: app.requirePermission('admin.ranks.manage') },
async (req, reply) => {
const parsed = deptParam.safeParse(req.params);
if (!parsed.success) return reply.status(400).send({ message: 'Unbekannte Behörde' });
return getRankMatrix(parsed.data.department);
},
);
// Einen Rang aktualisieren
app.put<{ Params: { department: string; grade: string } }>(
'/admin/ranks/:department/:grade',
{ preHandler: app.requirePermission('admin.ranks.manage') },
async (req, reply) => {
const params = deptParam.safeParse({ department: req.params.department });
const body = updateRankSchema.safeParse(req.body);
if (!params.success || !body.success) {
return reply.status(400).send({ message: 'Ungültige Anfrage' });
}
return updateRank(params.data.department, Number(req.params.grade), body.data);
},
);
// Mitarbeiterliste
app.get('/admin/users', { preHandler: app.requirePermission('admin.users.manage') }, async () => {
return listUsers();
});
// Override-Rollen eines Mitarbeiters setzen
app.patch<{ Params: { id: string } }>(
'/admin/users/:id/roles',
{ preHandler: app.requirePermission('admin.users.manage') },
async (req, reply) => {
const parsed = updateUserRolesSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send({ message: 'Ungültige Rollen' });
const user = await updateUserRoles(Number(req.params.id), parsed.data.roles);
if (!user) return reply.status(404).send({ message: 'Mitarbeiter nicht gefunden' });
return user;
},
);
}
@@ -0,0 +1,120 @@
import { and, eq } from 'drizzle-orm';
import {
PERMISSIONS,
ROLE_PERMISSIONS,
type Department,
type Permission,
type Role,
type Rank,
type MdtUser,
} from '@d4rk-tablet/shared';
import { db } from '../../db/client';
import { mdtRanks, mdtUsers } from '../../db/schema';
import { DEFAULT_RANKS, defaultPermissionsFor } from '../../config/rankDefaults';
const OVERRIDE_ROLES: Role[] = ['admin', 'dispatch'];
function asRoles(v: unknown): Role[] {
if (!Array.isArray(v)) return [];
const set = new Set<string>(['admin', 'dispatch', 'police', 'ems', 'fire']);
return v.filter((r): r is Role => typeof r === 'string' && set.has(r));
}
/**
* Effektive Rechte eines Users: Rang-Matrix(Department, Grade) Override-Rollen.
* admin → alle Rechte. DB-Rang überschreibt Code-Default.
*/
export async function computePermissions(
department: Department | null,
grade: number | null,
overrideRoles: Role[],
): Promise<Permission[]> {
if (overrideRoles.includes('admin')) return [...PERMISSIONS];
const perms = new Set<Permission>();
if (department && grade != null) {
const [row] = await db
.select()
.from(mdtRanks)
.where(and(eq(mdtRanks.department, department), eq(mdtRanks.grade, grade)))
.limit(1);
const rankPerms = row ? (row.permissions as Permission[]) : defaultPermissionsFor(department, grade);
for (const p of rankPerms) perms.add(p);
}
for (const role of overrideRoles) {
if (role === 'dispatch') for (const p of ROLE_PERMISSIONS.dispatch) perms.add(p);
}
return [...perms];
}
/** Rechte-Matrix einer Behörde: DB-Ränge über Defaults gemerged. */
export async function getRankMatrix(department: Department): Promise<Rank[]> {
const rows = await db.select().from(mdtRanks).where(eq(mdtRanks.department, department));
const byGrade = new Map(rows.map((r) => [r.grade, r]));
const grades = new Set<number>([
...DEFAULT_RANKS[department].map((r) => r.grade),
...rows.map((r) => r.grade),
]);
return [...grades]
.sort((a, b) => a - b)
.map((grade) => {
const dbRow = byGrade.get(grade);
if (dbRow) {
return {
department,
grade,
label: dbRow.label,
permissions: dbRow.permissions as Permission[],
};
}
const def = DEFAULT_RANKS[department].find((r) => r.grade === grade)!;
return { department, grade, label: def.label, permissions: def.permissions };
});
}
export async function updateRank(
department: Department,
grade: number,
input: { label?: string; permissions: Permission[] },
): Promise<Rank> {
const label =
input.label ?? DEFAULT_RANKS[department].find((r) => r.grade === grade)?.label ?? `Grade ${grade}`;
await db
.insert(mdtRanks)
.values({ department, grade, label, permissions: input.permissions })
.onDuplicateKeyUpdate({ set: { label, permissions: input.permissions } });
return { department, grade, label, permissions: input.permissions };
}
// ── Mitarbeiter ──
function mapUser(row: typeof mdtUsers.$inferSelect): MdtUser {
return {
id: row.id,
citizenid: row.citizenid,
discordId: row.discordId,
name: row.name,
callsign: row.callsign,
department: row.department,
grade: row.grade,
roles: asRoles(row.roles),
};
}
export async function listUsers(): Promise<MdtUser[]> {
const rows = await db.select().from(mdtUsers);
return rows.map(mapUser);
}
export async function updateUserRoles(id: number, roles: string[]): Promise<MdtUser | null> {
const filtered = asRoles(roles).filter((r) => OVERRIDE_ROLES.includes(r));
await db.update(mdtUsers).set({ roles: filtered }).where(eq(mdtUsers.id, id));
const [row] = await db.select().from(mdtUsers).where(eq(mdtUsers.id, id)).limit(1);
return row ? mapUser(row) : null;
}
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { createHmac } from 'node:crypto';
import { verifyBridgeSignature } from '../auth.service';
import { env } from '../../../config/env';
function sign(ts: string, license: string, citizenid: string): string {
return createHmac('sha256', env.BRIDGE_HMAC_SECRET)
.update(`${ts}.${license}.${citizenid}`)
.digest('hex');
}
describe('verifyBridgeSignature', () => {
const license = 'license:abc';
const citizenid = 'ABC12345';
it('akzeptiert eine gültige, frische Signatur', () => {
const ts = String(Date.now());
const sig = sign(ts, license, citizenid);
expect(verifyBridgeSignature(ts, license, citizenid, sig)).toBe(true);
});
it('lehnt eine manipulierte Signatur ab', () => {
const ts = String(Date.now());
const sig = sign(ts, license, citizenid);
expect(verifyBridgeSignature(ts, license, 'OTHER999', sig)).toBe(false);
});
it('lehnt einen abgelaufenen Timestamp ab (Replay-Schutz)', () => {
const ts = String(Date.now() - 120_000);
const sig = sign(ts, license, citizenid);
expect(verifyBridgeSignature(ts, license, citizenid, sig)).toBe(false);
});
it('lehnt eine falsch signierte (falscher Secret) Signatur ab', () => {
const ts = String(Date.now());
const wrong = createHmac('sha256', 'wrong-secret')
.update(`${ts}.${license}.${citizenid}`)
.digest('hex');
expect(verifyBridgeSignature(ts, license, citizenid, wrong)).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More