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.
332 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|