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
+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,
},
});