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