Rueckfragen im Stil der Anwendung, Kulanz-Ausgleich, Redesign aus dem CC-Topf

window.confirm und window.prompt sind raus - alle vierzehn Stellen. Sie sahen
nicht nur fremd aus: Wer sie im Browser abschaltet, bekommt stillschweigend
"nein" zurueck, und eine Aktion passiert einfach nicht, ohne dass jemand
erfaehrt warum. Stattdessen gibt es einen Dialog-Dienst mit drei Formen -
bestaetigen, abfragen, waehlen -, der sich wie der Rest der Anwendung
anfuehlt.

Die Wahl kam dazu, weil eine Stelle ein Ja/Nein fuer eine Auswahl
missbrauchte: "OK = Kulanz, Abbrechen = bezahltes Redesign". Das fuehrt jeden
in die Irre, der es zum ersten Mal sieht.

Neu: Steht der bezahlte Redesign-Topf im Minus, waehrend Kulanz uebrig ist,
erscheint "Minus mit Kulanz ausgleichen". Es bucht so viele Uebergaenge um,
wie beide Seiten hergeben - die aeltesten zuerst, damit das Ergebnis
wiederholbar ist. Unterm Strich aendert sich nichts, nur die Verteilung.

Ebenfalls neu: Ein Redesign laesst sich ausnahmsweise aus dem CC-Topf
bezahlen. Damit das Ausrichten an den Ketten so eine Entscheidung nicht beim
naechsten Umsortieren wieder einebnet, tragen von Hand gewaehlte Buchungen
jetzt einen Merker.

