import { useCallback, useEffect, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import BalanceCards from '../components/BalanceCards'; import UnmatchedBox from '../components/UnmatchedBox'; import Modal from '../components/Modal'; import Notice from '../components/Notice'; import { ApiError, api } from '../lib/api'; import { KIND_LABEL, REASON_LABEL, SOURCE_LABEL, formatAmount, formatDate } from '../lib/format'; import { useSession } from '../lib/session'; import type { Character, CreditKind, LedgerEntry, PlayerDetail as Detail, ScrapeSummary, } from '../lib/types'; type DialogState = | { type: 'none' } | { type: 'newCharacter' } | { type: 'redesign'; character: Character } | { type: 'deactivate'; character: Character } | { type: 'reactivatePaid'; character: Character } | { type: 'revert'; character: Character } | { type: 'reverse'; entry: LedgerEntry } | { type: 'editPlayer' } | { type: 'deletePlayer' }; export default function PlayerDetail() { const { id } = useParams<{ id: string }>(); const playerId = Number(id); const { can } = useSession(); const navigate = useNavigate(); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [flash, setFlash] = useState(null); const [dialog, setDialog] = useState({ type: 'none' }); const [busy, setBusy] = useState(false); const load = useCallback(async () => { try { setDetail(await api(`/api/players/${playerId}`)); setError(null); } catch (err) { setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); } finally { setLoading(false); } }, [playerId]); useEffect(() => { void load(); }, [load]); const run = async (action: () => Promise, message: string) => { setBusy(true); setError(null); try { await action(); await load(); setDialog({ type: 'none' }); setFlash(message); window.setTimeout(() => setFlash(null), 4000); } catch (err) { setError(err instanceof ApiError ? err.message : 'Aktion fehlgeschlagen'); } finally { setBusy(false); } }; // Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung // auch sagen, sonst wirkt der Knopf wirkungslos. const syncFromGextension = async () => { setBusy(true); setError(null); try { const result = await api(`/api/scrape/player/${playerId}`, { method: 'POST', body: {}, }); await load(); if (result.newRows > 0) { setFlash( `${result.newRows} ${result.newRows === 1 ? 'Paket' : 'Pakete'} gefunden. ` + 'Unter Import bestätigen, damit die Guthaben entstehen.', ); } else if (result.awaitingConfirmation > 0) { setFlash( `${result.awaitingConfirmation} ${result.awaitingConfirmation === 1 ? 'Paket wartet' : 'Pakete warten'} ` + 'bereits unter Import auf Bestätigung.', ); } else if (result.unclear > 0) { setFlash(`${result.unclear} unklare Einträge — siehe Kasten oben.`); } else if (result.duplicates > 0) { setFlash('Nichts Neues — alle Pakete sind bereits gebucht.'); } else if (result.failedPlayers.length > 0) { setError(`Profil nicht abrufbar: ${result.failedPlayers[0]?.reason ?? 'unbekannt'}`); } else { setFlash('Keine CC-relevanten Pakete im Profil gefunden.'); } window.setTimeout(() => setFlash(null), 8000); } catch (err) { setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); } finally { setBusy(false); } }; if (loading) return

Lade …

; if (!detail) return {error ?? 'Spieler nicht gefunden'}; const editor = can('EDITOR'); const active = detail.characters.filter((c) => c.status === 'ACTIVE'); const inactive = detail.characters.filter((c) => c.status === 'INACTIVE'); return (
← Alle Spieler

{detail.player.displayName}

{detail.player.steamId ?? 'keine SteamID'} {detail.player.forumName && ` · Forum: ${detail.player.forumName}`}

{detail.player.notes &&

{detail.player.notes}

}
{editor && (
{can('ADMIN') && ( )}
)}
{error && setError(null)}>{error}} {flash && setFlash(null)}>{flash}} {detail.player.gextensionUsage !== null && (

GExtension meldet {detail.player.gextensionUsage} Mal verbraucht {detail.player.gextensionSyncedAt && ` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`} . Nur zum Abgleich — wird nicht automatisch verrechnet.

)} void load()} /> {editor && (
{can('ADMIN') && detail.player.steamId && ( )}
)}

Aktive CCs ({active.length})

{active.length === 0 ? (

Keine aktiven CCs.

) : (
    {active.map((character) => (
  • {character.name} {character.predecessor && ( · Nachfolger von {character.predecessor.name} )} {character.loreText &&

    {character.loreText}

    } Seit {formatDate(character.createdAt)}
    {editor && (
    )}
  • ))}
)}

Inaktive CCs ({inactive.length})

