mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 16:03:41 +00:00
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>
298 lines
11 KiB
TypeScript
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
|
|
}
|
|
}
|