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:
+95
-12
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user