Dabei aufgefallen: Die Tests wurden gar nicht typgeprueft - tsconfig schloss
nur src ein. Sieben Aufrufe von bookKulanz hatten vertauschte Argumente und
liefen trotzdem durch, weil JavaScript das hinnimmt; sie pruefte damit etwas
anderes als gedacht. Tests haengen jetzt in der Typpruefung.
This commit is contained in:
2026-09-22 09:39:57 +02:00
parent 9b93844be2
commit 34819d44cc
19 changed files with 709 additions and 61 deletions
+10 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import Notice from './Notice';
import { useDialog } from './DialogProvider';
import { ApiError, api } from '../lib/api';
import type { AliasType, PlayerAlias } from '../lib/types';
@@ -18,6 +19,8 @@ export default function AliasBox({
canEdit: boolean;
onChange: () => void;
}) {
const { bestaetigen } = useDialog();
const [type, setType] = useState<AliasType>('STEAM_ID');
const [value, setValue] = useState('');
const [note, setNote] = useState('');
@@ -48,7 +51,13 @@ export default function AliasBox({
};
const entfernen = async (alias: PlayerAlias) => {
if (!window.confirm(`"${alias.value}" wirklich entfernen?`)) return;
const ja = await bestaetigen({
titel: 'Früheres Konto entfernen',
text: `Soll "${alias.value}" nicht mehr als früheres Konto geführt werden? Abgleich und Import finden Käufe darunter danach nicht mehr.`,
knopf: 'Entfernen',
gefahr: true,
});
if (!ja) return;
try {
await api(`/api/players/aliases/${alias.id}`, { method: 'DELETE' });
onChange();
+9 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import MemberList from './MemberList';
import { useDialog } from './DialogProvider';
import Notice from './Notice';
import { ApiError, api } from '../lib/api';
import { REASON_LABEL, formatDate, rueckkehrArt } from '../lib/format';
@@ -131,6 +132,8 @@ export default function ChainView({
onMoved: () => void;
onError: (message: string) => void;
}) {
const { bestaetigen } = useDialog();
const [ziehe, setZiehe] = useState<Character | null>(null);
const [ueber, setUeber] = useState<string | null>(null);
@@ -208,7 +211,12 @@ export default function ChainView({
};
const herausloesen = async (character: Character) => {
if (!window.confirm(`"${character.name}" aus seiner Kette lösen und allein stellen?`)) return;
const ja = await bestaetigen({
titel: `"${character.name}" herauslösen`,
text: 'Der Eintrag steht danach allein - als eigener Platz, der ein CC-Paket kostet. Die übrige Kette rückt zusammen.',
knopf: 'Herauslösen',
});
if (!ja) return;
setMeldung(null);
try {
await api(`/api/characters/${character.id}/detach`, { method: 'POST', body: {} });
+207
View File
@@ -0,0 +1,207 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import Modal from './Modal';
import Notice from './Notice';
/**
* Rückfragen im Stil der Anwendung statt als Browser-Dialog.
*
* `window.confirm` und `window.prompt` sehen nicht nur fremd aus - sie lassen
* sich im Browser abschalten. Danach liefern sie stillschweigend "nein"
* beziehungsweise nichts zurück, und eine Aktion passiert einfach nicht,
* ohne dass jemand erfährt warum. Das ist der eigentliche Grund, warum sie
* hier nichts zu suchen haben.
*/
interface Bestaetigung {
titel: string;
text: ReactNode;
/// Beschriftung des bestätigenden Knopfs.
knopf?: string;
/// Rot, wenn dabei etwas verloren geht.
gefahr?: boolean;
}
interface Abfrage {
titel: string;
text?: ReactNode;
label: string;
platzhalter?: string;
vorgabe?: string;
knopf?: string;
/// Für Passwörter: Eingabe verdecken.
geheim?: boolean;
}
interface Wahl<T extends string> {
titel: string;
text?: ReactNode;
/// Die Möglichkeiten, jeweils Wert und Beschriftung.
optionen: { wert: T; label: string; hinweis?: string }[];
vorgabe?: T;
knopf?: string;
}
interface DialogDienst {
bestaetigen: (optionen: Bestaetigung) => Promise<boolean>;
abfragen: (optionen: Abfrage) => Promise<string | null>;
/**
* Eine Auswahl aus mehreren Möglichkeiten. Ein Ja/Nein-Dialog, dessen
* "Abbrechen" in Wahrheit die zweite Möglichkeit bedeutet, führt jeden in
* die Irre, der ihn zum ersten Mal sieht.
*/
waehlen: <T extends string>(optionen: Wahl<T>) => Promise<T | null>;
}
const DialogContext = createContext<DialogDienst | null>(null);
type Offen =
| { art: 'bestaetigung'; optionen: Bestaetigung }
| { art: 'abfrage'; optionen: Abfrage }
| { art: 'wahl'; optionen: Wahl<string> }
| null;
export function DialogProvider({ children }: { children: ReactNode }) {
const [offen, setOffen] = useState<Offen>(null);
const [eingabe, setEingabe] = useState('');
// Die Antwort geht an den Aufrufer zurück, der auf sein Promise wartet.
const antwort = useRef<((wert: boolean | string | null) => void) | null>(null);
const schliessen = useCallback((wert: boolean | string | null) => {
antwort.current?.(wert);
antwort.current = null;
setOffen(null);
setEingabe('');
}, []);
const bestaetigen = useCallback((optionen: Bestaetigung) => {
setOffen({ art: 'bestaetigung', optionen });
return new Promise<boolean>((resolve) => {
antwort.current = (wert) => resolve(wert === true);
});
}, []);
const abfragen = useCallback((optionen: Abfrage) => {
setOffen({ art: 'abfrage', optionen });
setEingabe(optionen.vorgabe ?? '');
return new Promise<string | null>((resolve) => {
antwort.current = (wert) => resolve(typeof wert === 'string' ? wert : null);
});
}, []);
const waehlen = useCallback(<T extends string>(optionen: Wahl<T>) => {
setOffen({ art: 'wahl', optionen: optionen as Wahl<string> });
setEingabe(optionen.vorgabe ?? optionen.optionen[0]?.wert ?? '');
return new Promise<T | null>((resolve) => {
antwort.current = (wert) => resolve(typeof wert === 'string' ? (wert as T) : null);
});
}, []);
const dienst = useMemo<DialogDienst>(
() => ({ bestaetigen, abfragen, waehlen }),
[bestaetigen, abfragen, waehlen],
);
return (
<DialogContext.Provider value={dienst}>
{children}
{offen?.art === 'bestaetigung' && (
<Modal title={offen.optionen.titel} onClose={() => schliessen(false)}>
<Notice kind={offen.optionen.gefahr ? 'warn' : 'info'}>{offen.optionen.text}</Notice>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={() => schliessen(false)}>
Abbrechen
</button>
<button
type="button"
className={offen.optionen.gefahr ? 'btn btn-danger' : 'btn btn-primary'}
autoFocus
onClick={() => schliessen(true)}
>
{offen.optionen.knopf ?? 'Bestätigen'}
</button>
</div>
</Modal>
)}
{offen?.art === 'wahl' && (
<Modal title={offen.optionen.titel} onClose={() => schliessen(null)}>
{offen.optionen.text && <Notice kind="info">{offen.optionen.text}</Notice>}
<div className="radio-group">
{offen.optionen.optionen.map((option) => (
<label key={option.wert} className="radio">
<input
type="radio"
name="dialog-wahl"
checked={eingabe === option.wert}
onChange={() => setEingabe(option.wert)}
/>
<span>
<strong>{option.label}</strong>
{option.hinweis && <span className="muted"> - {option.hinweis}</span>}
</span>
</label>
))}
</div>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={() => schliessen(null)}>
Abbrechen
</button>
<button
type="button"
className="btn btn-primary"
disabled={!eingabe}
onClick={() => schliessen(eingabe)}
>
{offen.optionen.knopf ?? 'Übernehmen'}
</button>
</div>
</Modal>
)}
{offen?.art === 'abfrage' && (
<Modal title={offen.optionen.titel} onClose={() => schliessen(null)}>
<form
onSubmit={(event) => {
event.preventDefault();
if (eingabe.trim()) schliessen(eingabe.trim());
}}
>
{offen.optionen.text && <Notice kind="info">{offen.optionen.text}</Notice>}
<label htmlFor="dialog-eingabe">{offen.optionen.label}</label>
<input
id="dialog-eingabe"
type={offen.optionen.geheim ? 'password' : 'text'}
value={eingabe}
placeholder={offen.optionen.platzhalter ?? ''}
onChange={(event) => setEingabe(event.target.value)}
autoFocus
required
/>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={() => schliessen(null)}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={!eingabe.trim()}>
{offen.optionen.knopf ?? 'Übernehmen'}
</button>
</div>
</form>
</Modal>
)}
</DialogContext.Provider>
);
}
export function useDialog(): DialogDienst {
const dienst = useContext(DialogContext);
if (!dienst) throw new Error('useDialog benötigt einen DialogProvider');
return dienst;
}
+10 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import Modal from './Modal';
import { useDialog } from './DialogProvider';
import Notice from './Notice';
import { ApiError, api } from '../lib/api';
import type { Character, CharacterLink } from '../lib/types';
@@ -21,6 +22,8 @@ export default function LinksModal({
onClose: () => void;
onChanged: () => void;
}) {
const { bestaetigen } = useDialog();
const [links, setLinks] = useState<CharacterLink[]>(character.links ?? []);
const [label, setLabel] = useState('');
const [url, setUrl] = useState('');
@@ -62,7 +65,13 @@ export default function LinksModal({
};
const entfernen = async (link: CharacterLink) => {
if (!window.confirm(`Verweis "${link.label}" entfernen?`)) return;
const ja = await bestaetigen({
titel: 'Verweis entfernen',
text: `Soll der Verweis "${link.label}" entfernt werden?`,
knopf: 'Entfernen',
gefahr: true,
});
if (!ja) return;
try {
await api(`/api/characters/links/${link.id}`, { method: 'DELETE' });
setLinks((alt) => alt.filter((l) => l.id !== link.id));
+29 -10
View File
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { useDialog } from './DialogProvider';
import { ApiError, api } from '../lib/api';
import { REASON_LABEL, rueckkehrArt } from '../lib/format';
import type { Character, CharacterMember } from '../lib/types';
@@ -20,6 +21,7 @@ export default function MemberList({
onChanged: () => void;
onError: (message: string) => void;
}) {
const { bestaetigen, abfragen, waehlen } = useDialog();
const alle = character.members ?? [];
const aktiv = alle.filter((m) => !m.leftAt);
// Wer ausgefallen ist, kann zurückkehren - wer gegangen ist, nicht.
@@ -29,7 +31,13 @@ export default function MemberList({
if (aktiv.length <= 1 && ruhend.length === 0 && ehemalig.length === 0) return null;
const austragen = async (playerId: number, name: string) => {
if (!window.confirm(`"${name}" aus "${character.name}" austragen?`)) return;
const ja = await bestaetigen({
titel: `"${name}" austragen`,
text: `Soll "${name}" den CC "${character.name}" nicht mehr nutzen? Verbrauchtes Guthaben gibt es dabei nicht zurück - bis hierher hat er den CC genutzt.`,
knopf: 'Austragen',
gefahr: true,
});
if (!ja) return;
try {
await api(`/api/characters/${character.id}/members/${playerId}`, { method: 'DELETE' });
onChanged();
@@ -44,19 +52,30 @@ export default function MemberList({
let reason: string | undefined;
if (art === 'admin') {
const eingabe = window.prompt(
`"${name}" ist im Rollenspiel gestorben. Womit kehrt er zurück?`,
);
if (!eingabe?.trim()) return;
reason = eingabe.trim();
const eingabe = await abfragen({
titel: `"${name}" zurückholen`,
text: 'Im Rollenspiel gestorben - die Rückkehr entscheidet ein Administrator und will erklärt sein.',
label: 'Womit kehrt er zurück?',
platzhalter: 'z. B. Klon aus der Lagerhalle',
knopf: 'Zurückholen',
});
if (!eingabe) return;
reason = eingabe;
}
let useKind: string | undefined;
if (art === 'bezahlt') {
const kulanz = window.confirm(
`Die Rückkehr von "${name}" kostet ein Guthaben.\n\nOK = Kulanz-Redesign, Abbrechen = bezahltes Redesign.`,
);
useKind = kulanz ? 'REDESIGN_KULANZ' : 'REDESIGN_PAID';
const gewaehlt = await waehlen({
titel: `"${name}" zurückholen`,
text: 'Diese Rückkehr kostet ein Guthaben. Woraus soll sie bezahlt werden?',
optionen: [
{ wert: 'REDESIGN_KULANZ', label: 'Kulanz-Redesign', hinweis: 'vom Team gewährt' },
{ wert: 'REDESIGN_PAID', label: 'Bezahltes Redesign', hinweis: 'aus einem Kauf' },
],
knopf: 'Zurückholen',
});
if (!gewaehlt) return;
useKind = gewaehlt;
}
try {
+11 -7
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import Notice from './Notice';
import { useDialog } from './DialogProvider';
import { ApiError, api } from '../lib/api';
import { formatDate } from '../lib/format';
import type { PendingRow } from '../lib/types';
@@ -31,6 +32,8 @@ export default function PendingRowsBox({
onError: (message: string) => void;
onFlash: (message: string) => void;
}) {
const { bestaetigen } = useDialog();
const [busy, setBusy] = useState(false);
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set());
@@ -50,13 +53,14 @@ export default function PendingRowsBox({
const ausfuehren = async (pfad: 'book' | 'dismiss') => {
const anzahl = betroffen.length;
if (
pfad === 'dismiss' &&
!window.confirm(
`${anzahl} ${anzahl === 1 ? 'Paket' : 'Pakete'} ohne Buchung aus der Vorschau nehmen?`,
)
) {
return;
if (pfad === 'dismiss') {
const ja = await bestaetigen({
titel: anzahl === 1 ? 'Paket weglegen' : `${anzahl} Pakete weglegen`,
text: 'Sie verschwinden aus der Vorschau, ohne gebucht zu werden - am Konto ändert sich nichts.',
knopf: 'Weglegen',
gefahr: true,
});
if (!ja) return;
}
setBusy(true);
+4 -1
View File
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { DialogProvider } from './components/DialogProvider';
import { SessionProvider } from './lib/session';
import './styles.css';
@@ -12,7 +13,9 @@ createRoot(container).render(
<StrictMode>
<BrowserRouter>
<SessionProvider>
<App />
<DialogProvider>
<App />
</DialogProvider>
</SessionProvider>
</BrowserRouter>
</StrictMode>,
+9 -1
View File
@@ -1,11 +1,13 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Notice from '../components/Notice';
import { useDialog } from '../components/DialogProvider';
import { ApiError, api } from '../lib/api';
import { useSession } from '../lib/session';
export default function Account() {
const { user, refresh } = useSession();
const { abfragen } = useDialog();
const navigate = useNavigate();
const [current, setCurrent] = useState('');
@@ -77,7 +79,13 @@ export default function Account() {
};
const disableTotp = async () => {
const password = window.prompt('Zum Deaktivieren bitte das Passwort eingeben:');
const password = await abfragen({
titel: 'Zwei-Faktor-Anmeldung abschalten',
text: 'Danach genügt das Passwort allein für die Anmeldung.',
label: 'Zur Bestätigung dein Passwort',
geheim: true,
knopf: 'Abschalten',
});
if (!password) return;
setError(null);
try {
+27 -8
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import Modal from '../components/Modal';
import Notice from '../components/Notice';
import { useDialog } from '../components/DialogProvider';
import { ApiError, api } from '../lib/api';
import { formatDate } from '../lib/format';
import { useSession } from '../lib/session';
@@ -87,6 +88,7 @@ interface TabProps {
function UsersTab({ onError, onDone }: TabProps) {
const { user: me } = useSession();
const { bestaetigen, abfragen } = useDialog();
const [users, setUsers] = useState<AdminUser[]>([]);
const [creating, setCreating] = useState(false);
@@ -113,10 +115,14 @@ function UsersTab({ onError, onDone }: TabProps) {
}
};
const resetPassword = (target: AdminUser) => {
const password = window.prompt(
`Neues Passwort für "${target.username}" (min. 12 Zeichen, Groß/Klein, Ziffer):`,
);
const resetPassword = async (target: AdminUser) => {
const password = await abfragen({
titel: `Passwort für "${target.username}" setzen`,
text: 'Mindestens 12 Zeichen, Gross- und Kleinbuchstaben, eine Ziffer. Er muss es bei der nächsten Anmeldung ändern, und alle seine offenen Sitzungen enden.',
label: 'Neues Passwort',
geheim: true,
knopf: 'Setzen',
});
if (!password) return;
void act(
() =>
@@ -203,7 +209,7 @@ function UsersTab({ onError, onDone }: TabProps) {
<button
type="button"
className="btn btn-tiny btn-ghost"
onClick={() => resetPassword(row)}
onClick={() => void resetPassword(row)}
>
Passwort
</button>
@@ -231,8 +237,14 @@ function UsersTab({ onError, onDone }: TabProps) {
<button
type="button"
className="btn btn-tiny btn-danger"
onClick={() => {
if (!window.confirm(`Konto "${row.username}" wirklich löschen?`)) return;
onClick={async () => {
const ja = await bestaetigen({
titel: `Konto "${row.username}" löschen`,
text: 'Der Zugang verschwindet endgültig. Was er eingetragen hat, bleibt im Protokoll stehen.',
knopf: 'Löschen',
gefahr: true,
});
if (!ja) return;
void act(
() => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }),
`"${row.username}" gelöscht.`,
@@ -341,6 +353,7 @@ function CreateUserModal({
}
function MappingsTab({ onError, onDone }: TabProps) {
const { bestaetigen } = useDialog();
const [items, setItems] = useState<ProductMapping[]>([]);
const [pattern, setPattern] = useState('');
const [target, setTarget] = useState<ProductTarget>('CC_PACKAGE');
@@ -374,7 +387,13 @@ function MappingsTab({ onError, onDone }: TabProps) {
};
const remove = async (row: ProductMapping) => {
if (!window.confirm(`Mapping "${row.matchPattern}" löschen?`)) return;
const ja = await bestaetigen({
titel: 'Mapping löschen',
text: `Produkte mit "${row.matchPattern}" im Namen werden danach nicht mehr automatisch zugeordnet.`,
knopf: 'Löschen',
gefahr: true,
});
if (!ja) return;
try {
await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' });
await load();
+8 -7
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import Notice from '../components/Notice';
import { useDialog } from '../components/DialogProvider';
import { ApiError, api, uploadFile } from '../lib/api';
import { PLAN_LABEL, formatDate } from '../lib/format';
import { useSession } from '../lib/session';
@@ -482,17 +483,17 @@ function BackupBox({
onError: (message: string) => void;
onFlash: (message: string) => void;
}) {
const { bestaetigen } = useDialog();
const [busy, setBusy] = useState(false);
const [bericht, setBericht] = useState<BackupReport | null>(null);
const einspielen = async (file: File) => {
if (
!window.confirm(
`"${file.name}" einspielen?\n\nWas es hier schon gibt, bleibt unangetastet - ergänzt wird nur, was fehlt.`,
)
) {
return;
}
const ja = await bestaetigen({
titel: 'Sicherung einspielen',
text: `"${file.name}" wird gelesen. Was es hier schon gibt, bleibt unangetastet - ergänzt wird nur, was fehlt.`,
knopf: 'Einspielen',
});
if (!ja) return;
setBusy(true);
setBericht(null);
+74 -10
View File
@@ -4,6 +4,7 @@ import BalanceCards from '../components/BalanceCards';
import UnmatchedBox from '../components/UnmatchedBox';
import AliasBox from '../components/AliasBox';
import ChainView from '../components/ChainView';
import { useDialog } from '../components/DialogProvider';
import LinksModal from '../components/LinksModal';
import PendingRowsBox from '../components/PendingRowsBox';
import ThreadsBox from '../components/ThreadsBox';
@@ -58,6 +59,7 @@ export default function PlayerDetail() {
const { id } = useParams<{ id: string }>();
const playerId = Number(id);
const { can, gextensionBaseUrl } = useSession();
const { bestaetigen } = useDialog();
const navigate = useNavigate();
const [detail, setDetail] = useState<Detail | null>(null);
@@ -109,13 +111,12 @@ export default function PlayerDetail() {
* eine Buchung blieb, wo sie beim Anlegen entstand.
*/
const buchungenNachrechnen = async () => {
if (
!window.confirm(
'Buchungen nach den Ketten ausrichten?\n\nJede Kette beginnt mit einem gekauften CC, alles Weitere sind Redesigns. Was nicht passt, wird umgebucht - über eine Gegenbuchung, nichts geht verloren.',
)
) {
return;
}
const ja = await bestaetigen({
titel: 'Buchungen nach den Ketten ausrichten',
text: 'Jede Kette beginnt mit einem gekauften CC, alles Weitere sind Redesigns. Was nicht passt, wird umgebucht - über eine Gegenbuchung, nichts geht verloren.',
knopf: 'Nachrechnen',
});
if (!ja) return;
setBusy(true);
setError(null);
@@ -137,6 +138,45 @@ export default function PlayerDetail() {
}
};
/**
* Verrechnet übrige Kulanz gegen ein Minus bei den bezahlten Redesigns.
*
* Kommt vor, wenn nachträglich klar wird, dass ein Teil der Redesigns
* Kulanz war. Statt einzeln Übergang für Übergang umzustellen, wird so
* viel umgebucht, wie beide Seiten hergeben.
*/
const mitKulanzAusgleichen = async () => {
if (!detail) return;
const moeglich = Math.min(
-detail.balances.REDESIGN_PAID,
detail.balances.REDESIGN_KULANZ,
);
const ja = await bestaetigen({
titel: 'Minus mit Kulanz ausgleichen',
text: `${moeglich} ${moeglich === 1 ? 'Übergang wird' : 'Übergänge werden'} von "bezahlt" auf "Kulanz" umgebucht - die ältesten zuerst. Unterm Strich ändert sich nichts, nur die Verteilung auf die Töpfe.`,
knopf: 'Ausgleichen',
});
if (!ja) return;
setBusy(true);
setError(null);
try {
const ergebnis = await api<{ umgebucht: number }>(
`/api/players/${playerId}/credits/settle`,
{ method: 'POST', body: {} },
);
await load();
melden(
`${ergebnis.umgebucht} ${ergebnis.umgebucht === 1 ? 'Übergang' : 'Übergänge'} auf Kulanz umgebucht.`,
);
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Ausgleichen fehlgeschlagen');
} finally {
setBusy(false);
}
};
/**
* Nimmt die zuletzt von Hand eingetragene Zugabe zurück.
*
@@ -145,7 +185,13 @@ export default function PlayerDetail() {
* einer Bestellung und werden dort storniert, wo der Zusammenhang steht.
*/
const zugabeZuruecknehmen = async (pfad: string, was: string) => {
if (!window.confirm(`Zuletzt eingetragenes ${was} zurücknehmen?`)) return;
const ja = await bestaetigen({
titel: `${was} zurücknehmen`,
text: `Die zuletzt von Hand eingetragene Zugabe dieser Art wird gegengebucht. In der Historie bleibt beides stehen; aus einem Import gebuchte Pakete sind davon nicht betroffen.`,
knopf: 'Zurücknehmen',
gefahr: true,
});
if (!ja) return;
await run(
() =>
api(`/api/players/${playerId}/credits/${pfad}/remove`, { method: 'POST', body: {} }),
@@ -390,6 +436,19 @@ export default function PlayerDetail() {
>
Buchungen nachrechnen
</button>
{/* Nur anbieten, wenn es etwas zu verrechnen gibt: ein Minus im
bezahlten Topf und Kulanz, die danebenliegt. */}
{detail.balances.REDESIGN_PAID < 0 && detail.balances.REDESIGN_KULANZ > 0 && (
<button
type="button"
className="btn"
disabled={busy}
title="Übrige Kulanz gegen das Minus bei den bezahlten Redesigns verrechnen"
onClick={() => void mitKulanzAusgleichen()}
>
Minus mit Kulanz ausgleichen
</button>
)}
{can('EDITOR') && detail.player.steamId && (
<button
type="button"
@@ -543,7 +602,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_KULANZ', 'REDESIGN_PAID']}
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
balances={detail.balances}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
@@ -608,7 +667,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_KULANZ', 'REDESIGN_PAID']}
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
balances={detail.balances}
onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) =>
@@ -1007,6 +1066,11 @@ function CharacterFormModal({
{' '}
({balances[kind]} verfügbar)
</span>
{/* Ein Redesign aus dem CC-Topf ist möglich, aber die
Ausnahme - es kostet einen ganzen Platz. */}
{kind === 'CC' && withKind.length > 1 && (
<span className="muted small"> · Ausnahme: verbraucht einen Platz</span>
)}
</span>
</label>
))}