CC-Guthaben wird vorher umgewandelt, nicht am CC eingesetzt

Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein Guthaben dafuer
einsetzen will, tauscht es oben um und setzt es danach ein wie jedes
andere Redesign. Damit faellt die Wahl CC-Guthaben an den CC-Aktionen
weg; was ein Eintrag kostet, entscheidet seine Stelle in der Liste.

Die Bezahlart gilt jetzt je Nutzer: bei einem Gruppen-CC hat jeder seine
eigene Buchung, und umstellen liess sich bisher nur die des Erstellers.

Die Guthaben-Historie laesst sich nach Topf filtern.
This commit is contained in:
2026-09-22 12:42:48 +02:00
parent 1f4034b397
commit fee4a4f73a
7 changed files with 302 additions and 216 deletions
+114 -28
View File
@@ -66,6 +66,8 @@ export default function PlayerDetail() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null);
/// Welcher Topf in der Historie gezeigt wird; null heisst alle.
const [topf, setTopf] = useState<CreditKind | null>(null);
const [dialog, setDialog] = useState<DialogState>({ type: 'none' });
const [busy, setBusy] = useState(false);
@@ -198,6 +200,19 @@ export default function PlayerDetail() {
);
};
const umwandeln = async () => {
const ja = await bestaetigen({
titel: 'Ein CC-Guthaben in ein Redesign umwandeln?',
text: 'Das kostet einen CC-Platz und bringt ein bezahltes Redesign. Einsetzen lässt es sich danach wie jedes andere Redesign.',
knopf: 'Umwandeln',
});
if (!ja) return;
await run(
() => api(`/api/players/${playerId}/credits/convert`, { method: 'POST', body: {} }),
'CC-Guthaben in ein Redesign umgewandelt.',
);
};
// Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung
// auch sagen, sonst wirkt der Knopf wirkungslos. Gelesen werden alle Konten
// des Spielers, deshalb kann auch ein einzelner Lauf dauern.
@@ -222,6 +237,8 @@ export default function PlayerDetail() {
const editor = can('EDITOR');
const admin = can('ADMIN');
const gezeigteBuchungen =
topf === null ? detail.ledger : detail.ledger.filter((entry) => entry.kind === topf);
const playerName = detail.player.displayName;
@@ -418,6 +435,30 @@ export default function PlayerDetail() {
</button>
</span>
)}
{/* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein Guthaben
dafür einsetzen will, tauscht es hier - danach ist es ein
Redesign wie jedes andere. */}
<span className="credit-pair">
<button
type="button"
className="btn"
disabled={busy}
title="Ein CC-Guthaben in ein bezahltes Redesign tauschen: ein Platz weniger, ein Redesign mehr"
onClick={() => void umwandeln()}
>
CC → Redesign
</button>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
title="Die zuletzt vorgenommene Umwandlung zurücknehmen"
onClick={() => void zugabeZuruecknehmen('convert', 'umgewandelte CC-Guthaben')}
>
−
</button>
</span>
<button
type="button"
className="btn btn-primary"
@@ -505,6 +546,27 @@ export default function PlayerDetail() {
<section className="block">
<h2>Guthaben-Historie</h2>
{/* Wer wissen will, wo eine einzelne Kulanz geblieben ist, soll nicht
durch hundert Zeilen scrollen müssen. */}
<div className="filter-row">
{(
[
[null, 'Alle Töpfe'],
['CC', KIND_LABEL.CC],
['REDESIGN_PAID', KIND_LABEL.REDESIGN_PAID],
['REDESIGN_KULANZ', KIND_LABEL.REDESIGN_KULANZ],
] as const
).map(([wert, label]) => (
<button
key={label}
type="button"
className={`btn btn-tiny ${topf === wert ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setTopf(wert)}
>
{label}
</button>
))}
</div>
<div className="table-wrap">
<table className="table">
<thead>
@@ -519,7 +581,7 @@ export default function PlayerDetail() {
</tr>
</thead>
<tbody>
{detail.ledger.map((entry) => (
{gezeigteBuchungen.map((entry) => (
<tr key={entry.id} className={entry.reversedBy ? 'is-reversed' : undefined}>
<td
className="muted small"
@@ -558,10 +620,12 @@ export default function PlayerDetail() {
</td>
</tr>
))}
{detail.ledger.length === 0 && (
{gezeigteBuchungen.length === 0 && (
<tr>
<td colSpan={7} className="empty">
Noch keine Buchungen.
{topf === null
? 'Noch keine Buchungen.'
: `In diesem Topf gibt es keine Buchung.`}
</td>
</tr>
)}
@@ -570,7 +634,7 @@ export default function PlayerDetail() {
</div>
{detail.ledgerTotal > detail.ledger.length && (
<p className="muted">
{detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt.
{detail.ledger.length} von {detail.ledgerTotal} Buchungen geladen.
</p>
)}
</section>
@@ -601,7 +665,7 @@ export default function PlayerDetail() {
submitLabel="Redesign durchführen"
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
busy={busy}
withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']}
withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ']}
balances={detail.balances}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
@@ -643,7 +707,7 @@ export default function PlayerDetail() {
() =>
api(`/api/characters/${dialog.character.id}/funding`, {
method: 'POST',
body: { useKind },
body: { useKind, forPlayerId: playerId },
}),
`Bezahlart von "${dialog.character.name}" umgebucht.`,
)
@@ -666,7 +730,7 @@ export default function PlayerDetail() {
submitLabel="Redesign durchführen"
hint={`${playerName} steigt aus "${dialog.character.name}" aus und bekommt dafür einen eigenen CC. Die übrigen Nutzer bleiben unberührt. Ein Redesign tauscht einen Platz, es kommt keiner hinzu.`}
busy={busy}
withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']}
withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ']}
balances={detail.balances}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
@@ -724,8 +788,16 @@ export default function PlayerDetail() {
{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']}
hint={
dialog.character.predecessorId === null
? 'Dieser CC steht am Anfang einer Liste - seine Rückkehr kostet einen eigenen Platz.'
: 'Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr kostet ein Redesign.'
}
kinds={
dialog.character.predecessorId === null
? ['CC', 'REDESIGN_PAID', 'REDESIGN_KULANZ']
: ['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']
}
balances={detail.balances}
busy={busy}
onClose={() => setDialog({ type: 'none' })}
@@ -1069,12 +1141,6 @@ function CharacterFormModal({
</label>
))}
</div>
{useKind === 'CC' && (
<p className="muted small">
Ein CC-Guthaben für ein Redesign wird umgewandelt: ein Platz weniger, ein
bezahltes Redesign mehr.
</p>
)}
</>
)}
@@ -1688,7 +1754,11 @@ function ReviveDeadModal({
}) {
const [reason, setReason] = useState('');
const [kostet, setKostet] = useState(false);
const [useKind, setUseKind] = useState<CreditKind>('CC');
// Am Anfang einer Liste kostet die Rückkehr einen Platz, darüber ein
// Redesign - das ergibt sich aus der Stelle, nicht aus der Wahl.
const [useKind, setUseKind] = useState<CreditKind>(
character.predecessorId === null ? 'CC' : 'REDESIGN_PAID',
);
return (
<Modal title={`"${character.name}" zurückholen`} onClose={onClose}>
@@ -1730,12 +1800,17 @@ function ReviveDeadModal({
{kostet && (
<div className="radio-group">
{(
[
['CC', 'CC-Guthaben', balances.CC],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
] as const
{(character.predecessorId === null
? ([
['CC', 'CC-Guthaben', balances.CC],
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
] as const)
: ([
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
['CC', 'CC-Guthaben', balances.CC],
] as const)
).map(([wert, label, stand]) => (
<label key={wert} className="radio">
<input
@@ -1878,22 +1953,33 @@ function RedesignKindModal({
onClose: () => void;
onSubmit: (useKind: CreditKind) => void;
}) {
const [useKind, setUseKind] = useState<CreditKind>('REDESIGN_KULANZ');
const [useKind, setUseKind] = useState<CreditKind>('REDESIGN_PAID');
// Am Anfang einer Liste bucht ein Eintrag einen Platz; das ergibt sich aus
// der Stelle und steht nicht zur Wahl. Zu entscheiden bleibt, ob ein
// Redesign bezahlt wurde oder auf Kulanz lief.
const amAnfang = character.predecessorId === null;
return (
<Modal title={`Bezahlart von "${character.name}"`} onClose={onClose}>
<Notice kind="info">
Woraus ein CC bezahlt wurde, stellt sich manchmal erst später heraus - etwa wenn eine
Absprache auftaucht oder sich zeigt, dass ein Eintrag gar nicht der Anfang einer Kette
war. Umgebucht wird über eine Gegenbuchung; in der Historie bleibt beides sichtbar.
Woraus ein Redesign bezahlt wurde, stellt sich manchmal erst später heraus - etwa wenn
eine Absprache auftaucht. Umgebucht wird über eine Gegenbuchung; in der Historie bleibt
beides sichtbar.
</Notice>
{amAnfang && (
<Notice kind="warn">
Dieser Eintrag steht am Anfang einer Liste und bucht damit einen eigenen Platz aus dem
CC-Guthaben. Erst über einem anderen Eintrag wird daraus ein Redesign.
</Notice>
)}
<div className="radio-group">
{(
[
['CC', 'CC-Paket (eigener Platz)', balances.CC],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
] as const
).map(([wert, label, stand]) => (
<label key={wert} className="radio">
+8
View File
@@ -1327,3 +1327,11 @@ textarea {
border-color: var(--warn);
color: var(--warn);
}
/* Knopfreihe über einer Tabelle, etwa zum Filtern der Historie. */
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.6rem;
}