Tote CCs zurueckholen, Redesign-Beitritte und Problemfaelle

Vier Punkte aus dem Betrieb.

Ein toter CC liess sich nicht zurueckholen. Der Knopf dafuer wurde nie
angezeigt: bei Grund "Im RP gestorben" landete die Seite im Zweig "Bezahlt
reaktivieren", den der Server zu Recht abweist - ein Toter kehrt nur durch
einen Administrator zurueck. Der Dialog dafuer war fertig gebaut und wurde
schlicht nie geoeffnet. Jetzt steht an einem toten CC "Zurueckholen", fuer
Editoren stattdessen der Hinweis, dass das ein Administrator entscheidet.

Der Abgleich eines einzelnen Spielers fragte nur dessen hinterlegte SteamID
ab. Guthaben, das unter einem frueheren Konto gekauft wurde, blieb damit
unsichtbar - obwohl das Konto hinterlegt war und der Sammellauf es beruecksichtigt
hat. Jetzt werden alle Kennungen des Spielers abgefragt.

Ein Beitritt zu einem Gruppen-CC ueber ein Redesign kostete nur das Guthaben.
Damit entstand aus einem Redesign ein zusaetzlicher CC, was die Regel gerade
nicht hergibt: ein Redesign ersetzt einen CC. Jetzt muss angegeben werden,
welcher eigene CC dafuer aufgegeben wird; ein aktiver wird dabei inaktiv
gesetzt, ein bereits inaktiver bleibt unveraendert. Ueber ein CC-Paket kommt
weiterhin ein Platz hinzu, ohne Vorgaenger. Welcher CC aufgegeben wurde, haengt
am Beitritt und bleibt nachvollziehbar.

Faelle, die sich beim Eintragen nicht klaeren lassen - ein Antrag ohne
auffindbares Paket, ein fehlender GExtension-Eintrag, widerspruechliche Angaben
- koennen als Spieler angelegt und dabei als Problem vorgemerkt werden, mit
Notiz. Auch nachtraeglich setzbar und wieder abraeumbar. Die Markierung ist
folgenlos: sie sperrt nichts und veraendert weder Guthaben noch CCs. Die
Uebersicht kennzeichnet betroffene Spieler und laesst sich darauf eingrenzen.

