Schema-Aenderungen als eigene Migration nachziehen

Die erste Migration wurde nachtraeglich mehrfach erweitert, statt Aenderungen
anzuhaengen. Auf einer bestehenden Installation gilt sie als angewendet und
wird uebersprungen - die seither hinzugekommenen Tabellen fehlten dort also
schlicht, ohne dass beim Start etwas auffiel. Die Antragsseite lief dadurch
in einen internen Fehler.

Die neue Migration ergaenzt alles Fehlende und ist durchgehend mit
IF NOT EXISTS geschrieben, weil bestehende Datenbanken je nach Zeitpunkt der
Einrichtung unterschiedliche Staende haben. Geprueft gegen eine frische
Datenbank, gegen eine auf altem Stand und auf mehrfaches Anwenden.

Der Testharness wendet jetzt alle Migrationen der Reihe nach an statt nur der
ersten - sonst faellt so etwas beim naechsten Mal wieder nicht auf.

Ausserdem scheiterte das Buchen komplett, wenn eine Bestellung zwischenzeitlich
ueber einen anderen Import gebucht worden war: die eindeutige Bestell-ID kippte
die gesamte Transaktion. Solche Zeilen werden jetzt uebersprungen und gezaehlt,
die uebrigen laufen durch.

Dazu fehlten in der Oberflaeche die Wege zum Bearbeiten und Loeschen eines
Spielers, obwohl es die Endpunkte gab. Beides ist jetzt auf der Spielerseite
erreichbar; das Loeschen verlangt den Namen als Bestaetigung und benennt
vorher, was mit verschwindet.
This commit is contained in:
2026-09-18 17:43:25 +02:00
parent 664a0fd5ee
commit 751f8bfe8e
6 changed files with 374 additions and 11 deletions
+5 -5
View File
@@ -157,14 +157,14 @@ export default function Import() {
setBusy(true);
setError(null);
try {
const result = await api<{ booked: number; createdPlayers: number }>(
const result = await api<{ booked: number; skipped: number; createdPlayers: number }>(
`/api/imports/${upload.importId}/commit`,
{ method: 'POST', body: {} },
);
setFlash(
`${result.booked} Buchungen angelegt` +
(result.createdPlayers > 0 ? `, ${result.createdPlayers} Spieler neu erfasst.` : '.'),
);
const teile = [`${result.booked} Buchungen angelegt`];
if (result.createdPlayers > 0) teile.push(`${result.createdPlayers} Spieler neu erfasst`);
if (result.skipped > 0) teile.push(`${result.skipped} bereits gebucht, übersprungen`);
setFlash(teile.join(', ') + '.');
reset();
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Buchen fehlgeschlagen');
+195 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams } from 'react-router-dom';
import BalanceCards from '../components/BalanceCards';
import UnmatchedBox from '../components/UnmatchedBox';
import Modal from '../components/Modal';
@@ -22,12 +22,15 @@ type DialogState =
| { type: 'deactivate'; character: Character }
| { type: 'reactivatePaid'; character: Character }
| { type: 'revert'; character: Character }
| { type: 'reverse'; entry: LedgerEntry };
| { type: 'reverse'; entry: LedgerEntry }
| { type: 'editPlayer' }
| { type: 'deletePlayer' };
export default function PlayerDetail() {
const { id } = useParams<{ id: string }>();
const playerId = Number(id);
const { can } = useSession();
const navigate = useNavigate();
const [detail, setDetail] = useState<Detail | null>(null);
const [loading, setLoading] = useState(true);
@@ -125,7 +128,29 @@ export default function PlayerDetail() {
{detail.player.steamId ?? 'keine SteamID'}
{detail.player.forumName && ` · Forum: ${detail.player.forumName}`}
</p>
{detail.player.notes && <p className="lore">{detail.player.notes}</p>}
</div>
{editor && (
<div className="head-actions">
<button
type="button"
className="btn"
onClick={() => setDialog({ type: 'editPlayer' })}
>
Bearbeiten
</button>
{can('ADMIN') && (
<button
type="button"
className="btn btn-danger"
onClick={() => setDialog({ type: 'deletePlayer' })}
>
Löschen
</button>
)}
</div>
)}
</div>
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
@@ -498,6 +523,40 @@ export default function PlayerDetail() {
/>
)}
{dialog.type === 'editPlayer' && (
<EditPlayerModal
player={detail.player}
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
void run(
() => api(`/api/players/${playerId}`, { method: 'PATCH', body: values }),
'Spielerdaten gespeichert.',
)
}
/>
)}
{dialog.type === 'deletePlayer' && (
<DeletePlayerModal
player={detail.player}
characterCount={detail.characters.length}
ledgerCount={detail.ledgerTotal}
busy={busy}
onClose={() => setDialog({ type: 'none' })}
onConfirm={async () => {
setBusy(true);
try {
await api(`/api/players/${playerId}`, { method: 'DELETE' });
navigate('/', { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Löschen fehlgeschlagen');
setBusy(false);
}
}}
/>
)}
{dialog.type === 'reverse' && (
<ReasonModal
title="Buchung stornieren"
@@ -816,3 +875,137 @@ function ReasonModal({
</Modal>
);
}
function EditPlayerModal({
player,
busy,
onClose,
onSubmit,
}: {
player: Detail['player'];
busy: boolean;
onClose: () => void;
onSubmit: (values: Record<string, string | null>) => void;
}) {
const [displayName, setDisplayName] = useState(player.displayName);
const [steamId, setSteamId] = useState(player.steamId ?? '');
const [forumName, setForumName] = useState(player.forumName ?? '');
const [notes, setNotes] = useState(player.notes ?? '');
return (
<Modal title="Spielerdaten bearbeiten" onClose={onClose}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit({
displayName: displayName.trim(),
steamId: steamId.trim() || null,
forumName: forumName.trim() || null,
notes: notes.trim() || null,
});
}}
>
<label htmlFor="edit-name">Anzeigename</label>
<input
id="edit-name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
autoFocus
required
/>
<label htmlFor="edit-steam">SteamID</label>
<input
id="edit-steam"
value={steamId}
onChange={(event) => setSteamId(event.target.value)}
placeholder="76561198... oder STEAM_0:1:..."
/>
<p className="muted small">Leer lassen entfernt die Zuordnung zu GExtension.</p>
<label htmlFor="edit-forum">Forum-Name</label>
<input
id="edit-forum"
value={forumName}
onChange={(event) => setForumName(event.target.value)}
/>
<p className="muted small">
Muss dem Forumsnamen entsprechen, damit Anträge automatisch zugeordnet werden.
</p>
<label htmlFor="edit-notes">Notizen</label>
<textarea
id="edit-notes"
rows={3}
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
<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 …' : 'Speichern'}
</button>
</div>
</form>
</Modal>
);
}
function DeletePlayerModal({
player,
characterCount,
ledgerCount,
busy,
onClose,
onConfirm,
}: {
player: Detail['player'];
characterCount: number;
ledgerCount: number;
busy: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
// Tippen statt klicken: das hier lässt sich nicht rückgängig machen.
const [confirmation, setConfirmation] = useState('');
const passt = confirmation.trim() === player.displayName;
return (
<Modal title={`"${player.displayName}" löschen`} onClose={onClose}>
<Notice kind="warn">
Dabei verschwinden <strong>{characterCount}</strong>{' '}
{characterCount === 1 ? 'CC' : 'CCs'} und <strong>{ledgerCount}</strong>{' '}
{ledgerCount === 1 ? 'Buchung' : 'Buchungen'} mit. Auch Spuren in Importen und in den
Details früherer Protokolleinträge werden entfernt. Das lässt sich nicht rückgängig
machen — für eine Fehleingabe ist eher das Bearbeiten gedacht.
</Notice>
<label htmlFor="confirm-name">
Zum Bestätigen den Namen eingeben: <code className="mono">{player.displayName}</code>
</label>
<input
id="confirm-name"
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
autoFocus
/>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button
type="button"
className="btn btn-danger"
disabled={busy || !passt}
onClick={onConfirm}
>
{busy ? 'Lösche …' : 'Endgültig löschen'}
</button>
</div>
</Modal>
);
}