Antraege durchsuchbar, Fortschritt sichtbar, Spieler an einem Stueck

Der Abgleich zeigt jetzt, was er tut. Gelesen wird zuerst von jedem Board die
erste Seite - danach steht der Umfang fest, und der Balken zeigt einen
ehrlichen Anteil statt einer Schaetzung. Ein Protokoll schreibt Seite fuer
Seite mit.

Beide Antragslisten lassen sich durchsuchen und wahlweise mit dem neuesten
oder dem aeltesten Antrag zuerst anzeigen. Die Liste ohne zugeordneten
Spieler blaettert seitenweise, statt bei fuenfhundert Eintraegen die ersten
fuenfzig zu zeigen und den Rest zu verschweigen.

Neu darueber: "Ersteller ohne Spieler" - dieselben Antraege, nach Ersteller
zusammengefasst. Wer dreiundzwanzig Antraege gestellt hat, faellt dort auf,
waehrend er zwischen fuenfhundert Einzelantraegen untergeht. Ein Klick legt
den Spieler mit bereits eingetragenem Forum-Namen an.

Ein Spieler laesst sich jetzt an einem Stueck abarbeiten, statt zwischen drei
Seiten zu wechseln:

- Seine Forums-Antraege stehen auf der Spielerseite, aeltester zuerst, jeder
  als Link ins Forum. Offene sind dort direkt entscheidbar, ueber denselben
  Weg wie auf der Antraege-Seite - danach stehen sie dort nicht mehr offen.
- Pakete, die noch in einer Import-Vorschau liegen, erscheinen unter "Offene
  Pakete aus dem Abgleich" und lassen sich einzeln oder am Stueck buchen.
  Gebuchtes wird ueberall abgehakt, wo dieselbe Bestellung noch offen liegt,
  auch in einem zweiten Import - doppelt buchen ist ausgeschlossen. Wer ein
  Paket gar nicht buchen will, legt es weg, ohne das Konto zu beruehren.