{inactive.length === 0 ? (

Keine inaktiven CCs.

) : (
    {inactive.map((character) => { // Nur ein redesignter CC existiert im Original nicht mehr. const free = character.inactiveReason !== 'REWORKED'; return (
  • {character.name} {character.inactiveReason ? REASON_LABEL[character.inactiveReason] : 'Unbekannt'} {character.successors.length > 0 && ( {' '} · Ersetzt durch {character.successors.map((s) => s.name).join(', ')} )} {character.inactiveNote && (

    {character.inactiveNote}

    )}

    {free ? 'Kommt gratis zurück — kostet kein Guthaben.' : 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}

    {editor && (
    {free ? ( ) : ( )}
    )}
  • ); })}
)}

Guthaben-Historie

{detail.ledger.map((entry) => ( ))} {detail.ledger.length === 0 && ( )}
Zeitpunkt Topf Menge Anlass Notiz Von
{formatDate(entry.createdAt)} {KIND_LABEL[entry.kind]} {formatAmount(entry.amount)} {SOURCE_LABEL[entry.source]} {entry.note ?? '—'} {entry.character && ` (${entry.character.name})`} {entry.createdBy?.username ?? 'System'} {editor && !entry.reversedBy && entry.source !== 'CONSUME' && entry.source !== 'REVERSAL' && ( )} {entry.reversedBy && storniert}
Noch keine Buchungen.
{detail.ledgerTotal > detail.ledger.length && (

{detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt.

)}
{dialog.type === 'newCharacter' && ( setDialog({ type: 'none' })} onSubmit={(values) => void run( () => api(`/api/players/${playerId}/characters`, { method: 'POST', body: values, }), `CC "${values.name}" angelegt — 1 CC-Guthaben verbraucht.`, ) } /> )} {dialog.type === 'redesign' && ( setDialog({ type: 'none' })} onSubmit={(values) => void run( () => api(`/api/characters/${dialog.character.id}/redesign`, { method: 'POST', body: values, }), `Redesign gebucht: "${dialog.character.name}" → "${values.name}".`, ) } /> )} {dialog.type === 'deactivate' && ( setDialog({ type: 'none' })} onSubmit={(reason, note) => void run( () => api(`/api/characters/${dialog.character.id}/deactivate`, { method: 'POST', body: { reason, ...(note.trim() ? { note: note.trim() } : {}) }, }), `"${dialog.character.name}" ist jetzt inaktiv.`, ) } /> )} {dialog.type === 'reactivatePaid' && ( setDialog({ type: 'none' })} onSubmit={(useKind) => void run( () => api(`/api/characters/${dialog.character.id}/reactivate-paid`, { method: 'POST', body: { useKind }, }), `"${dialog.character.name}" ist wieder aktiv.`, ) } /> )} {dialog.type === 'revert' && ( setDialog({ type: 'none' })} onSubmit={(reason) => void run( () => api(`/api/characters/${dialog.character.id}/revert`, { method: 'POST', body: { reason }, }), `"${dialog.character.name}" wurde zurückgenommen.`, ) } /> )} {dialog.type === 'editPlayer' && ( setDialog({ type: 'none' })} onSubmit={(values) => void run( () => api(`/api/players/${playerId}`, { method: 'PATCH', body: values }), 'Spielerdaten gespeichert.', ) } /> )} {dialog.type === 'deletePlayer' && ( setDialog({ type: 'none' })} onConfirm={async () => { setBusy(true); try { await api(`/api/players/${playerId}`, { method: 'DELETE' }); navigate('/', { replace: true }); } catch (err) { setError(err instanceof ApiError ? err.message : 'Löschen fehlgeschlagen'); setBusy(false); } }} /> )} {dialog.type === 'reverse' && ( setDialog({ type: 'none' })} onSubmit={(reason) => void run( () => api(`/api/credits/${dialog.entry.id}/reverse`, { method: 'POST', body: { reason }, }), 'Buchung storniert.', ) } /> )}
); } interface CharacterValues { name: string; loreText?: string; useKind?: CreditKind; } function CharacterFormModal({ title, submitLabel, hint, busy, withKind, balances, onClose, onSubmit, }: { title: string; submitLabel: string; hint: string; busy: boolean; withKind?: CreditKind[]; balances?: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number }; onClose: () => void; onSubmit: (values: CharacterValues) => void; }) { const [name, setName] = useState(''); const [loreText, setLoreText] = useState(''); // Kulanz steht bewusst vorne: sie soll zuerst aufgebraucht werden. const [useKind, setUseKind] = useState(withKind?.[0] ?? 'CC'); return (
{ event.preventDefault(); onSubmit({ name, ...(loreText.trim() ? { loreText: loreText.trim() } : {}), ...(withKind ? { useKind } : {}), }); }} > {hint} setName(event.target.value)} autoFocus required />