Files
CC-Manager/web/src/pages/Dashboard.tsx
T
Harlekin 6c50d32d2b Rollen neu geschnitten, Seitenzahl korrigiert, frühere Konten
Der Forums-Abgleich las von 25 Archivseiten nur zwei. WoltLab verlinkt in der
Blätterleiste nur die Nachbarseiten und führt die Gesamtzahl in einem eigenen
Element; ausgewertet wurden aber die Links. Alles jenseits von Seite zwei war
damit unsichtbar - etwa ein Antrag auf Seite vier, der deshalb nie ankam.

Die Rollen verlaufen jetzt zwischen erfassen und gewähren statt zwischen
Alltag und Verwaltung: Editoren erfassen Käufe und führen Datei-Import,
GExtension- und Forums-Abgleich samt Entscheidung über Anträge durch. Kulanz
zu vergeben bleibt Administratoren, ebenso das Auflösen unklarer Pakete, weil
dabei Kulanz entstehen kann.

Dazu lassen sich frühere Steam- und Forumskonten am Spieler hinterlegen.
Abgleich und Import prüfen sie mit, sodass ein Kontowechsel die Zuordnung
nicht abreißen lässt. Steam-Kennungen werden dabei auf dieselbe Form gebracht
wie die Hauptangabe, und eine Kennung kann nicht zwei Spielern gehören.
2026-09-18 20:07:56 +02:00

287 lines
9.5 KiB
TypeScript

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 (
<span className={`mini-counter${value < 0 ? ' is-negative' : ''}`} title={title}>
{value}
</span>
);
}
export default function Dashboard() {
const { can } = useSession();
const [query, setQuery] = useState('');
const [result, setResult] = useState<SearchResult>({ total: 0, items: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] });
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
const load = useCallback(async (search: string, signal?: AbortSignal) => {
setLoading(true);
try {
const params = new URLSearchParams({ q: search, take: '100' });
const data = await api<SearchResult>(`/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<UnmatchedOverview>('/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<ScrapeSummary>('/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 (
<div className="page">
<div className="page-head">
<h1>Spieler</h1>
<div className="head-actions">
{can('EDITOR') && (
<button type="button" className="btn" disabled={syncing} onClick={() => void runSync()}>
{syncing ? 'Gleiche ab …' : 'GExtension abgleichen'}
</button>
)}
{can('EDITOR') && (
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
Spieler anlegen
</button>
)}
</div>
</div>
<input
className="search"
type="search"
placeholder="Name, SteamID oder Forum-Name suchen …"
value={query}
onChange={(event) => setQuery(event.target.value)}
autoFocus
/>
{error && <Notice kind="error">{error}</Notice>}
{syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>}
{flagged.total > 0 && (
<Notice kind="warn">
<strong>
{flagged.total} ungeklärte {flagged.total === 1 ? 'Eintrag' : 'Einträge'} bei{' '}
{flagged.players.length} {flagged.players.length === 1 ? 'Spieler' : 'Spielern'}:
</strong>{' '}
{flagged.players.map((entry, index) => (
<span key={entry.playerId}>
{index > 0 && ', '}
<Link to={`/players/${entry.playerId}`}>
{entry.displayName} ({entry.count})
</Link>
</span>
))}
</Notice>
)}
<div className="table-wrap">
<table className="table">
<thead>
<tr>
<th>Spieler</th>
<th>SteamID</th>
<th className="num">CC</th>
<th className="num">Redesign</th>
<th className="num">Kulanz</th>
<th className="num">Aktive CCs</th>
</tr>
</thead>
<tbody>
{result.items.map((player) => (
<tr key={player.id}>
<td>
<Link to={`/players/${player.id}`} className="link-strong">
{player.displayName}
</Link>
{player.forumName && <span className="muted"> · {player.forumName}</span>}
</td>
<td className="mono muted">{player.steamId ?? '—'}</td>
<td className="num">
<Counter value={player.balances.CC} title="CC-Guthaben" />
</td>
<td className="num">
<Counter value={player.balances.REDESIGN_PAID} title="Redesign-Guthaben" />
</td>
<td className="num">
<Counter value={player.balances.REDESIGN_KULANZ} title="Kulanz-Guthaben" />
</td>
<td className="num muted">
{player.activeCharacterCount} / {player.characterCount}
</td>
</tr>
))}
{!loading && result.items.length === 0 && (
<tr>
<td colSpan={6} className="empty">
{query ? 'Kein Treffer.' : 'Noch keine Spieler erfasst.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
{loading && <p className="muted">Lade …</p>}
{!loading && result.total > result.items.length && (
<p className="muted">
{result.items.length} von {result.total} angezeigt — Suche eingrenzen.
</p>
)}
{creating && (
<CreatePlayerModal
onClose={() => setCreating(false)}
onCreated={() => {
setCreating(false);
void load(query);
}}
/>
)}
</div>
);
}
function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [displayName, setDisplayName] = useState('');
const [steamId, setSteamId] = useState('');
const [forumName, setForumName] = useState('');
const [error, setError] = useState<string | null>(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 (
<Modal title="Spieler anlegen" onClose={onClose}>
<form onSubmit={submit}>
{error && <Notice kind="error">{error}</Notice>}
<label htmlFor="displayName">Anzeigename</label>
<input
id="displayName"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
autoFocus
required
/>
<label htmlFor="steamId">SteamID (optional)</label>
<input
id="steamId"
value={steamId}
onChange={(event) => setSteamId(event.target.value)}
placeholder="76561198... oder STEAM_0:1:..."
/>
<p className="muted small">
Beide Schreibweisen sind möglich, auch <code className="mono">[U:1:...]</code>.
Gespeichert wird immer die 64-Bit-Form, damit derselbe Spieler nicht doppelt anlegt.
</p>
<label htmlFor="forumName">Forum-Name (optional)</label>
<input
id="forumName"
value={forumName}
onChange={(event) => setForumName(event.target.value)}
/>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Lege an …' : 'Anlegen'}
</button>
</div>
</form>
</Modal>
);
}