Files
CC-Manager/web/src/components/ThreadModals.tsx
T
Harlekin 1f4034b397 Jede Liste gehoert einem Spieler, CC-Guthaben wird umgewandelt
Ein Gruppen-CC wird fuer jeden Nutzer einzeln gerechnet: fuer den einen
kann er der gekaufte Platz sein, fuer den anderen ein Redesign seines
vorherigen CCs. Dafuer haengt die Liste jetzt an der Mitgliedschaft
statt am CC, und verschieben ordnet nur die eigene Liste.

Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben
dafuer einsetzt, wandelt es um: ein Platz weniger, ein bezahltes
Redesign mehr. Damit entfaellt die Ausnahme, die sich bisher durch
Slot-Pruefung und Kettenrechnung zog.

Beim Start werden Buchungen einmalig an die Listen angeglichen.
2026-09-22 12:21:53 +02:00

332 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
import Modal from './Modal';
import Notice from './Notice';
import { ApiError, api } from '../lib/api';
import { CLASSIFICATION_LABEL } from '../lib/format';
import type {
Character,
ForumClassification,
ForumThread,
PlayerDetail,
PlayerSummary,
} from '../lib/types';
export 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);
// Der Regelfall ist das bezahlte Redesign. Kulanz ist die Ausnahme und
// wird bewusst vergeben - sonst entstehen Kulanz-Buchungen, die niemand
// gewollt hat.
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_PAID');
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);
}
};
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>
<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>
{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_PAID'}
onChange={() => setUseKind('REDESIGN_PAID')}
/>
<span>Bezahltes Redesign</span>
</label>
<label className="radio">
<input
type="radio"
name="reviveKind"
checked={useKind === 'REDESIGN_KULANZ'}
onChange={() => setUseKind('REDESIGN_KULANZ')}
/>
<span>Kulanz-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_PAID'}
onChange={() => setUseKind('REDESIGN_PAID')}
/>
<span>Bezahltes Redesign</span>
</label>
<label className="radio">
<input
type="radio"
name="useKind"
checked={useKind === 'REDESIGN_KULANZ'}
onChange={() => setUseKind('REDESIGN_KULANZ')}
/>
<span>Kulanz-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>
);
}
export 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>
);
}