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:
2026-09-18 18:06:37 +02:00
parent 751f8bfe8e
commit bebb89c10e
13 changed files with 503 additions and 59 deletions
+1
View File
@@ -57,6 +57,7 @@ export const BOARD_LABEL: Record<string, string> = {
export const CLASSIFICATION_LABEL: Record<string, string> = {
NEW_CC: 'Angenommen – neuer CC',
REDESIGN: 'Angenommen – Redesign',
REACTIVATION: 'Angenommen – Reaktivierung',
REJECTED: 'Abgelehnt',
IGNORE: 'Ignoriert',
};
+9 -2
View File
@@ -193,7 +193,12 @@ export interface ImportRecord {
}
export type ForumBoard = 'CUSTOM_CHARACTERS' | 'AKTIVE_CCS' | 'CC_ARCHIV';
export type ForumClassification = 'NEW_CC' | 'REDESIGN' | 'REJECTED' | 'IGNORE';
export type ForumClassification =
| 'NEW_CC'
| 'REDESIGN'
| 'REACTIVATION'
| 'REJECTED'
| 'IGNORE';
export interface ForumThread {
threadId: number;
@@ -201,6 +206,7 @@ export interface ForumThread {
url: string;
authorName: string | null;
authorForumId: number | null;
labels: string | null;
playerId: number | null;
player: { id: number; displayName: string } | null;
currentBoard: ForumBoard;
@@ -221,7 +227,8 @@ export interface ForumScanSummary {
movedThreads: number;
boardChangeNotices: number;
skippedStubs: number;
skippedLabels: number;
skippedUnknownAuthor: number;
matchedPlayers: number;
withoutPlayer: number;
failedBoards: { board: string; reason: string }[];
}
+95 -12
View File
@@ -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
View File
@@ -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>