Die beiden Antrags-Dialoge liegen dafuer jetzt unter components/, weil beide
Seiten sie brauchen.
This commit is contained in:
2026-09-21 00:49:38 +02:00
parent e65fdfc0ad
commit 293d882cb4
16 changed files with 1655 additions and 431 deletions
+25 -6
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
@@ -25,6 +25,10 @@ export default function Dashboard() {
const [result, setResult] = useState<SearchResult>({ total: 0, items: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Von der Anträge-Seite aus: "Spieler anlegen" für einen Ersteller, dessen
// Forum-Name schon feststeht.
const [params, setParams] = useSearchParams();
const vorgabe = params.get('anlegen');
const [creating, setCreating] = useState(false);
const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] });
const [syncing, setSyncing] = useState(false);
@@ -215,11 +219,16 @@ export default function Dashboard() {
</p>
)}
{creating && (
{(creating || vorgabe !== null) && (
<CreatePlayerModal
onClose={() => setCreating(false)}
forumNameVorgabe={vorgabe ?? ''}
onClose={() => {
setCreating(false);
if (vorgabe !== null) setParams({}, { replace: true });
}}
onCreated={() => {
setCreating(false);
if (vorgabe !== null) setParams({}, { replace: true });
void load(query, nurProbleme);
}}
/>
@@ -228,10 +237,20 @@ export default function Dashboard() {
);
}
function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [displayName, setDisplayName] = useState('');
function CreatePlayerModal({
forumNameVorgabe,
onClose,
onCreated,
}: {
forumNameVorgabe: string;
onClose: () => void;
onCreated: () => void;
}) {
// Kommt der Aufruf von einem Forums-Ersteller, ist der Name schon bekannt -
// und taugt bis auf Weiteres auch als Anzeigename.
const [displayName, setDisplayName] = useState(forumNameVorgabe);
const [steamId, setSteamId] = useState('');
const [forumName, setForumName] = useState('');
const [forumName, setForumName] = useState(forumNameVorgabe);
const [istProblem, setIstProblem] = useState(false);
const [problemNote, setProblemNote] = useState('');
const [error, setError] = useState<string | null>(null);
+271 -381
View File
@@ -1,19 +1,17 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import Modal from '../components/Modal';
import { Link, useNavigate } from 'react-router-dom';
import Notice from '../components/Notice';
import { AssignModal, ClassifyModal } from '../components/ThreadModals';
import { ApiError, api } from '../lib/api';
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
import { useSession } from '../lib/session';
import type {
Character,
ForumClassification,
ForumScanSummary,
ForumThread,
PlayerDetail,
PlayerSummary,
ScanState,
ThreadOrder,
UnassignedThreads,
UntrackedAuthor,
} from '../lib/types';
/// Die Board-Pfade des Abgleichs in lesbarer Form.
@@ -23,8 +21,11 @@ const BOARD_PFAD: Record<string, string> = {
'board/113-cc-archiv': 'das Archiv',
};
const SEITE = 25;
export default function Forum() {
const { can } = useSession();
const navigate = useNavigate();
const darfEntscheiden = can('EDITOR');
const [threads, setThreads] = useState<ForumThread[]>([]);
@@ -34,36 +35,73 @@ export default function Forum() {
const [busy, setBusy] = useState(false);
const [dialog, setDialog] = useState<ForumThread | null>(null);
const [assigning, setAssigning] = useState<ForumThread | null>(null);
const [ohneSpieler, setOhneSpieler] = useState<UnassignedThreads | null>(null);
const [suche, setSuche] = useState('');
const [zeigeOhneSpieler, setZeigeOhneSpieler] = useState(false);
/// Hochzählen heißt: die Liste ohne Spieler neu holen.
const [stand, setStand] = useState(0);
const [scanZustand, setScan] = useState<ScanState | null>(null);
const ladeOhneSpieler = useCallback(async (query: string) => {
const param = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : '';
// Arbeitsliste
const [sucheOffen, setSucheOffen] = useState('');
const [sortOffen, setSortOffen] = useState<ThreadOrder>('newest');
// Ohne zugeordneten Spieler
const [ohneSpieler, setOhneSpieler] = useState<UnassignedThreads | null>(null);
const [sucheOhne, setSucheOhne] = useState('');
const [sortOhne, setSortOhne] = useState<ThreadOrder>('newest');
const [seite, setSeite] = useState(0);
const [zeigeOhneSpieler, setZeigeOhneSpieler] = useState(false);
// Ersteller ohne Spieler
const [autoren, setAutoren] = useState<UntrackedAuthor[]>([]);
const [zeigeAutoren, setZeigeAutoren] = useState(false);
/// Hochzählen heißt: die abhängigen Listen neu holen.
const [stand, setStand] = useState(0);
const ladeOffene = useCallback(async (query: string, order: ThreadOrder) => {
const params = new URLSearchParams({ open: 'true', order });
if (query.trim()) params.set('q', query.trim());
try {
setOhneSpieler(await api<UnassignedThreads>(`/api/forum/threads/unassigned${param}`));
const data = await api<{ items: ForumThread[] }>(`/api/forum/threads?${params}`);
setThreads(data.items);
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
}, []);
const ladeOhneSpieler = useCallback(
async (query: string, order: ThreadOrder, skip: number) => {
const params = new URLSearchParams({
order,
take: String(SEITE),
skip: String(skip),
});
if (query.trim()) params.set('q', query.trim());
try {
setOhneSpieler(await api<UnassignedThreads>(`/api/forum/threads/unassigned?${params}`));
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
},
[],
);
const ladeAutoren = useCallback(async () => {
try {
const data = await api<{ items: UntrackedAuthor[] }>('/api/forum/authors/untracked');
setAutoren(data.items);
} catch {
// Die Liste ist Beiwerk; ein Fehler hier darf den Rest nicht stören.
}
}, []);
const load = useCallback(async () => {
try {
const [open, notice] = await Promise.all([
api<{ items: ForumThread[] }>('/api/forum/threads?open=true'),
api<{ items: ForumThread[] }>('/api/forum/notices'),
]);
setThreads(open.items);
setNotices(notice.items);
setNotices((await api<{ items: ForumThread[] }>('/api/forum/notices')).items);
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
await ladeAutoren();
setStand((n) => n + 1);
}, []);
}, [ladeAutoren]);
useEffect(() => {
void load();
@@ -71,9 +109,25 @@ export default function Forum() {
// Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken.
useEffect(() => {
const timer = window.setTimeout(() => void ladeOhneSpieler(suche), suche ? 250 : 0);
const timer = window.setTimeout(
() => void ladeOffene(sucheOffen, sortOffen),
sucheOffen ? 250 : 0,
);
return () => window.clearTimeout(timer);
}, [suche, stand, ladeOhneSpieler]);
}, [sucheOffen, sortOffen, stand, ladeOffene]);
useEffect(() => {
const timer = window.setTimeout(
() => void ladeOhneSpieler(sucheOhne, sortOhne, seite * SEITE),
sucheOhne ? 250 : 0,
);
return () => window.clearTimeout(timer);
}, [sucheOhne, sortOhne, seite, stand, ladeOhneSpieler]);
// Eine neue Suche fängt wieder auf Seite eins an.
useEffect(() => {
setSeite(0);
}, [sucheOhne, sortOhne]);
const bericht = (result: ForumScanSummary): string => {
const parts = [`${result.scannedPages} Seiten gelesen`];
@@ -100,27 +154,26 @@ export default function Forum() {
useEffect(() => {
if (!busy) return;
const timer = window.setInterval(() => {
void (async () => {
try {
const state = await api<ScanState>('/api/forum/scan/status');
setScan(state);
if (state.running) return;
const abfragen = async () => {
try {
const state = await api<ScanState>('/api/forum/scan/status');
setScan(state);
if (state.running) return;
setBusy(false);
if (state.error) setError(state.error);
else if (state.summary) {
setFlash(bericht(state.summary));
window.setTimeout(() => setFlash(null), 12000);
}
await load();
} catch (err) {
setBusy(false);
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
setBusy(false);
if (state.error) setError(state.error);
else if (state.summary) {
setFlash(bericht(state.summary));
window.setTimeout(() => setFlash(null), 12000);
}
})();
}, 2000);
await load();
} catch (err) {
setBusy(false);
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
}
};
const timer = window.setInterval(() => void abfragen(), 1500);
return () => window.clearInterval(timer);
}, [busy, load]);
@@ -156,6 +209,8 @@ export default function Forum() {
}
};
const seiten = ohneSpieler ? Math.ceil(ohneSpieler.total / SEITE) : 0;
return (
<div className="page">
<div className="page-head">
@@ -163,15 +218,13 @@ export default function Forum() {
<h1>Forums-Anträge</h1>
<p className="muted">
Jeder Antrag wird von Hand entschieden - Titel und Board sind kein verlässliches
Signal und lösen nie eine Buchung aus. Aufgenommen werden nur Threads von Spielern,
die hier mit passendem Forum-Namen erfasst sind.
Signal und lösen nie eine Buchung aus. Anträge von Erstellern, die hier unter keinem
Forum-Namen geführt werden, stehen weiter unten.
</p>
</div>
{darfEntscheiden && (
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}>
{busy
? `Lese Forum … (${scanZustand?.pagesRead ?? 0} Seiten)`
: 'Forum abgleichen'}
{busy ? 'Lese Forum …' : 'Forum abgleichen'}
</button>
)}
</div>
@@ -179,14 +232,7 @@ export default function Forum() {
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
{busy && (
<Notice kind="warn">
Der Abgleich liest gerade {BOARD_PFAD[scanZustand?.currentBoard ?? ''] ?? 'das Forum'} -
bisher {scanZustand?.pagesRead ?? 0} Seiten. Das Archiv umfasst über zwanzig Seiten, und
zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der
Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird.
</Notice>
)}
{busy && <ScanProgress state={scanZustand} />}
{notices.length > 0 && (
<section className="block block-flag">
@@ -229,9 +275,28 @@ export default function Forum() {
<section className="block">
<h2>Wartet auf Entscheidung ({threads.length})</h2>
<div className="list-tools">
<input
type="search"
placeholder="Nach Titel oder Ersteller suchen …"
value={sucheOffen}
onChange={(event) => setSucheOffen(event.target.value)}
/>
<select
value={sortOffen}
onChange={(event) => setSortOffen(event.target.value as ThreadOrder)}
aria-label="Sortierung"
>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
</select>
</div>
{threads.length === 0 ? (
<p className="muted">
Keine offenen Anträge. {darfEntscheiden && 'Über „Forum abgleichen" nach neuen suchen.'}
{sucheOffen
? 'Kein Treffer.'
: `Keine offenen Anträge. ${darfEntscheiden ? 'Über „Forum abgleichen" nach neuen suchen.' : ''}`}
</p>
) : (
<ul className="cc-list">
@@ -281,14 +346,77 @@ export default function Forum() {
)}
</section>
<section className="block">
<div className="page-head">
<div>
<h2>Ersteller ohne Spieler ({autoren.length})</h2>
<p className="muted small">
Wer im Forum Anträge gestellt hat, hier aber unter keinem Forum-Namen geführt wird.
Ein Klick legt den Spieler an - der Forum-Name steht schon drin, und seine Anträge
wandern anschließend von selbst in die Arbeitsliste.
</p>
</div>
{autoren.length > 0 && (
<button
type="button"
className="btn btn-small btn-ghost"
onClick={() => setZeigeAutoren((v) => !v)}
>
{zeigeAutoren ? 'Einklappen' : 'Anzeigen'}
</button>
)}
</div>
{autoren.length === 0 ? (
<p className="muted">Jeder Ersteller ist einem Spieler zugeordnet.</p>
) : (
zeigeAutoren && (
<ul className="cc-list">
{autoren.map((autor) => (
<li key={autor.authorName} className="cc-item">
<div>
<strong>{autor.authorName}</strong>
<span className="badge">
{autor.threadCount} {autor.threadCount === 1 ? 'Antrag' : 'Anträge'}
</span>
</div>
<div className="cc-actions">
<button
type="button"
className="btn btn-small btn-ghost"
onClick={() => {
setSucheOhne(autor.authorName);
setZeigeOhneSpieler(true);
}}
>
Anträge zeigen
</button>
{darfEntscheiden && (
<button
type="button"
className="btn btn-small btn-primary"
onClick={() =>
navigate(`/?anlegen=${encodeURIComponent(autor.authorName)}`)
}
>
Spieler anlegen
</button>
)}
</div>
</li>
))}
</ul>
)
)}
</section>
<section className="block">
<div className="page-head">
<div>
<h2>Ohne zugeordneten Spieler ({ohneSpieler?.total ?? 0})</h2>
<p className="muted small">
Anträge, deren Ersteller hier unter keinem Forum-Namen geführt wird. Sie bleiben
aus der Arbeitsliste heraus, gehen aber nicht verloren. Sobald ein Spieler mit
passendem Forum-Namen angelegt wird, wandern sie von selbst hinüber.
Einzelne Anträge, deren Ersteller hier nicht geführt wird. Sie bleiben aus der
Arbeitsliste heraus, gehen aber nicht verloren.
</p>
</div>
<button
@@ -302,21 +430,30 @@ export default function Forum() {
{zeigeOhneSpieler && (
<>
<input
type="search"
className="input"
placeholder="Nach Titel oder Ersteller suchen …"
value={suche}
onChange={(event) => setSuche(event.target.value)}
/>
<div className="list-tools">
<input
type="search"
placeholder="Nach Titel oder Ersteller suchen …"
value={sucheOhne}
onChange={(event) => setSucheOhne(event.target.value)}
/>
<select
value={sortOhne}
onChange={(event) => setSortOhne(event.target.value as ThreadOrder)}
aria-label="Sortierung"
>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Älteste zuerst</option>
</select>
</div>
{ohneSpieler === null || ohneSpieler.items.length === 0 ? (
<p className="muted" style={{ marginTop: '0.75rem' }}>
{suche ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'}
<p className="muted">
{sucheOhne ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'}
</p>
) : (
<>
<ul className="cc-list" style={{ marginTop: '0.75rem' }}>
<ul className="cc-list">
{ohneSpieler.items.map((thread) => (
<li key={thread.threadId} className="cc-item">
<div>
@@ -349,11 +486,29 @@ export default function Forum() {
</li>
))}
</ul>
{ohneSpieler.total > ohneSpieler.items.length && (
<p className="muted small">
Zeigt {ohneSpieler.items.length} von {ohneSpieler.total} - über die Suche
eingrenzen.
</p>
{seiten > 1 && (
<div className="pager">
<button
type="button"
className="btn btn-small btn-ghost"
disabled={seite === 0}
onClick={() => setSeite((n) => Math.max(0, n - 1))}
>
Zurück
</button>
<span className="muted small">
Seite {seite + 1} von {seiten} · {ohneSpieler.total} Anträge
</span>
<button
type="button"
className="btn btn-small btn-ghost"
disabled={seite + 1 >= seiten}
onClick={() => setSeite((n) => n + 1)}
>
Weiter
</button>
</div>
)}
</>
)}
@@ -390,318 +545,53 @@ export default function Forum() {
);
}
function ClassifyModal({
thread,
onClose,
onDone,
onError,
}: {
thread: ForumThread;
onClose: () => void;
onDone: (message: string) => void;
onError: (message: string) => void;
}) {
const [choice, setChoice] = useState<ForumClassification>('NEW_CC');
const [name, setName] = useState(thread.title);
const [predecessorId, setPredecessorId] = useState<number | null>(null);
const [reviveId, setReviveId] = useState<number | null>(null);
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
const [characters, setCharacters] = useState<Character[]>([]);
const [inactive, setInactive] = useState<Character[]>([]);
const [busy, setBusy] = useState(false);
// Für ein Redesign braucht der Admin die Liste der aktiven CCs des Spielers.
useEffect(() => {
if (!thread.playerId) return;
void (async () => {
try {
const detail = await api<PlayerDetail>(`/api/players/${thread.playerId}`);
setCharacters(detail.characters.filter((c) => c.status === 'ACTIVE'));
setInactive(detail.characters.filter((c) => c.status === 'INACTIVE'));
} catch {
// Ohne Liste bleibt nur die Ablehnung - das Formular sagt es.
}
})();
}, [thread.playerId]);
const needsPlayer = choice === 'NEW_CC' || choice === 'REDESIGN' || choice === 'REACTIVATION';
const needsName = choice === 'NEW_CC' || choice === 'REDESIGN';
// Ein redesignter CC existiert im Original nicht mehr und kostet deshalb.
const gewaehlterCC = inactive.find((c) => c.id === reviveId);
const reaktivierungKostet = gewaehlterCC?.inactiveReason === 'REWORKED';
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setBusy(true);
try {
await api(`/api/forum/threads/${thread.threadId}/classify`, {
method: 'POST',
body: {
classification: choice,
...(needsName ? { characterName: name.trim() } : {}),
...(choice === 'REDESIGN' ? { predecessorId, useKind } : {}),
...(choice === 'REACTIVATION'
? { characterId: reviveId, ...(reaktivierungKostet ? { useKind } : {}) }
: {}),
},
});
onDone(`Antrag entschieden: ${CLASSIFICATION_LABEL[choice]}.`);
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Entscheidung fehlgeschlagen');
} finally {
setBusy(false);
}
};
/**
* Was der Abgleich gerade tut. Der Umfang steht erst fest, wenn von jedem
* Board die erste Seite gelesen ist - bis dahin läuft der Balken durch,
* statt einen erfundenen Anteil zu behaupten.
*/
function ScanProgress({ state }: { state: ScanState | null }) {
const gelesen = state?.pagesRead ?? 0;
const gesamt = state?.totalPages ?? 0;
const bekannt = gesamt > 0;
const anteil = bekannt ? Math.min(100, Math.round((gelesen / gesamt) * 100)) : 0;
const letzte = state?.log.slice(-6) ?? [];
return (
<Modal title="Antrag entscheiden" onClose={onClose}>
<form onSubmit={submit}>
<Notice kind="info">
<strong>{thread.title}</strong>
<br />
von {thread.authorName ?? 'unbekannt'}
{thread.player ? ` · ${thread.player.displayName}` : ' · kein Spieler zugeordnet'}
</Notice>
<section className="block progress">
<div className="progress-head">
<strong>
{bekannt
? `${gelesen} von ${gesamt} Seiten gelesen`
: `Umfang wird ermittelt … (${gelesen} Seiten)`}
</strong>
<span className="muted">
{state?.currentBoard
? `liest ${BOARD_PFAD[state.currentBoard] ?? state.currentBoard}`
: 'wird gespeichert'}
{bekannt && ` · ${anteil} %`}
</span>
</div>
<div className="radio-group">
{(
[
['NEW_CC', 'Angenommen - neuer CC', 'verbraucht 1 CC-Guthaben'],
['REDESIGN', 'Angenommen - Redesign', 'verbraucht 1 Redesign-Guthaben'],
['REACTIVATION', 'Angenommen - Reaktivierung', 'ein inaktiver CC kehrt zurück'],
['REJECTED', 'Abgelehnt', 'keine Buchung'],
['IGNORE', 'Kein Antrag', 'keine Buchung'],
] as const
).map(([value, label, hint]) => (
<label key={value} className="radio">
<input
type="radio"
name="classification"
checked={choice === value}
onChange={() => setChoice(value)}
/>
<span>
<strong>{label}</strong>
<span className="muted"> - {hint}</span>
</span>
</label>
<div className="progress-bar">
<div
className={bekannt ? 'progress-fill' : 'progress-fill is-unknown'}
style={bekannt ? { width: `${anteil}%` } : undefined}
/>
</div>
{letzte.length > 0 && (
<ul className="progress-log">
{letzte.map((zeile, index) => (
<li key={`${index}-${zeile}`}>{zeile}</li>
))}
</div>
</ul>
)}
{needsPlayer && !thread.playerId && (
<Notice kind="warn">
Ohne zugeordneten Spieler lässt sich nichts buchen. Bitte erst „Spieler zuordnen".
</Notice>
)}
{needsName && thread.playerId && (
<>
<label htmlFor="cc-name">Name des CCs</label>
<input
id="cc-name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
<p className="muted small">Vorbelegt mit dem Thread-Titel - bitte anpassen.</p>
</>
)}
{choice === 'REACTIVATION' && thread.playerId && (
<>
<label htmlFor="revive">Welcher CC kehrt zurück?</label>
<select
id="revive"
value={reviveId ?? ''}
onChange={(event) => setReviveId(Number(event.target.value) || null)}
required
>
<option value="">- bitte wählen -</option>
{inactive.map((character) => (
<option key={character.id} value={character.id}>
{character.name}
{character.inactiveReason === 'REWORKED' ? ' (redesignt - kostet)' : ' (gratis)'}
</option>
))}
</select>
{inactive.length === 0 && (
<p className="muted small neg">Dieser Spieler hat keine inaktiven CCs.</p>
)}
{gewaehlterCC && !reaktivierungKostet && (
<Notice kind="success">
Kommt gratis zurück - der CC existiert unverändert weiter.
</Notice>
)}
{reaktivierungKostet && (
<>
<Notice kind="warn">
Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr
kostet ein Guthaben.
</Notice>
<label>Womit bezahlen?</label>
<div className="radio-group">
<label className="radio">
<input
type="radio"
name="reviveKind"
checked={useKind === 'REDESIGN_KULANZ'}
onChange={() => setUseKind('REDESIGN_KULANZ')}
/>
<span>Kulanz-Redesign</span>
</label>
<label className="radio">
<input
type="radio"
name="reviveKind"
checked={useKind === 'REDESIGN_PAID'}
onChange={() => setUseKind('REDESIGN_PAID')}
/>
<span>Bezahltes Redesign</span>
</label>
</div>
</>
)}
</>
)}
{choice === 'REDESIGN' && thread.playerId && (
<>
<label htmlFor="vorgaenger">Welcher CC wurde redesignt?</label>
<select
id="vorgaenger"
value={predecessorId ?? ''}
onChange={(event) => setPredecessorId(Number(event.target.value) || null)}
required
>
<option value="">- bitte wählen -</option>
{characters.map((character) => (
<option key={character.id} value={character.id}>
{character.name}
</option>
))}
</select>
{characters.length === 0 && (
<p className="muted small neg">Dieser Spieler hat keine aktiven CCs.</p>
)}
<label>Womit bezahlen?</label>
<div className="radio-group">
<label className="radio">
<input
type="radio"
name="useKind"
checked={useKind === 'REDESIGN_KULANZ'}
onChange={() => setUseKind('REDESIGN_KULANZ')}
/>
<span>Kulanz-Redesign</span>
</label>
<label className="radio">
<input
type="radio"
name="useKind"
checked={useKind === 'REDESIGN_PAID'}
onChange={() => setUseKind('REDESIGN_PAID')}
/>
<span>Bezahltes Redesign</span>
</label>
</div>
</>
)}
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button
type="submit"
className="btn btn-primary"
disabled={busy || (needsPlayer && !thread.playerId)}
>
{busy ? 'Speichere …' : 'Entscheiden'}
</button>
</div>
</form>
</Modal>
);
}
function AssignModal({
thread,
onClose,
onDone,
onError,
}: {
thread: ForumThread;
onClose: () => void;
onDone: () => void;
onError: (message: string) => void;
}) {
const [query, setQuery] = useState(thread.authorName ?? '');
const [results, setResults] = useState<PlayerSummary[]>([]);
useEffect(() => {
const timer = setTimeout(async () => {
try {
const params = new URLSearchParams({ q: query, take: '20' });
const data = await api<{ items: PlayerSummary[] }>(`/api/players?${params}`);
setResults(data.items);
} catch {
// Suchfehler blockieren den Dialog nicht.
}
}, 250);
return () => clearTimeout(timer);
}, [query]);
const assign = async (playerId: number) => {
try {
await api(`/api/forum/threads/${thread.threadId}/player`, {
method: 'POST',
body: { playerId },
});
onDone();
} catch (err) {
onError(err instanceof ApiError ? err.message : 'Zuordnen fehlgeschlagen');
}
};
return (
<Modal title="Spieler zuordnen" onClose={onClose}>
<Notice kind="info">
Forumsname des Antragstellers: <strong>{thread.authorName ?? 'unbekannt'}</strong>. Passt
kein Eintrag, trag den Forumsnamen beim Spieler nach - dann findet der nächste Abgleich
ihn von allein.
</Notice>
<label htmlFor="suche">Spieler suchen</label>
<input
id="suche"
value={query}
onChange={(event) => setQuery(event.target.value)}
autoFocus
/>
<ul className="cc-list" style={{ marginTop: '0.75rem' }}>
{results.map((player) => (
<li key={player.id} className="cc-item">
<div>
<strong>{player.displayName}</strong>
{player.forumName && <span className="muted"> · Forum: {player.forumName}</span>}
</div>
<div className="cc-actions">
<button
type="button"
className="btn btn-small btn-primary"
onClick={() => void assign(player.id)}
>
Zuordnen
</button>
</div>
</li>
))}
{results.length === 0 && <p className="muted">Kein Treffer.</p>}
</ul>
</Modal>
<p className="muted small">
Zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der
Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird.
</p>
</section>
);
}
+24
View File
@@ -3,6 +3,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import BalanceCards from '../components/BalanceCards';
import UnmatchedBox from '../components/UnmatchedBox';
import AliasBox from '../components/AliasBox';
import PendingRowsBox from '../components/PendingRowsBox';
import ThreadsBox from '../components/ThreadsBox';
import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { ApiError, api } from '../lib/api';
@@ -231,6 +233,17 @@ export default function PlayerDetail() {
onResolved={() => void load()}
/>
<PendingRowsBox
rows={detail.pendingRows ?? []}
canEdit={editor}
onChanged={() => void load()}
onError={setError}
onFlash={(text) => {
setFlash(text);
window.setTimeout(() => setFlash(null), 6000);
}}
/>
{editor && (
<section className="action-bar">
<button
@@ -441,6 +454,17 @@ export default function PlayerDetail() {
)}
</section>
<ThreadsBox
threads={detail.threads ?? []}
canEdit={editor}
onChanged={() => void load()}
onError={setError}
onFlash={(text) => {
setFlash(text);
window.setTimeout(() => setFlash(null), 6000);
}}
/>
<AliasBox
playerId={playerId}
aliases={detail.aliases ?? []}