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.
287 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|