import { useCallback, useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import Modal from '../components/Modal'; import Notice from '../components/Notice'; import { ApiError, api } from '../lib/api'; import { useSession } from '../lib/session'; import type { PlayerSummary, ScrapeSummary, UnmatchedOverview } from '../lib/types'; interface SearchResult { total: number; items: PlayerSummary[]; } function Counter({ value, title }: { value: number; title: string }) { return ( {value} ); } export default function Dashboard() { const { can } = useSession(); const [query, setQuery] = useState(''); const [result, setResult] = useState({ total: 0, items: [] }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [creating, setCreating] = useState(false); const [flagged, setFlagged] = useState({ total: 0, players: [] }); const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(null); const load = useCallback(async (search: string, signal?: AbortSignal) => { setLoading(true); try { const params = new URLSearchParams({ q: search, take: '100' }); const data = await api(`/api/players?${params}`, signal ? { signal } : {}); setResult(data); setError(null); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); } finally { setLoading(false); } }, []); const loadFlagged = useCallback(async () => { try { setFlagged(await api('/api/scrape/unmatched/overview')); } catch { // Der Hinweis ist Beiwerk - ein Fehler hier darf die Liste nicht stören. } }, []); useEffect(() => { void loadFlagged(); }, [loadFlagged]); const runSync = async () => { setSyncing(true); setSyncResult(null); try { const result = await api('/api/scrape/run', { method: 'POST', body: {} }); const parts = [`${result.scannedPlayers} Profile geprüft`]; if (result.newRows > 0) parts.push(`${result.newRows} neue Pakete gefunden`); if (result.awaitingConfirmation > 0) { parts.push(`${result.awaitingConfirmation} warten schon`); } if (result.unclear > 0) parts.push(`${result.unclear} ungeklärt`); if (result.duplicates > 0) parts.push(`${result.duplicates} schon gebucht`); if (result.failedPlayers.length > 0) parts.push(`${result.failedPlayers.length} nicht erreichbar`); const offen = result.newRows + result.awaitingConfirmation; setSyncResult( parts.join(', ') + (offen > 0 ? '. Unter Import bestätigen, damit die Guthaben entstehen.' : '.'), ); await Promise.all([load(query), loadFlagged()]); } catch (err) { setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); } finally { setSyncing(false); } }; // Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst. useEffect(() => { const controller = new AbortController(); const timer = setTimeout(() => void load(query, controller.signal), 250); return () => { clearTimeout(timer); controller.abort(); }; }, [query, load]); return (

Spieler

{can('EDITOR') && ( )} {can('EDITOR') && ( )}
setQuery(event.target.value)} autoFocus /> {error && {error}} {syncResult && setSyncResult(null)}>{syncResult}} {flagged.total > 0 && ( {flagged.total} ungeklärte {flagged.total === 1 ? 'Eintrag' : 'Einträge'} bei{' '} {flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}: {' '} {flagged.players.map((entry, index) => ( {index > 0 && ', '} {entry.displayName} ({entry.count}) ))} )}
{result.items.map((player) => ( ))} {!loading && result.items.length === 0 && ( )}
Spieler SteamID CC Redesign Kulanz Aktive CCs
{player.displayName} {player.forumName && · {player.forumName}} {player.steamId ?? '—'} {player.activeCharacterCount} / {player.characterCount}
{query ? 'Kein Treffer.' : 'Noch keine Spieler erfasst.'}
{loading &&

Lade …

} {!loading && result.total > result.items.length && (

{result.items.length} von {result.total} angezeigt — Suche eingrenzen.

)} {creating && ( setCreating(false)} onCreated={() => { setCreating(false); void load(query); }} /> )}
); } function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { const [displayName, setDisplayName] = useState(''); const [steamId, setSteamId] = useState(''); const [forumName, setForumName] = useState(''); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const submit = async (event: React.FormEvent) => { event.preventDefault(); setBusy(true); setError(null); try { await api('/api/players', { method: 'POST', body: { displayName, steamId: steamId.trim() || null, forumName: forumName.trim() || null, }, }); onCreated(); } catch (err) { setError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen'); } finally { setBusy(false); } }; return (
{error && {error}} setDisplayName(event.target.value)} autoFocus required /> setSteamId(event.target.value)} placeholder="76561198... oder STEAM_0:1:..." />

Beide Schreibweisen sind möglich, auch [U:1:...]. Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt.

setForumName(event.target.value)} />
); }