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:
@@ -0,0 +1,328 @@
|
||||
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);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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_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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user