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.
1012 lines
32 KiB
TypeScript
1012 lines
32 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
import BalanceCards from '../components/BalanceCards';
|
|
import UnmatchedBox from '../components/UnmatchedBox';
|
|
import Modal from '../components/Modal';
|
|
import Notice from '../components/Notice';
|
|
import { ApiError, api } from '../lib/api';
|
|
import { KIND_LABEL, REASON_LABEL, SOURCE_LABEL, formatAmount, formatDate } from '../lib/format';
|
|
import { useSession } from '../lib/session';
|
|
import type {
|
|
Character,
|
|
CreditKind,
|
|
LedgerEntry,
|
|
PlayerDetail as Detail,
|
|
ScrapeSummary,
|
|
} from '../lib/types';
|
|
|
|
type DialogState =
|
|
| { type: 'none' }
|
|
| { type: 'newCharacter' }
|
|
| { type: 'redesign'; character: Character }
|
|
| { type: 'deactivate'; character: Character }
|
|
| { type: 'reactivatePaid'; character: Character }
|
|
| { type: 'revert'; character: Character }
|
|
| { 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);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [flash, setFlash] = useState<string | null>(null);
|
|
const [dialog, setDialog] = useState<DialogState>({ type: 'none' });
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setDetail(await api<Detail>(`/api/players/${playerId}`));
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [playerId]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const run = async (action: () => Promise<unknown>, message: string) => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await action();
|
|
await load();
|
|
setDialog({ type: 'none' });
|
|
setFlash(message);
|
|
window.setTimeout(() => setFlash(null), 4000);
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Aktion fehlgeschlagen');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
// Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung
|
|
// auch sagen, sonst wirkt der Knopf wirkungslos.
|
|
const syncFromGextension = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const result = await api<ScrapeSummary>(`/api/scrape/player/${playerId}`, {
|
|
method: 'POST',
|
|
body: {},
|
|
});
|
|
await load();
|
|
|
|
if (result.newRows > 0) {
|
|
setFlash(
|
|
`${result.newRows} ${result.newRows === 1 ? 'Paket' : 'Pakete'} gefunden. ` +
|
|
'Unter Import bestätigen, damit die Guthaben entstehen.',
|
|
);
|
|
} else if (result.awaitingConfirmation > 0) {
|
|
setFlash(
|
|
`${result.awaitingConfirmation} ${result.awaitingConfirmation === 1 ? 'Paket wartet' : 'Pakete warten'} ` +
|
|
'bereits unter Import auf Bestätigung.',
|
|
);
|
|
} else if (result.unclear > 0) {
|
|
setFlash(`${result.unclear} unklare Einträge — siehe Kasten oben.`);
|
|
} else if (result.duplicates > 0) {
|
|
setFlash('Nichts Neues — alle Pakete sind bereits gebucht.');
|
|
} else if (result.failedPlayers.length > 0) {
|
|
setError(`Profil nicht abrufbar: ${result.failedPlayers[0]?.reason ?? 'unbekannt'}`);
|
|
} else {
|
|
setFlash('Keine CC-relevanten Pakete im Profil gefunden.');
|
|
}
|
|
window.setTimeout(() => setFlash(null), 8000);
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (loading) return <p className="muted">Lade …</p>;
|
|
if (!detail) return <Notice kind="error">{error ?? 'Spieler nicht gefunden'}</Notice>;
|
|
|
|
const editor = can('EDITOR');
|
|
const active = detail.characters.filter((c) => c.status === 'ACTIVE');
|
|
const inactive = detail.characters.filter((c) => c.status === 'INACTIVE');
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="page-head">
|
|
<div>
|
|
<Link to="/" className="back-link">
|
|
← Alle Spieler
|
|
</Link>
|
|
<h1>{detail.player.displayName}</h1>
|
|
<p className="muted mono">
|
|
{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>}
|
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
|
|
|
<BalanceCards balances={detail.balances} />
|
|
|
|
{detail.player.gextensionUsage !== null && (
|
|
<p className="muted small usage-hint">
|
|
GExtension meldet <strong>{detail.player.gextensionUsage}</strong> Mal verbraucht
|
|
{detail.player.gextensionSyncedAt &&
|
|
` (Stand ${formatDate(detail.player.gextensionSyncedAt)})`}
|
|
. Nur zum Abgleich — wird nicht automatisch verrechnet.
|
|
</p>
|
|
)}
|
|
|
|
<UnmatchedBox
|
|
items={detail.unmatched ?? []}
|
|
canResolve={can('ADMIN')}
|
|
onResolved={() => void load()}
|
|
/>
|
|
|
|
{editor && (
|
|
<section className="action-bar">
|
|
<button
|
|
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.',
|
|
)
|
|
}
|
|
>
|
|
+ CC-Paket
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void run(
|
|
() =>
|
|
api(`/api/players/${playerId}/credits/redesign-package`, {
|
|
method: 'POST',
|
|
body: {},
|
|
}),
|
|
'Redesign-Paket eingetragen: +1 Redesign.',
|
|
)
|
|
}
|
|
>
|
|
+ Redesign-Paket
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void run(
|
|
() => api(`/api/players/${playerId}/credits/kulanz`, { method: 'POST', body: {} }),
|
|
'Kulanz-Redesign eingetragen: +1 Kulanz.',
|
|
)
|
|
}
|
|
>
|
|
+ Kulanz
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={busy}
|
|
onClick={() => setDialog({ type: 'newCharacter' })}
|
|
>
|
|
Neuer CC
|
|
</button>
|
|
{can('ADMIN') && detail.player.steamId && (
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
disabled={busy}
|
|
title="Pakete dieses Spielers von GExtension abrufen"
|
|
onClick={() => void syncFromGextension()}
|
|
>
|
|
GExtension abgleichen
|
|
</button>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
<section className="block">
|
|
<h2>Aktive CCs ({active.length})</h2>
|
|
{active.length === 0 ? (
|
|
<p className="muted">Keine aktiven CCs.</p>
|
|
) : (
|
|
<ul className="cc-list">
|
|
{active.map((character) => (
|
|
<li key={character.id} className="cc-item">
|
|
<div>
|
|
<strong>{character.name}</strong>
|
|
{character.predecessor && (
|
|
<span className="muted"> · Nachfolger von {character.predecessor.name}</span>
|
|
)}
|
|
{character.loreText && <p className="lore">{character.loreText}</p>}
|
|
<span className="muted small">Seit {formatDate(character.createdAt)}</span>
|
|
</div>
|
|
{editor && (
|
|
<div className="cc-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-small"
|
|
onClick={() => setDialog({ type: 'redesign', character })}
|
|
>
|
|
Redesign
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-small btn-ghost"
|
|
onClick={() => setDialog({ type: 'deactivate', character })}
|
|
>
|
|
Deaktivieren
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-small btn-danger"
|
|
onClick={() => setDialog({ type: 'revert', character })}
|
|
>
|
|
Zurücknehmen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section className="block">
|
|
<h2>Inaktive CCs ({inactive.length})</h2>
|
|
{inactive.length === 0 ? (
|
|
<p className="muted">Keine inaktiven CCs.</p>
|
|
) : (
|
|
<ul className="cc-list">
|
|
{inactive.map((character) => {
|
|
// Nur ein redesignter CC existiert im Original nicht mehr.
|
|
const free = character.inactiveReason !== 'REWORKED';
|
|
return (
|
|
<li key={character.id} className="cc-item is-inactive">
|
|
<div>
|
|
<strong>{character.name}</strong>
|
|
<span className={`badge ${free ? 'badge-free' : 'badge-paid'}`}>
|
|
{character.inactiveReason
|
|
? REASON_LABEL[character.inactiveReason]
|
|
: 'Unbekannt'}
|
|
</span>
|
|
{character.successors.length > 0 && (
|
|
<span className="muted">
|
|
{' '}
|
|
· Ersetzt durch {character.successors.map((s) => s.name).join(', ')}
|
|
</span>
|
|
)}
|
|
{character.inactiveNote && (
|
|
<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.'}
|
|
</p>
|
|
</div>
|
|
{editor && (
|
|
<div className="cc-actions">
|
|
{free ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-small btn-primary"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
void run(
|
|
() =>
|
|
api(`/api/characters/${character.id}/reactivate-free`, {
|
|
method: 'POST',
|
|
body: {},
|
|
}),
|
|
`"${character.name}" ist wieder aktiv — kostenlos.`,
|
|
)
|
|
}
|
|
>
|
|
Gratis reaktivieren
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn btn-small"
|
|
onClick={() => setDialog({ type: 'reactivatePaid', character })}
|
|
>
|
|
Bezahlt reaktivieren
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section className="block">
|
|
<h2>Guthaben-Historie</h2>
|
|
<div className="table-wrap">
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Zeitpunkt</th>
|
|
<th>Topf</th>
|
|
<th className="num">Menge</th>
|
|
<th>Anlass</th>
|
|
<th>Notiz</th>
|
|
<th>Von</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{detail.ledger.map((entry) => (
|
|
<tr key={entry.id} className={entry.reversedBy ? 'is-reversed' : undefined}>
|
|
<td className="muted small">{formatDate(entry.createdAt)}</td>
|
|
<td>{KIND_LABEL[entry.kind]}</td>
|
|
<td className={`num ${entry.amount < 0 ? 'neg' : 'pos'}`}>
|
|
{formatAmount(entry.amount)}
|
|
</td>
|
|
<td>{SOURCE_LABEL[entry.source]}</td>
|
|
<td className="muted small">
|
|
{entry.note ?? '—'}
|
|
{entry.character && ` (${entry.character.name})`}
|
|
</td>
|
|
<td className="muted small">{entry.createdBy?.username ?? 'System'}</td>
|
|
<td className="num">
|
|
{editor &&
|
|
!entry.reversedBy &&
|
|
entry.source !== 'CONSUME' &&
|
|
entry.source !== 'REVERSAL' && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-tiny btn-ghost"
|
|
onClick={() => setDialog({ type: 'reverse', entry })}
|
|
>
|
|
Storno
|
|
</button>
|
|
)}
|
|
{entry.reversedBy && <span className="muted small">storniert</span>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{detail.ledger.length === 0 && (
|
|
<tr>
|
|
<td colSpan={7} className="empty">
|
|
Noch keine Buchungen.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{detail.ledgerTotal > detail.ledger.length && (
|
|
<p className="muted">
|
|
{detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt.
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
{dialog.type === 'newCharacter' && (
|
|
<CharacterFormModal
|
|
title="Neuen CC anlegen"
|
|
submitLabel="Anlegen"
|
|
hint="Kostet 1 CC-Guthaben. Kulanz-Guthaben ist dafür nicht einsetzbar."
|
|
busy={busy}
|
|
onClose={() => setDialog({ type: 'none' })}
|
|
onSubmit={(values) =>
|
|
void run(
|
|
() =>
|
|
api(`/api/players/${playerId}/characters`, {
|
|
method: 'POST',
|
|
body: values,
|
|
}),
|
|
`CC "${values.name}" angelegt — 1 CC-Guthaben verbraucht.`,
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{dialog.type === 'redesign' && (
|
|
<CharacterFormModal
|
|
title={`Redesign von "${dialog.character.name}"`}
|
|
submitLabel="Redesign durchführen"
|
|
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
|
|
busy={busy}
|
|
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID']}
|
|
balances={detail.balances}
|
|
onClose={() => setDialog({ type: 'none' })}
|
|
onSubmit={(values) =>
|
|
void run(
|
|
() =>
|
|
api(`/api/characters/${dialog.character.id}/redesign`, {
|
|
method: 'POST',
|
|
body: values,
|
|
}),
|
|
`Redesign gebucht: "${dialog.character.name}" → "${values.name}".`,
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{dialog.type === 'deactivate' && (
|
|
<DeactivateModal
|
|
character={dialog.character}
|
|
busy={busy}
|
|
onClose={() => setDialog({ type: 'none' })}
|
|
onSubmit={(reason, note) =>
|
|
void run(
|
|
() =>
|
|
api(`/api/characters/${dialog.character.id}/deactivate`, {
|
|
method: 'POST',
|
|
body: { reason, ...(note.trim() ? { note: note.trim() } : {}) },
|
|
}),
|
|
`"${dialog.character.name}" ist jetzt inaktiv.`,
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{dialog.type === 'reactivatePaid' && (
|
|
<KindChoiceModal
|
|
title={`"${dialog.character.name}" reaktivieren`}
|
|
hint="Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Reaktivierung kostet ein Guthaben."
|
|
kinds={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
|
|
balances={detail.balances}
|
|
busy={busy}
|
|
onClose={() => setDialog({ type: 'none' })}
|
|
onSubmit={(useKind) =>
|
|
void run(
|
|
() =>
|
|
api(`/api/characters/${dialog.character.id}/reactivate-paid`, {
|
|
method: 'POST',
|
|
body: { useKind },
|
|
}),
|
|
`"${dialog.character.name}" ist wieder aktiv.`,
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{dialog.type === 'revert' && (
|
|
<ReasonModal
|
|
title={`"${dialog.character.name}" zurücknehmen`}
|
|
hint="Der CC wird gelöscht, verbrauchte Guthaben werden zurückgebucht. Für Fehleinträge gedacht."
|
|
confirmLabel="Zurücknehmen"
|
|
danger
|
|
busy={busy}
|
|
onClose={() => setDialog({ type: 'none' })}
|
|
onSubmit={(reason) =>
|
|
void run(
|
|
() =>
|
|
api(`/api/characters/${dialog.character.id}/revert`, {
|
|
method: 'POST',
|
|
body: { reason },
|
|
}),
|
|
`"${dialog.character.name}" wurde zurückgenommen.`,
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{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"
|
|
hint="Es wird eine Gegenbuchung erzeugt. Bei einem CC-Paket betrifft das beide Zeilen."
|
|
confirmLabel="Stornieren"
|
|
busy={busy}
|
|
onClose={() => setDialog({ type: 'none' })}
|
|
onSubmit={(reason) =>
|
|
void run(
|
|
() =>
|
|
api(`/api/credits/${dialog.entry.id}/reverse`, {
|
|
method: 'POST',
|
|
body: { reason },
|
|
}),
|
|
'Buchung storniert.',
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface CharacterValues {
|
|
name: string;
|
|
loreText?: string;
|
|
useKind?: CreditKind;
|
|
}
|
|
|
|
function CharacterFormModal({
|
|
title,
|
|
submitLabel,
|
|
hint,
|
|
busy,
|
|
withKind,
|
|
balances,
|
|
onClose,
|
|
onSubmit,
|
|
}: {
|
|
title: string;
|
|
submitLabel: string;
|
|
hint: string;
|
|
busy: boolean;
|
|
withKind?: CreditKind[];
|
|
balances?: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
|
|
onClose: () => void;
|
|
onSubmit: (values: CharacterValues) => void;
|
|
}) {
|
|
const [name, setName] = useState('');
|
|
const [loreText, setLoreText] = useState('');
|
|
// Kulanz steht bewusst vorne: sie soll zuerst aufgebraucht werden.
|
|
const [useKind, setUseKind] = useState<CreditKind>(withKind?.[0] ?? 'CC');
|
|
|
|
return (
|
|
<Modal title={title} onClose={onClose}>
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
onSubmit({
|
|
name,
|
|
...(loreText.trim() ? { loreText: loreText.trim() } : {}),
|
|
...(withKind ? { useKind } : {}),
|
|
});
|
|
}}
|
|
>
|
|
<Notice kind="info">{hint}</Notice>
|
|
|
|
<label htmlFor="cc-name">Name des CCs</label>
|
|
<input
|
|
id="cc-name"
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
autoFocus
|
|
required
|
|
/>
|
|
|
|
<label htmlFor="cc-lore">Lore / Notiz (optional)</label>
|
|
<textarea
|
|
id="cc-lore"
|
|
rows={3}
|
|
value={loreText}
|
|
onChange={(event) => setLoreText(event.target.value)}
|
|
/>
|
|
|
|
{withKind && balances && (
|
|
<>
|
|
<label>Welches Guthaben soll verbraucht werden?</label>
|
|
<div className="radio-group">
|
|
{withKind.map((kind) => (
|
|
<label key={kind} className="radio">
|
|
<input
|
|
type="radio"
|
|
name="useKind"
|
|
value={kind}
|
|
checked={useKind === kind}
|
|
onChange={() => setUseKind(kind)}
|
|
/>
|
|
<span>
|
|
{KIND_LABEL[kind]}
|
|
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
|
|
{' '}
|
|
({balances[kind]} verfügbar)
|
|
</span>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<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 …' : submitLabel}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function KindChoiceModal({
|
|
title,
|
|
hint,
|
|
kinds,
|
|
balances,
|
|
busy,
|
|
onClose,
|
|
onSubmit,
|
|
}: {
|
|
title: string;
|
|
hint: string;
|
|
kinds: CreditKind[];
|
|
balances: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
|
|
busy: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (kind: CreditKind) => void;
|
|
}) {
|
|
const [useKind, setUseKind] = useState<CreditKind>(kinds[0] ?? 'CC');
|
|
|
|
return (
|
|
<Modal title={title} onClose={onClose}>
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
onSubmit(useKind);
|
|
}}
|
|
>
|
|
<Notice kind="info">{hint}</Notice>
|
|
|
|
<div className="radio-group">
|
|
{kinds.map((kind) => (
|
|
<label key={kind} className="radio">
|
|
<input
|
|
type="radio"
|
|
name="useKind"
|
|
value={kind}
|
|
checked={useKind === kind}
|
|
onChange={() => setUseKind(kind)}
|
|
/>
|
|
<span>
|
|
{KIND_LABEL[kind]}
|
|
<span className={balances[kind] < 1 ? 'neg' : 'muted'}>
|
|
{' '}
|
|
({balances[kind]} verfügbar)
|
|
</span>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
<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 …' : 'Bestätigen'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function DeactivateModal({
|
|
character,
|
|
busy,
|
|
onClose,
|
|
onSubmit,
|
|
}: {
|
|
character: Character;
|
|
busy: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (reason: 'PLAYER_INACTIVITY' | 'OTHER', note: string) => void;
|
|
}) {
|
|
const [reason, setReason] = useState<'PLAYER_INACTIVITY' | 'OTHER'>('PLAYER_INACTIVITY');
|
|
const [note, setNote] = useState('');
|
|
|
|
return (
|
|
<Modal title={`"${character.name}" deaktivieren`} onClose={onClose}>
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
onSubmit(reason, note);
|
|
}}
|
|
>
|
|
<Notice kind="info">
|
|
Der CC bleibt unverändert und kommt später in beiden Fällen gratis zurück. Nur ein
|
|
Redesign macht ihn kostenpflichtig — das läuft aber über die Redesign-Aktion.
|
|
</Notice>
|
|
|
|
<div className="radio-group">
|
|
<label className="radio">
|
|
<input
|
|
type="radio"
|
|
name="reason"
|
|
checked={reason === 'PLAYER_INACTIVITY'}
|
|
onChange={() => setReason('PLAYER_INACTIVITY')}
|
|
/>
|
|
<span>
|
|
<strong>Spieler-Inaktivität</strong>
|
|
<span className="muted"> — vom Server geputzt, weil der Spieler länger weg war</span>
|
|
</span>
|
|
</label>
|
|
<label className="radio">
|
|
<input
|
|
type="radio"
|
|
name="reason"
|
|
checked={reason === 'OTHER'}
|
|
onChange={() => setReason('OTHER')}
|
|
/>
|
|
<span>
|
|
<strong>Anderer Grund</strong>
|
|
<span className="muted"> — etwa eine vorübergehend entfernte Einheit</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<label htmlFor="deactivate-note">
|
|
Notiz {reason === 'OTHER' ? '(empfohlen)' : '(optional)'}
|
|
</label>
|
|
<input
|
|
id="deactivate-note"
|
|
value={note}
|
|
onChange={(event) => setNote(event.target.value)}
|
|
placeholder="z. B. Einheit bis März entfernt"
|
|
/>
|
|
|
|
<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 …' : 'Deaktivieren'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function ReasonModal({
|
|
title,
|
|
hint,
|
|
confirmLabel,
|
|
danger,
|
|
busy,
|
|
onClose,
|
|
onSubmit,
|
|
}: {
|
|
title: string;
|
|
hint: string;
|
|
confirmLabel: string;
|
|
danger?: boolean;
|
|
busy: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (reason: string) => void;
|
|
}) {
|
|
const [reason, setReason] = useState('');
|
|
|
|
return (
|
|
<Modal title={title} onClose={onClose}>
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
onSubmit(reason);
|
|
}}
|
|
>
|
|
<Notice kind={danger ? 'warn' : 'info'}>{hint}</Notice>
|
|
|
|
<label htmlFor="reason">Begründung</label>
|
|
<input
|
|
id="reason"
|
|
value={reason}
|
|
onChange={(event) => setReason(event.target.value)}
|
|
placeholder="z. B. versehentlich doppelt eingetragen"
|
|
autoFocus
|
|
required
|
|
/>
|
|
|
|
<div className="form-actions">
|
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className={`btn ${danger ? 'btn-danger' : 'btn-primary'}`}
|
|
disabled={busy}
|
|
>
|
|
{busy ? 'Speichere …' : confirmLabel}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</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>
|
|
);
|
|
}
|