Nebenbei: die Spielerliste gab die Markierung nicht mit aus, aufgefallen beim
Test.
This commit is contained in:
2026-09-21 00:19:48 +02:00
parent 15d683c59e
commit e65fdfc0ad
14 changed files with 794 additions and 34 deletions
+8
View File
@@ -36,6 +36,8 @@ export interface PlayerSummary {
displayName: string;
steamId: string | null;
forumName: string | null;
isProblem: boolean;
problemNote: string | null;
characterCount: number;
activeCharacterCount: number;
balances: Balances;
@@ -47,6 +49,10 @@ export interface Player {
steamId: string | null;
forumName: string | null;
notes: string | null;
/// Ein Fall, der sich gerade nicht klären lässt. Reine Merkhilfe.
isProblem: boolean;
problemNote: string | null;
problemSince: string | null;
createdAt: string;
gextensionUsage: number | null;
gextensionSyncedAt: string | null;
@@ -105,6 +111,8 @@ export interface CharacterMember {
joinedAt: string;
leftAt: string | null;
paidWith: CreditKind | null;
/// Der eigene CC, den der Spieler für den Platz aufgegeben hat.
predecessorId: number | null;
note: string | null;
}
+52 -5
View File
@@ -29,11 +29,14 @@ export default function Dashboard() {
const [flagged, setFlagged] = useState<UnmatchedOverview>({ total: 0, players: [] });
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
/// Nur die Fälle zeigen, bei denen noch etwas offen ist.
const [nurProbleme, setNurProbleme] = useState(false);
const load = useCallback(async (search: string, signal?: AbortSignal) => {
const load = useCallback(async (search: string, probleme: boolean, signal?: AbortSignal) => {
setLoading(true);
try {
const params = new URLSearchParams({ q: search, take: '100' });
if (probleme) params.set('problems', 'true');
const data = await api<SearchResult>(`/api/players?${params}`, signal ? { signal } : {});
setResult(data);
setError(null);
@@ -78,7 +81,7 @@ export default function Dashboard() {
? '. Unter Import bestätigen, damit die Guthaben entstehen.'
: '.'),
);
await Promise.all([load(query), loadFlagged()]);
await Promise.all([load(query, nurProbleme), loadFlagged()]);
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
} finally {
@@ -89,12 +92,12 @@ export default function Dashboard() {
// Tippen entprellen, damit nicht jeder Anschlag eine Anfrage auslöst.
useEffect(() => {
const controller = new AbortController();
const timer = setTimeout(() => void load(query, controller.signal), 250);
const timer = setTimeout(() => void load(query, nurProbleme, controller.signal), 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query, load]);
}, [query, nurProbleme, load]);
return (
<div className="page">
@@ -123,6 +126,15 @@ export default function Dashboard() {
autoFocus
/>
<label className="checkbox" style={{ marginBottom: '1rem' }}>
<input
type="checkbox"
checked={nurProbleme}
onChange={(event) => setNurProbleme(event.target.checked)}
/>
<span>Nur offene Problemfälle</span>
</label>
{error && <Notice kind="error">{error}</Notice>}
{syncResult && <Notice kind="success" onDismiss={() => setSyncResult(null)}>{syncResult}</Notice>}
@@ -162,6 +174,11 @@ export default function Dashboard() {
<Link to={`/players/${player.id}`} className="link-strong">
{player.displayName}
</Link>
{player.isProblem && (
<span className="badge badge-dead" title={player.problemNote ?? 'Offener Fall'}>
Problem
</span>
)}
{player.forumName && <span className="muted"> · {player.forumName}</span>}
</td>
<td className="mono muted">{player.steamId ?? '-'}</td>
@@ -203,7 +220,7 @@ export default function Dashboard() {
onClose={() => setCreating(false)}
onCreated={() => {
setCreating(false);
void load(query);
void load(query, nurProbleme);
}}
/>
)}
@@ -215,6 +232,8 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
const [displayName, setDisplayName] = useState('');
const [steamId, setSteamId] = useState('');
const [forumName, setForumName] = useState('');
const [istProblem, setIstProblem] = useState(false);
const [problemNote, setProblemNote] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -229,6 +248,7 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
displayName,
steamId: steamId.trim() || null,
forumName: forumName.trim() || null,
...(istProblem ? { isProblem: true, problemNote: problemNote.trim() || null } : {}),
},
});
onCreated();
@@ -272,6 +292,33 @@ function CreatePlayerModal({ onClose, onCreated }: { onClose: () => void; onCrea
onChange={(event) => setForumName(event.target.value)}
/>
<label className="checkbox">
<input
type="checkbox"
checked={istProblem}
onChange={(event) => setIstProblem(event.target.checked)}
/>
<span>Als Problemfall vormerken</span>
</label>
<p className="muted small">
Für alles, was sich gerade nicht sauber klären lässt: ein Antrag ohne auffindbares
Paket, kein GExtension-Eintrag, widersprüchliche Angaben. Der Spieler wird trotzdem
angelegt, damit die Spur nicht verloren geht. An Guthaben und CCs ändert die
Markierung nichts.
</p>
{istProblem && (
<>
<label htmlFor="problemNote">Was ist offen?</label>
<input
id="problemNote"
value={problemNote}
onChange={(event) => setProblemNote(event.target.value)}
placeholder="z. B. CC-Antrag da, aber kein Paket auffindbar"
/>
</>
)}
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
+229 -8
View File
@@ -17,6 +17,7 @@ import {
import { useSession } from '../lib/session';
import type {
Character,
Player,
PlayerSummary,
CreditKind,
LedgerEntry,
@@ -59,7 +60,8 @@ type DialogState =
| { type: 'deletePlayer' }
| { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' }
| { type: 'addMember'; character: Character }
| { type: 'reviveDead'; character: Character };
| { type: 'reviveDead'; character: Character }
| { type: 'problem' };
export default function PlayerDetail() {
const { id } = useParams<{ id: string }>();
@@ -148,6 +150,7 @@ export default function PlayerDetail() {
if (!detail) return <Notice kind="error">{error ?? 'Spieler nicht gefunden'}</Notice>;
const editor = can('EDITOR');
const admin = can('ADMIN');
const active = detail.characters.filter((c) => c.status === 'ACTIVE');
const inactive = detail.characters.filter((c) => c.status === 'INACTIVE');
@@ -175,6 +178,13 @@ export default function PlayerDetail() {
>
Bearbeiten
</button>
<button
type="button"
className="btn"
onClick={() => setDialog({ type: 'problem' })}
>
{detail.player.isProblem ? 'Problem klären' : 'Als Problem merken'}
</button>
{can('ADMIN') && (
<button
type="button"
@@ -191,6 +201,19 @@ export default function PlayerDetail() {
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
{detail.player.isProblem && (
<Notice kind="warn">
<strong>Offener Problemfall.</strong>{' '}
{detail.player.problemNote ?? 'Ohne nähere Angabe vermerkt.'}
{detail.player.problemSince && (
<span className="muted"> · vermerkt {formatDate(detail.player.problemSince)}</span>
)}
<br />
Guthaben und CCs sind davon unberührt - die Markierung ist nur ein Merkzettel, bis es
darauf ankommt.
</Notice>
)}
<BalanceCards balances={detail.balances} />
{detail.player.gextensionUsage !== null && (
@@ -361,14 +384,28 @@ export default function PlayerDetail() {
<p className="lore">{character.inactiveNote}</p>
)}
<p className="muted small">
{free
? 'Kommt gratis zurück - kostet kein Guthaben.'
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
{art === 'admin'
? 'Im Rollenspiel gestorben - die Rückkehr entscheidet ein Administrator.'
: free
? 'Kommt gratis zurück - kostet kein Guthaben.'
: 'Reaktivierung kostet ein Redesign- oder CC-Guthaben.'}
</p>
</div>
{editor && (
<div className="cc-actions">
{free ? (
{art === 'admin' ? (
admin ? (
<button
type="button"
className="btn btn-small btn-primary"
onClick={() => setDialog({ type: 'reviveDead', character })}
>
Zurückholen
</button>
) : (
<span className="muted small">Nur ein Administrator</span>
)
) : free ? (
<button
type="button"
className="btn btn-small btn-primary"
@@ -576,6 +613,23 @@ export default function PlayerDetail() {
/>
)}
{dialog.type === 'problem' && (
<ProblemModal
player={detail.player}
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
void run(
() =>
api(`/api/players/${playerId}/problem`, { method: 'POST', body: values }),
values.isProblem
? 'Als Problemfall vorgemerkt.'
: 'Problemfall als geklärt abgehakt.',
)
}
/>
)}
{dialog.type === 'addMember' && (
<AddMemberModal
character={dialog.character}
@@ -1302,13 +1356,24 @@ function AddMemberModal({
character: Character;
busy: boolean;
onClose: () => void;
onSubmit: (values: { playerId: number; useKind: CreditKind; note?: string }) => void;
onSubmit: (values: {
playerId: number;
useKind: CreditKind;
predecessorId?: number;
note?: string;
}) => void;
}) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<PlayerSummary[]>([]);
const [gewaehlt, setGewaehlt] = useState<PlayerSummary | null>(null);
const [useKind, setUseKind] = useState<CreditKind>('CC');
const [note, setNote] = useState('');
const [eigeneCCs, setEigeneCCs] = useState<Character[]>([]);
const [vorgaenger, setVorgaenger] = useState<number | null>(null);
// Ein Redesign ersetzt einen CC, es kommt keiner dazu - also muss feststehen,
// welchen der Spieler dafür aufgibt.
const ueberRedesign = useKind === 'REDESIGN_PAID' || useKind === 'REDESIGN_KULANZ';
useEffect(() => {
const timer = setTimeout(async () => {
@@ -1323,11 +1388,31 @@ function AddMemberModal({
return () => clearTimeout(timer);
}, [query, character.playerId]);
// Die eigenen CCs des Gewählten, sobald er feststeht. Auch die inaktiven:
// ein längst stillgelegter CC lässt sich ebenso redesignen.
useEffect(() => {
if (!gewaehlt) {
setEigeneCCs([]);
setVorgaenger(null);
return;
}
void (async () => {
try {
const detail = await api<Detail>(`/api/players/${gewaehlt.id}`);
setEigeneCCs(detail.characters.filter((c) => c.id !== character.id));
} catch {
// Ohne Liste bleibt nur ein Platz aus dem CC-Guthaben.
setEigeneCCs([]);
}
})();
}, [gewaehlt, character.id]);
return (
<Modal title={`Nutzer zu "${character.name}" hinzufügen`} onClose={onClose}>
<Notice kind="info">
Für den neuen Nutzer gilt dasselbe wie für einen eigenen CC: der Platz kostet ihn ein
eigenes Guthaben. Ob über sein CC-Paket oder ein Redesign, entscheidest du hier.
eigenes Guthaben. Über ein CC-Paket kommt ein Platz hinzu; über ein Redesign tritt der
Platz an die Stelle eines seiner CCs, genau wie bei jedem anderen Redesign.
</Notice>
{!gewaehlt ? (
@@ -1370,6 +1455,7 @@ function AddMemberModal({
onSubmit({
playerId: gewaehlt.id,
useKind,
...(ueberRedesign && vorgaenger !== null ? { predecessorId: vorgaenger } : {}),
...(note.trim() ? { note: note.trim() } : {}),
});
}}
@@ -1403,6 +1489,45 @@ function AddMemberModal({
))}
</div>
{ueberRedesign && (
<>
<label htmlFor="member-predecessor">Welchen eigenen CC gibt er dafür auf?</label>
{eigeneCCs.length === 0 ? (
<Notice kind="warn">
{gewaehlt.displayName} hat keinen eigenen CC. Ein Redesign ersetzt einen CC -
ohne einen gibt es nichts zu ersetzen. Für einen zusätzlichen Platz braucht es
ein CC-Paket.
</Notice>
) : (
<>
<select
id="member-predecessor"
value={vorgaenger ?? ''}
onChange={(event) =>
setVorgaenger(event.target.value ? Number(event.target.value) : null)
}
>
<option value="">Bitte wählen</option>
{eigeneCCs.map((cc) => (
<option key={cc.id} value={cc.id}>
{cc.name}
{cc.status === 'INACTIVE'
? ` (inaktiv${
cc.inactiveReason ? `: ${REASON_LABEL[cc.inactiveReason]}` : ''
})`
: ''}
</option>
))}
</select>
<p className="muted small">
Ein aktiver CC wird dabei inaktiv gesetzt (redesignt). Ein bereits inaktiver
bleibt, wie er ist - aufgegeben wurde er schon.
</p>
</>
)}
</>
)}
<label htmlFor="member-note">Notiz (optional)</label>
<input
id="member-note"
@@ -1415,7 +1540,11 @@ function AddMemberModal({
<button type="button" className="btn btn-ghost" onClick={() => setGewaehlt(null)}>
Anderen wählen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
<button
type="submit"
className="btn btn-primary"
disabled={busy || (ueberRedesign && vorgaenger === null)}
>
{busy ? 'Speichere …' : 'Aufnehmen'}
</button>
</div>
@@ -1517,3 +1646,95 @@ function ReviveDeadModal({
</Modal>
);
}
/**
* Markiert einen Spieler als Problemfall oder hakt ihn ab. Bewusst ohne jede
* Wirkung auf Guthaben und CCs - hier wird nur notiert, was offen ist.
*/
function ProblemModal({
player,
busy,
onClose,
onSubmit,
}: {
player: Player;
busy: boolean;
onClose: () => void;
onSubmit: (values: { isProblem: boolean; note?: string | null }) => void;
}) {
const [note, setNote] = useState(player.problemNote ?? '');
if (player.isProblem) {
return (
<Modal title={`Problemfall "${player.displayName}"`} onClose={onClose}>
<Notice kind="info">
Vermerkt ist: {player.problemNote ?? 'ohne nähere Angabe'}
{player.problemSince && ` (seit ${formatDate(player.problemSince)})`}.
</Notice>
<label htmlFor="problem-note">Vermerk nachschärfen</label>
<input
id="problem-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. Paket liegt unter einem früheren Konto"
/>
<div className="form-actions">
<button
type="button"
className="btn"
disabled={busy}
onClick={() => onSubmit({ isProblem: true, note: note.trim() || null })}
>
Vermerk aktualisieren
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => onSubmit({ isProblem: false })}
>
Geklärt - Markierung entfernen
</button>
</div>
</Modal>
);
}
return (
<Modal title={`"${player.displayName}" als Problem merken`} onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit({ isProblem: true, note: note.trim() || null });
}}
>
<Notice kind="info">
Für alles, was sich gerade nicht klären lässt: ein Antrag ohne auffindbares Paket, kein
GExtension-Eintrag, widersprüchliche Angaben. Der Fall bleibt so auffindbar, bis es
darauf ankommt - kommt der Spieler nie wieder, kommt es nie darauf an. An Guthaben und
CCs ändert sich nichts.
</Notice>
<label htmlFor="problem-note">Was ist offen?</label>
<input
id="problem-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. CC-Antrag da, aber kein Paket auffindbar"
autoFocus
/>
<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 ? 'Speichere …' : 'Vormerken'}
</button>
</div>
</form>
</Modal>
);
}