Weboberflaeche mit Spielerkonto, Import und Verwaltung
Die Spieler-Detailseite beantwortet die Kernfrage direkt: drei grosse Zaehler, rot sobald ein Topf im Minus ist, darunter ob ein neuer CC oder ein Redesign gerade moeglich ist. Inaktive CCs zeigen ihren Grund und bieten genau die passende Aktion an - gratis reaktivieren oder bezahlt. Beim Redesign steht der Kulanz-Topf an erster Stelle, damit er zuerst aufgebraucht wird. Der Import fuehrt ueber Spaltenzuordnung und Vorschau; gebucht wird erst nach Bestaetigung.
This commit is contained in:
@@ -0,0 +1,730 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import BalanceCards from '../components/BalanceCards';
|
||||
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 } 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 };
|
||||
|
||||
export default function PlayerDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const playerId = Number(id);
|
||||
const { can } = useSession();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
||||
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
||||
|
||||
<BalanceCards balances={detail.balances} />
|
||||
|
||||
{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>
|
||||
</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 })}
|
||||
>
|
||||
Zuruecknehmen
|
||||
</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) => {
|
||||
const free = character.inactiveReason === 'PLAYER_INACTIVITY';
|
||||
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>
|
||||
)}
|
||||
<p className="muted small">
|
||||
{free
|
||||
? 'Kommt gratis zurueck — 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 dafuer 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 durchfuehren"
|
||||
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) =>
|
||||
void run(
|
||||
() =>
|
||||
api(`/api/characters/${dialog.character.id}/deactivate`, {
|
||||
method: 'POST',
|
||||
body: { reason },
|
||||
}),
|
||||
`"${dialog.character.name}" ist jetzt inaktiv.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dialog.type === 'reactivatePaid' && (
|
||||
<KindChoiceModal
|
||||
title={`"${dialog.character.name}" reaktivieren`}
|
||||
hint="Dieser CC 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}" zuruecknehmen`}
|
||||
hint="Der CC wird geloescht, verbrauchte Guthaben werden zurueckgebucht. Fuer Fehleintraege gedacht."
|
||||
confirmLabel="Zuruecknehmen"
|
||||
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 zurueckgenommen.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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]} verfuegbar)
|
||||
</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]} verfuegbar)
|
||||
</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 …' : 'Bestaetigen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DeactivateModal({
|
||||
character,
|
||||
busy,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
character: Character;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (reason: 'PLAYER_INACTIVITY' | 'OTHER') => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState<'PLAYER_INACTIVITY' | 'OTHER'>('PLAYER_INACTIVITY');
|
||||
|
||||
return (
|
||||
<Modal title={`"${character.name}" deaktivieren`} onClose={onClose}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit(reason);
|
||||
}}
|
||||
>
|
||||
<Notice kind="info">
|
||||
Der Grund entscheidet, ob die spaetere Reaktivierung kostenlos ist.
|
||||
</Notice>
|
||||
|
||||
<div className="radio-group">
|
||||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="reason"
|
||||
checked={reason === 'PLAYER_INACTIVITY'}
|
||||
onChange={() => setReason('PLAYER_INACTIVITY')}
|
||||
/>
|
||||
<span>
|
||||
<strong>Spieler-Inaktivitaet</strong>
|
||||
<span className="muted"> — vom Server geputzt, kommt gratis zurueck</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="reason"
|
||||
checked={reason === 'OTHER'}
|
||||
onChange={() => setReason('OTHER')}
|
||||
/>
|
||||
<span>
|
||||
<strong>Sonstiger Grund</strong>
|
||||
<span className="muted"> — Reaktivierung kostet ein Guthaben</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 …' : '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">Begruendung</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user