Forums-Anträge erfassen und von Hand entscheiden
Der GExtension-Abgleich liefert die Käufe, der Verbrauch steht im Forum. Die App liest jetzt die drei CC-Boards und sammelt die Anträge. Gelesen wird nur die Board-Übersicht: sie führt bereits den Ersteller, nicht nur den letzten Antwortenden. Damit bleibt ein Lauf bei wenigen Anfragen statt einer je Thread. Unterforen und globale Kästen tragen ebenfalls Thread-Links, deshalb liest der Parser strikt entlang der Listeneinträge - sonst landen Changelogs und Bewerbungen in der Antragsliste. Klassifiziert wird ausschließlich von Hand. Weder Titel noch Board sind verlässlich genug: Threads werden nicht immer verschoben, ein genehmigter Antrag kann weiter im Antrags-Board liegen. Das Board steht deshalb nur als Hinweis an der Zeile. Angenommene Anträge rufen dieselben Aktionen wie die Knöpfe auf der Spielerseite, es gibt keinen zweiten Buchungsweg mit eigenen Regeln. Wandert ein bereits entschiedener Thread später in ein anderes Board, erscheint das als Hinweis zum Abhaken - der CC-Status ändert sich nie von allein. Die Thread-ID bleibt beim Verschieben stabil und traegt deshalb die Wiedererkennung; die Board-Historie wird mitgeschrieben. Zurückgelassene Verweise erkennt der Parser und zählt sie nicht als eigenen Antrag.
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Modal from '../components/Modal';
|
||||
import Notice from '../components/Notice';
|
||||
import { ApiError, api } from '../lib/api';
|
||||
import { BOARD_LABEL, CLASSIFICATION_LABEL, formatDate } from '../lib/format';
|
||||
import { useSession } from '../lib/session';
|
||||
import type {
|
||||
Character,
|
||||
ForumScanSummary,
|
||||
ForumThread,
|
||||
PlayerDetail,
|
||||
PlayerSummary,
|
||||
} from '../lib/types';
|
||||
|
||||
export default function Forum() {
|
||||
const { can } = useSession();
|
||||
const admin = can('ADMIN');
|
||||
|
||||
const [threads, setThreads] = useState<ForumThread[]>([]);
|
||||
const [notices, setNotices] = useState<ForumThread[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dialog, setDialog] = useState<ForumThread | null>(null);
|
||||
const [assigning, setAssigning] = useState<ForumThread | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [open, notice] = await Promise.all([
|
||||
api<{ items: ForumThread[] }>('/api/forum/threads?open=true'),
|
||||
api<{ items: ForumThread[] }>('/api/forum/notices'),
|
||||
]);
|
||||
setThreads(open.items);
|
||||
setNotices(notice.items);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const scan = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api<ForumScanSummary>('/api/forum/scan', { method: 'POST', body: {} });
|
||||
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.failedBoards.length > 0) parts.push(`${result.failedBoards.length} Board-Fehler`);
|
||||
setFlash(parts.join(', ') + '.');
|
||||
await load();
|
||||
window.setTimeout(() => setFlash(null), 8000);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = async (thread: ForumThread) => {
|
||||
try {
|
||||
await api(`/api/forum/threads/${thread.threadId}/dismiss`, { method: 'POST', body: {} });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Konnte nicht ausgeblendet werden');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
{admin && (
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}>
|
||||
{busy ? 'Lese Forum …' : 'Forum abgleichen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||
|
||||
{notices.length > 0 && (
|
||||
<section className="block block-flag">
|
||||
<h2>Verschoben nach der Entscheidung ({notices.length})</h2>
|
||||
<Notice kind="warn">
|
||||
Diese Anträge wurden bereits entschieden, ihr Thread liegt inzwischen aber in einem
|
||||
anderen Board. Das ist nur ein Hinweis — geprüft und geklickt wird von Hand.
|
||||
</Notice>
|
||||
|
||||
<ul className="cc-list">
|
||||
{notices.map((thread) => (
|
||||
<li key={thread.threadId} className="cc-item">
|
||||
<div>
|
||||
<strong>{thread.title}</strong>
|
||||
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
||||
<p className="muted small">
|
||||
{thread.classification && CLASSIFICATION_LABEL[thread.classification]}
|
||||
{thread.linkedCharacter && ` · CC: ${thread.linkedCharacter.name}`}
|
||||
{thread.lastBoardChangeAt && ` · verschoben ${formatDate(thread.lastBoardChangeAt)}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="cc-actions">
|
||||
<a className="btn btn-small btn-ghost" href={thread.url} target="_blank" rel="noreferrer">
|
||||
Thread öffnen
|
||||
</a>
|
||||
{admin && (
|
||||
<button type="button" className="btn btn-small" onClick={() => void dismiss(thread)}>
|
||||
Erledigt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="block">
|
||||
<h2>Wartet auf Entscheidung ({threads.length})</h2>
|
||||
|
||||
{threads.length === 0 ? (
|
||||
<p className="muted">
|
||||
Keine offenen Anträge. {admin && 'Über „Forum abgleichen" nach neuen suchen.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="cc-list">
|
||||
{threads.map((thread) => (
|
||||
<li key={thread.threadId} className="cc-item">
|
||||
<div>
|
||||
<strong>{thread.title}</strong>
|
||||
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
|
||||
<p className="muted small">
|
||||
von {thread.authorName ?? 'unbekannt'} ·{' '}
|
||||
{thread.player ? (
|
||||
<Link to={`/players/${thread.player.id}`}>{thread.player.displayName}</Link>
|
||||
) : (
|
||||
<span className="neg">kein Spieler zugeordnet</span>
|
||||
)}{' '}
|
||||
· gesehen {formatDate(thread.lastSeenAt)}
|
||||
</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
|
||||
</button>
|
||||
)}
|
||||
{admin && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={() => setDialog(thread)}
|
||||
>
|
||||
Entscheiden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{dialog && (
|
||||
<ClassifyModal
|
||||
thread={dialog}
|
||||
onClose={() => setDialog(null)}
|
||||
onDone={(message) => {
|
||||
setDialog(null);
|
||||
setFlash(message);
|
||||
void load();
|
||||
window.setTimeout(() => setFlash(null), 6000);
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{assigning && (
|
||||
<AssignModal
|
||||
thread={assigning}
|
||||
onClose={() => setAssigning(null)}
|
||||
onDone={() => {
|
||||
setAssigning(null);
|
||||
void load();
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassifyModal({
|
||||
thread,
|
||||
onClose,
|
||||
onDone,
|
||||
onError,
|
||||
}: {
|
||||
thread: ForumThread;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [choice, setChoice] = useState<'NEW_CC' | 'REDESIGN' | 'REJECTED' | 'IGNORE'>('NEW_CC');
|
||||
const [name, setName] = useState(thread.title);
|
||||
const [predecessorId, setPredecessorId] = useState<number | null>(null);
|
||||
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
|
||||
const [characters, setCharacters] = 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'));
|
||||
} catch {
|
||||
// Ohne Liste bleibt nur die Ablehnung - das Formular sagt es.
|
||||
}
|
||||
})();
|
||||
}, [thread.playerId]);
|
||||
|
||||
const needsPlayer = choice === 'NEW_CC' || choice === 'REDESIGN';
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/api/forum/threads/${thread.threadId}/classify`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
classification: choice,
|
||||
...(needsPlayer ? { characterName: name.trim() } : {}),
|
||||
...(choice === 'REDESIGN' ? { predecessorId, 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'],
|
||||
['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>
|
||||
)}
|
||||
|
||||
{needsPlayer && 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 === '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>
|
||||
);
|
||||
}
|
||||
|
||||
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