Antraege eingrenzen, Reaktivierung und Bestaetigungen

Sechs Ruecklaeufer aus dem ersten Praxistest:

Das Archiv reicht ueber 25 Seiten und enthaelt vieles, was kein CC-Antrag ist.
Aufgenommen werden jetzt nur Threads von Spielern, die im CC-Manager mit
passendem Forum-Namen erfasst sind. Angepinnte Ankuendigungen tragen die
Labels "Wichtig" oder "Information" und fallen darueber raus.

Der Titel wird bewusst nirgends ausgewertet - weder zum Erkennen noch zum
Aussortieren. Antraege heissen mal "CC Antrag | Name", mal "The Duke Rang
Update"; daran ist nichts abzulesen.

Eine Reaktivierung laeuft ebenfalls ueber einen Forums-Antrag und fehlte als
Entscheidung. Sie ist jetzt waehlbar und kostet nichts, solange der CC nicht
redesignt wurde - die bestehende Regel entscheidet das, nicht der Dialog.

Der Titel in der Liste verlinkt jetzt auf den Thread im Forum. Die Labels
stehen als Kontext daneben, ohne die Entscheidung vorwegzunehmen.

Wer einen Antrag von Hand eintraegt, kann beim Anlegen oder Redesignen den
Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht nicht
erneut als offen auf.

Manuelle Guthaben-Buchungen fragen vorher nach: bisher buchte ein einzelner
Klick sofort, ohne zu sagen, was genau passiert. Der Dialog nennt die Wirkung
und nimmt eine Notiz entgegen.
This commit is contained in:
2026-09-18 18:06:37 +02:00
parent 751f8bfe8e
commit bebb89c10e
13 changed files with 503 additions and 59 deletions
+95 -12
View File
@@ -7,6 +7,7 @@ import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
import { useSession } from '../lib/session';
import type {
Character,
ForumClassification,
ForumScanSummary,
ForumThread,
PlayerDetail,
@@ -51,8 +52,11 @@ export default function Forum() {
const parts = [`${result.scannedPages} Seiten gelesen`];
if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`);
if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`);
if (result.withoutPlayer > 0) parts.push(`${result.withoutPlayer} ohne Spieler`);
if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise übersprungen`);
if (result.skippedUnknownAuthor > 0) {
parts.push(`${result.skippedUnknownAuthor} ohne bekannten Spieler übersprungen`);
}
if (result.skippedLabels > 0) parts.push(`${result.skippedLabels} Ankündigungen`);
if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise`);
if (result.failedBoards.length > 0) parts.push(`${result.failedBoards.length} Board-Fehler`);
setFlash(parts.join(', ') + '.');
await load();
@@ -80,7 +84,8 @@ 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.
Signal und lösen nie eine Buchung aus. Aufgenommen werden nur Threads von Spielern,
die hier mit passendem Forum-Namen erfasst sind.
</p>
</div>
{admin && (
@@ -105,7 +110,9 @@ export default function Forum() {
{notices.map((thread) => (
<li key={thread.threadId} className="cc-item">
<div>
<strong>{thread.title}</strong>
<a href={thread.url} target="_blank" rel="noreferrer" className="link-strong">
{thread.title}
</a>
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
<p className="muted small">
{thread.classification && CLASSIFICATION_LABEL[thread.classification]}
@@ -141,8 +148,17 @@ export default function Forum() {
{threads.map((thread) => (
<li key={thread.threadId} className="cc-item">
<div>
<strong>{thread.title}</strong>
<a
href={thread.url}
target="_blank"
rel="noreferrer"
className="link-strong"
title="Im Forum öffnen"
>
{thread.title}
</a>
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
{thread.labels && <span className="muted small"> · {thread.labels}</span>}
<p className="muted small">
von {thread.authorName ?? 'unbekannt'} ·{' '}
{thread.player ? (
@@ -154,9 +170,6 @@ export default function Forum() {
</p>
</div>
<div className="cc-actions">
<a className="btn btn-small btn-ghost" href={thread.url} target="_blank" rel="noreferrer">
Thread
</a>
{admin && !thread.player && (
<button type="button" className="btn btn-small" onClick={() => setAssigning(thread)}>
Spieler zuordnen
@@ -218,11 +231,13 @@ function ClassifyModal({
onDone: (message: string) => void;
onError: (message: string) => void;
}) {
const [choice, setChoice] = useState<'NEW_CC' | 'REDESIGN' | 'REJECTED' | 'IGNORE'>('NEW_CC');
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.
@@ -232,13 +247,18 @@ function ClassifyModal({
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';
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();
@@ -248,8 +268,11 @@ function ClassifyModal({
method: 'POST',
body: {
classification: choice,
...(needsPlayer ? { characterName: name.trim() } : {}),
...(needsName ? { characterName: name.trim() } : {}),
...(choice === 'REDESIGN' ? { predecessorId, useKind } : {}),
...(choice === 'REACTIVATION'
? { characterId: reviveId, ...(reaktivierungKostet ? { useKind } : {}) }
: {}),
},
});
onDone(`Antrag entschieden: ${CLASSIFICATION_LABEL[choice]}.`);
@@ -275,6 +298,7 @@ function ClassifyModal({
[
['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
@@ -300,7 +324,7 @@ function ClassifyModal({
</Notice>
)}
{needsPlayer && thread.playerId && (
{needsName && thread.playerId && (
<>
<label htmlFor="cc-name">Name des CCs</label>
<input
@@ -313,6 +337,65 @@ function ClassifyModal({
</>
)}
{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>