Weboberflaeche mit Spielerkonto, Import und Verwaltung
Die Spieler-Detailseite beantwortet die Kernfrage direkt: drei grosse Zaehler, rot sobald ein Topf im Minus ist, darunter ob ein neuer CC oder ein Redesign gerade moeglich ist. Inaktive CCs zeigen ihren Grund und bieten genau die passende Aktion an - gratis reaktivieren oder bezahlt. Beim Redesign steht der Kulanz-Topf an erster Stelle, damit er zuerst aufgebraucht wird. Der Import fuehrt ueber Spaltenzuordnung und Vorschau; gebucht wird erst nach Bestaetigung.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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 } 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 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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage ausloest.
|
||||
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>
|
||||
{can('EDITOR') && (
|
||||
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
||||
Spieler anlegen
|
||||
</button>
|
||||
)}
|
||||
</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>}
|
||||
|
||||
<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)} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user