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
+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>
))}