first commit
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return clsx(inputs);
|
||||
}
|
||||
Vendored
+16
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user