dak.c2s/frontend/src/pages/AdminInvitationsPage.tsx
CCS Admin ad0bcaf8c1 feat: add tooltips and explanatory text for DAK-Mitarbeiter pages
Add contextual tooltips on table headers, KPI cards, status badges,
and action buttons across Dashboard, Cases, Wochenübersicht, Reports,
and My Disclosures pages. Wrap global TooltipProvider in App.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:12:59 +00:00

298 lines
11 KiB
TypeScript

import { useState, useEffect } from 'react'
import { Plus, Copy, Check, Loader2 } from 'lucide-react'
import api from '@/services/api'
import type { InvitationResponse, CreateInvitationPayload } from '@/types'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Tooltip, TooltipContent, TooltipTrigger,
} from '@/components/ui/tooltip'
export function AdminInvitationsPage() {
const [invitations, setInvitations] = useState<InvitationResponse[]>([])
const [loading, setLoading] = useState(true)
// Create dialog
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState<CreateInvitationPayload>({
email: '', role: 'dak_mitarbeiter', expires_in_days: 7,
})
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState('')
const [createdToken, setCreatedToken] = useState<string | null>(null)
// Copy feedback
const [copiedId, setCopiedId] = useState<number | null>(null)
useEffect(() => {
fetchInvitations()
}, [])
const fetchInvitations = () => {
setLoading(true)
api.get<InvitationResponse[]>('/admin/invitations', { params: { skip: 0, limit: 200 } })
.then((res) => setInvitations(res.data))
.catch(() => setInvitations([]))
.finally(() => setLoading(false))
}
const handleCreate = async () => {
setCreating(true)
setCreateError('')
setCreatedToken(null)
try {
const payload: Record<string, unknown> = {
role: createForm.role,
expires_in_days: createForm.expires_in_days,
}
if (createForm.email && createForm.email.trim()) {
payload.email = createForm.email.trim()
}
const res = await api.post<InvitationResponse>('/admin/invitations', payload)
setCreatedToken(res.data.token)
fetchInvitations()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setCreateError(msg || 'Fehler beim Erstellen der Einladung.')
} finally {
setCreating(false)
}
}
const copyToken = async (token: string, id: number) => {
try {
await navigator.clipboard.writeText(token)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
} catch {
// Fallback
const textarea = document.createElement('textarea')
textarea.value = token
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
}
}
const getStatus = (inv: InvitationResponse): { label: string; variant: 'default' | 'secondary' | 'destructive' } => {
if (inv.used_at) return { label: 'Verwendet', variant: 'secondary' }
if (new Date(inv.expires_at) < new Date()) return { label: 'Abgelaufen', variant: 'destructive' }
return { label: 'Aktiv', variant: 'default' }
}
const resetCreateDialog = () => {
setCreateForm({ email: '', role: 'dak_mitarbeiter', expires_in_days: 7 })
setCreateError('')
setCreatedToken(null)
setCreateOpen(false)
}
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Einladungen</h1>
<Button onClick={() => { setCreateError(''); setCreatedToken(null); setCreateOpen(true) }}>
<Plus className="mr-2 h-4 w-4" />
Einladung erstellen
</Button>
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : invitations.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Token</TableHead>
<TableHead>E-Mail</TableHead>
<TableHead>Rolle</TableHead>
<TableHead>Erstellt</TableHead>
<TableHead>Gültig bis</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitations.map((inv) => {
const status = getStatus(inv)
return (
<TableRow key={inv.id}>
<TableCell className="font-mono text-xs max-w-[120px] truncate">
{inv.token.slice(0, 12)}...
</TableCell>
<TableCell>{inv.email || '-'}</TableCell>
<TableCell>
<Badge variant={inv.role === 'admin' ? 'default' : 'secondary'}>
{inv.role === 'admin' ? 'Admin' : 'DAK Mitarbeiter'}
</Badge>
</TableCell>
<TableCell>{formatDateTime(inv.created_at)}</TableCell>
<TableCell>{formatDateTime(inv.expires_at)}</TableCell>
<TableCell>
<Badge variant={status.variant}>{status.label}</Badge>
</TableCell>
<TableCell className="text-right">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => copyToken(inv.token, inv.id)}
>
{copiedId === inv.id ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{copiedId === inv.id ? 'Kopiert!' : 'Token kopieren'}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
) : (
<p className="py-8 text-center text-muted-foreground">
Keine Einladungen vorhanden.
</p>
)}
{/* Create invitation dialog */}
<Dialog open={createOpen} onOpenChange={(open) => { if (!open) resetCreateDialog() }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Einladung erstellen</DialogTitle>
<DialogDescription>
Erstellen Sie einen Einladungstoken für neue Benutzer.
</DialogDescription>
</DialogHeader>
{createdToken ? (
<div className="space-y-4">
<Alert>
<AlertDescription>
Einladung erfolgreich erstellt! Kopieren Sie den Token:
</AlertDescription>
</Alert>
<div className="flex items-center gap-2 rounded-md border p-3 bg-muted">
<code className="flex-1 text-sm break-all">{createdToken}</code>
<Button
variant="ghost"
size="sm"
onClick={() => copyToken(createdToken, -1)}
>
{copiedId === -1 ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
<DialogFooter>
<Button onClick={resetCreateDialog}>Schließen</Button>
</DialogFooter>
</div>
) : (
<>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="inv-email">E-Mail (optional)</Label>
<Input
id="inv-email"
type="email"
value={createForm.email ?? ''}
onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))}
placeholder="Optional: E-Mail des Eingeladenen"
/>
</div>
<div className="space-y-1">
<Label>Rolle</Label>
<Select
value={createForm.role}
onValueChange={(v) => setCreateForm((f) => ({ ...f, role: v as 'admin' | 'dak_mitarbeiter' }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dak_mitarbeiter">DAK Mitarbeiter</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="inv-days">Gültigkeit (Tage)</Label>
<Input
id="inv-days"
type="number"
value={createForm.expires_in_days}
onChange={(e) => setCreateForm((f) => ({ ...f, expires_in_days: Number(e.target.value) }))}
min={1}
max={90}
className="w-28"
/>
</div>
{createError && (
<Alert variant="destructive">
<AlertDescription>{createError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetCreateDialog}>
Abbrechen
</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Erstellen...
</>
) : (
'Erstellen'
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
)
}
function formatDateTime(dateStr: string): string {
try {
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
} catch {
return dateStr
}
}