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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user