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>
|
||||
|
||||
+115
-23
@@ -24,7 +24,8 @@ type DialogState =
|
||||
| { type: 'revert'; character: Character }
|
||||
| { type: 'reverse'; entry: LedgerEntry }
|
||||
| { type: 'editPlayer' }
|
||||
| { type: 'deletePlayer' };
|
||||
| { type: 'deletePlayer' }
|
||||
| { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' };
|
||||
|
||||
export default function PlayerDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -179,12 +180,7 @@ export default function PlayerDetail() {
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => api(`/api/players/${playerId}/credits/cc-package`, { method: 'POST', body: {} }),
|
||||
'CC-Paket eingetragen: +1 CC und +1 Redesign.',
|
||||
)
|
||||
}
|
||||
onClick={() => setDialog({ type: 'addCredit', kind: 'cc-package' })}
|
||||
>
|
||||
+ CC-Paket
|
||||
</button>
|
||||
@@ -192,16 +188,7 @@ export default function PlayerDetail() {
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() =>
|
||||
api(`/api/players/${playerId}/credits/redesign-package`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
}),
|
||||
'Redesign-Paket eingetragen: +1 Redesign.',
|
||||
)
|
||||
}
|
||||
onClick={() => setDialog({ type: 'addCredit', kind: 'redesign-package' })}
|
||||
>
|
||||
+ Redesign-Paket
|
||||
</button>
|
||||
@@ -209,12 +196,7 @@ export default function PlayerDetail() {
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(
|
||||
() => api(`/api/players/${playerId}/credits/kulanz`, { method: 'POST', body: {} }),
|
||||
'Kulanz-Redesign eingetragen: +1 Kulanz.',
|
||||
)
|
||||
}
|
||||
onClick={() => setDialog({ type: 'addCredit', kind: 'kulanz' })}
|
||||
>
|
||||
+ Kulanz
|
||||
</button>
|
||||
@@ -523,6 +505,25 @@ export default function PlayerDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{dialog.type === 'addCredit' && (
|
||||
<AddCreditModal
|
||||
kind={dialog.kind}
|
||||
playerName={detail.player.displayName}
|
||||
busy={busy}
|
||||
onClose={() => setDialog({ type: 'none' })}
|
||||
onConfirm={(note) =>
|
||||
void run(
|
||||
() =>
|
||||
api(`/api/players/${playerId}/credits/${dialog.kind}`, {
|
||||
method: 'POST',
|
||||
body: note.trim() ? { note: note.trim() } : {},
|
||||
}),
|
||||
CREDIT_ACTIONS[dialog.kind].flash,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dialog.type === 'editPlayer' && (
|
||||
<EditPlayerModal
|
||||
player={detail.player}
|
||||
@@ -580,10 +581,87 @@ export default function PlayerDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
const CREDIT_ACTIONS = {
|
||||
'cc-package': {
|
||||
titel: 'CC-Paket eintragen',
|
||||
wirkung: 'Schreibt +1 CC-Guthaben und +1 Redesign-Guthaben gut.',
|
||||
hinweis: 'Das Redesign ist bei jedem CC-Paket enthalten und wird immer mitgebucht.',
|
||||
flash: 'CC-Paket eingetragen: +1 CC und +1 Redesign.',
|
||||
},
|
||||
'redesign-package': {
|
||||
titel: 'Redesign-Paket eintragen',
|
||||
wirkung: 'Schreibt +1 bezahltes Redesign-Guthaben gut.',
|
||||
hinweis: 'Für einzeln gekaufte Redesigns, ohne CC-Guthaben.',
|
||||
flash: 'Redesign-Paket eingetragen: +1 Redesign.',
|
||||
},
|
||||
kulanz: {
|
||||
titel: 'Kulanz-Redesign eintragen',
|
||||
wirkung: 'Schreibt +1 Kulanz-Redesign gut.',
|
||||
hinweis: 'Für vom Team gewährte Redesigns, die nicht bezahlt wurden.',
|
||||
flash: 'Kulanz-Redesign eingetragen: +1 Kulanz.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function AddCreditModal({
|
||||
kind,
|
||||
playerName,
|
||||
busy,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
kind: keyof typeof CREDIT_ACTIONS;
|
||||
playerName: string;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (note: string) => void;
|
||||
}) {
|
||||
const [note, setNote] = useState('');
|
||||
const aktion = CREDIT_ACTIONS[kind];
|
||||
|
||||
return (
|
||||
<Modal title={aktion.titel} onClose={onClose}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onConfirm(note);
|
||||
}}
|
||||
>
|
||||
<Notice kind="info">
|
||||
Für <strong>{playerName}</strong>: {aktion.wirkung}
|
||||
<br />
|
||||
<span className="muted">{aktion.hinweis}</span>
|
||||
</Notice>
|
||||
|
||||
<label htmlFor="credit-note">Notiz (optional)</label>
|
||||
<input
|
||||
id="credit-note"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder="z. B. Bestellnummer oder Grund der Kulanz"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="muted small">
|
||||
Eine Buchung lässt sich später stornieren, verschwindet aber nie aus der Historie.
|
||||
</p>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? 'Buche …' : 'Eintragen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface CharacterValues {
|
||||
name: string;
|
||||
loreText?: string;
|
||||
useKind?: CreditKind;
|
||||
forumThread?: string;
|
||||
}
|
||||
|
||||
function CharacterFormModal({
|
||||
@@ -607,6 +685,7 @@ function CharacterFormModal({
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [loreText, setLoreText] = useState('');
|
||||
const [forumThread, setForumThread] = useState('');
|
||||
// Kulanz steht bewusst vorne: sie soll zuerst aufgebraucht werden.
|
||||
const [useKind, setUseKind] = useState<CreditKind>(withKind?.[0] ?? 'CC');
|
||||
|
||||
@@ -618,6 +697,7 @@ function CharacterFormModal({
|
||||
onSubmit({
|
||||
name,
|
||||
...(loreText.trim() ? { loreText: loreText.trim() } : {}),
|
||||
...(forumThread.trim() ? { forumThread: forumThread.trim() } : {}),
|
||||
...(withKind ? { useKind } : {}),
|
||||
});
|
||||
}}
|
||||
@@ -641,6 +721,18 @@ function CharacterFormModal({
|
||||
onChange={(event) => setLoreText(event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="cc-thread">Forums-Thread (optional)</label>
|
||||
<input
|
||||
id="cc-thread"
|
||||
value={forumThread}
|
||||
onChange={(event) => setForumThread(event.target.value)}
|
||||
placeholder="Link zum Antrag oder nur die Thread-Nummer"
|
||||
/>
|
||||
<p className="muted small">
|
||||
Trag den Antrag hier ein, dann taucht er beim nächsten Forums-Abgleich nicht erneut
|
||||
als offen auf.
|
||||
</p>
|
||||
|
||||
{withKind && balances && (
|
||||
<>
|
||||
<label>Welches Guthaben soll verbraucht werden?</label>
|
||||
|
||||
Reference in New Issue
Block a user