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
+15 -1
View File
@@ -90,7 +90,16 @@ woanders steht. Ein Platz entsteht dabei nicht aus dem Nichts: Wird ein CC dadur
muss er gedeckt sein wie jeder andere. muss er gedeckt sein wie jeder andere.
Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen - Über *Bearbeiten* lassen sich Name, Beschreibung und der verknüpfte Forums-Antrag nachtragen -
die Thread-Nummer genügt, die volle Adresse geht auch. Stimmt ein Bestand nicht mehr - etwa weil er aus einer Zeit stammt, in der eine Buchung blieb, die Thread-Nummer genügt, die volle Adresse geht auch. Es gibt zwei Ausnahmen von dieser Regel, beide bewusst zu treffen:
- Ein **Redesign aus dem CC-Topf**. Möglich, aber nicht der Regelfall - es verbraucht einen
ganzen Platz statt eines Redesigns. Im Dialog steht das dabei.
- Eine **von Hand geänderte Bezahlart** über den Knopf *Bezahlart*.
Beide werden als bewusste Entscheidung vermerkt, und das Ausrichten an den Ketten lässt sie
stehen. Sonst würde jede Ausnahme beim nächsten Umsortieren wieder eingeebnet.
Stimmt ein Bestand nicht mehr - etwa weil er aus einer Zeit stammt, in der eine Buchung blieb,
wo sie beim Anlegen entstand -, richtet **Buchungen nachrechnen** auf der Spielerseite alles wo sie beim Anlegen entstand -, richtet **Buchungen nachrechnen** auf der Spielerseite alles
nach dieser Regel aus. Angefasst wird nur, was nicht passt; umgebucht wird über eine nach dieser Regel aus. Angefasst wird nur, was nicht passt; umgebucht wird über eine
Gegenbuchung, und zwischen bezahltem Redesign und Kulanz wird nichts gewechselt. Gegenbuchung, und zwischen bezahltem Redesign und Kulanz wird nichts gewechselt.
@@ -137,6 +146,11 @@ CC-Paket gehen beide Zeilen mit zurück, das CC-Guthaben und das darin enthalten
**Aus einem Import gebuchte Pakete rührt das nicht an** - die gehören zu einer Bestellung und **Aus einem Import gebuchte Pakete rührt das nicht an** - die gehören zu einer Bestellung und
werden dort storniert, wo dieser Zusammenhang sichtbar ist. werden dort storniert, wo dieser Zusammenhang sichtbar ist.
Steht der **bezahlte Redesign-Topf im Minus, während Kulanz übrig ist**, erscheint *Minus mit
Kulanz ausgleichen*. Es bucht so viele Übergänge von „bezahlt" auf „Kulanz" um, wie beide Seiten
hergeben - die ältesten zuerst. Unterm Strich ändert sich nichts, nur die Verteilung auf die
Töpfe. Das ist derselbe Vorgang wie ein Dutzend Mal *Bezahlart* zu drücken, nur in einem Zug.
In den Kästen oben steht neben dem offenen Stand, wie viel jemals hereinkam: *0 von 3* liest In den Kästen oben steht neben dem offenen Stand, wie viel jemals hereinkam: *0 von 3* liest
sich anders als *0 von 0*. Das eine heißt aufgebraucht, das andere nie etwas gehabt. sich anders als *0 von 0*. Das eine heißt aufgebraucht, das andere nie etwas gehabt.
+1 -1
View File
@@ -12,7 +12,7 @@
"migrate": "prisma migrate deploy", "migrate": "prisma migrate deploy",
"migrate:dev": "prisma migrate dev", "migrate:dev": "prisma migrate dev",
"studio": "prisma studio", "studio": "prisma studio",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
"test": "node --import tsx --test test/*.test.ts" "test": "node --import tsx --test test/*.test.ts"
}, },
"dependencies": { "dependencies": {
@@ -0,0 +1,4 @@
-- Eine von Hand gewaehlte Bezahlart soll bleiben. Ohne diese Markierung
-- wuerde das Ausrichten der Buchungen an den Ketten jede Ausnahme beim
-- naechsten Umsortieren wieder einebnen.
ALTER TABLE "CreditLedger" ADD COLUMN IF NOT EXISTS "fundingPinned" BOOLEAN NOT NULL DEFAULT false;
+5
View File
@@ -227,6 +227,11 @@ model CreditLedger {
/// hier eingetippt hat. /// hier eingetippt hat.
occurredAt DateTime @default(now()) occurredAt DateTime @default(now())
/// Von Hand so entschieden. Das Ausrichten der Buchungen an den Ketten
/// lässt solche Zeilen in Ruhe - sonst würde jede Ausnahme beim nächsten
/// Umsortieren wieder eingeebnet.
fundingPinned Boolean @default(false)
@@index([playerId]) @@index([playerId])
@@index([playerId, kind]) @@index([playerId, kind])
@@index([groupId]) @@index([groupId])
+24 -2
View File
@@ -27,6 +27,7 @@ import {
changeFunding, changeFunding,
addMemberToChain, addMemberToChain,
rebuildFunding, rebuildFunding,
settleWithKulanz,
} from '../services/characters.js'; } from '../services/characters.js';
import { parseThreadId } from '../lib/forum.js'; import { parseThreadId } from '../lib/forum.js';
import { linkThreadToCharacter } from '../services/forum.js'; import { linkThreadToCharacter } from '../services/forum.js';
@@ -49,7 +50,13 @@ async function linkThread(
await linkThreadToCharacter(threadId, characterId, classification, ctx); await linkThreadToCharacter(threadId, characterId, classification, ctx);
} }
const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]); /// Der Regelfall für ein Redesign sind die Redesign-Töpfe. Ein CC-Paket
/// geht ausnahmsweise auch - dann gilt die Wahl als von Hand getroffen.
const redesignKind = z.enum([
CreditKind.REDESIGN_PAID,
CreditKind.REDESIGN_KULANZ,
CreditKind.CC,
]);
const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]); const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]);
export async function characterRoutes(app: FastifyInstance): Promise<void> { export async function characterRoutes(app: FastifyInstance): Promise<void> {
@@ -208,6 +215,15 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
return rebuildFunding(id, contextOf(request)); return rebuildFunding(id, contextOf(request));
}); });
/**
* Gleicht ein Minus im bezahlten Redesign-Topf mit vorhandener Kulanz aus,
* indem ebenso viele Übergänge auf Kulanz umgebucht werden.
*/
app.post('/api/players/:id/credits/settle', { preHandler: requireEditor }, async (request) => {
const { id } = parseWith(idParam, request.params);
return settleWithKulanz(id, contextOf(request));
});
// --- Ketten --- // --- Ketten ---
/** /**
@@ -294,7 +310,13 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
z.object({ z.object({
name: z.string().trim().min(1).max(160), name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(), loreText: z.string().trim().max(5000).optional(),
useKind: z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]), /// Der Regelfall sind die Redesign-Töpfe; ein CC-Paket geht
/// ausnahmsweise auch.
useKind: z.enum([
CreditKind.REDESIGN_PAID,
CreditKind.REDESIGN_KULANZ,
CreditKind.CC,
]),
note: optionalNote, note: optionalNote,
}), }),
request.body, request.body,
+114 -6
View File
@@ -162,7 +162,7 @@ export async function redesignCharacter(
input: { input: {
name: string; name: string;
loreText?: string | undefined; loreText?: string | undefined;
useKind: RedesignKind; useKind: JoinKind;
note?: string | undefined; note?: string | undefined;
}, },
ctx: ActionContext, ctx: ActionContext,
@@ -172,9 +172,13 @@ export async function redesignCharacter(
if (old.status !== CharacterStatus.ACTIVE) { if (old.status !== CharacterStatus.ACTIVE) {
throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden'); throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden');
} }
if (!REDESIGN_KINDS.includes(input.useKind)) { // Der Regelfall ist ein Redesign-Guthaben. Ausnahmsweise lässt sich auch
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); // ein CC-Paket dafür einsetzen - dann gilt die Entscheidung als von Hand
// getroffen und bleibt beim Umsortieren stehen.
if (!JOIN_KINDS.includes(input.useKind)) {
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für ein Redesign');
} }
const ausnahme = input.useKind === CreditKind.CC;
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
const predecessor = await tx.character.update({ const predecessor = await tx.character.update({
@@ -221,6 +225,7 @@ export async function redesignCharacter(
characterId: created.id, characterId: created.id,
note: input.note ?? `Redesign: ${old.name} -> ${created.name}`, note: input.note ?? `Redesign: ${old.name} -> ${created.name}`,
createdById: ctx.actorId, createdById: ctx.actorId,
...(ausnahme ? { fundingPinned: true } : {}),
}, },
}); });
@@ -809,6 +814,8 @@ export async function changeFunding(
createdById: ctx.actorId, createdById: ctx.actorId,
// Bezahlt wurde damals, nicht heute. // Bezahlt wurde damals, nicht heute.
occurredAt: buchung.occurredAt, occurredAt: buchung.occurredAt,
// Von Hand gewählt - das Ausrichten an den Ketten lässt es stehen.
fundingPinned: true,
}, },
}); });
@@ -879,6 +886,10 @@ async function buchungFolgtPosition(
}); });
if (!offen) return false; if (!offen) return false;
// Wer die Bezahlart von Hand gewählt hat, meinte es so - etwa wenn ein
// Redesign ausnahmsweise aus einem CC-Paket bezahlt wurde.
if (offen.fundingPinned) return false;
const istCC = offen.kind === CreditKind.CC; const istCC = offen.kind === CreditKind.CC;
if (istCC === sollCC) return false; if (istCC === sollCC) return false;
@@ -1053,6 +1064,102 @@ export async function rebuildFunding(
}; };
} }
/**
* Begleicht ein Minus im Redesign-Topf mit vorhandener Kulanz.
*
* Kommt vor, wenn nachträglich klar wird, dass ein Teil der Redesigns
* Kulanz war: Dann stehen dort Guthaben herum, während der bezahlte Topf im
* Minus hängt. Statt einzeln Übergang für Übergang umzustellen, wird hier so
* viel umgebucht, wie beide Seiten hergeben.
*
* Welche Übergänge es trifft, spielt inhaltlich keine Rolle - am Ende zählt,
* wie viel woraus bezahlt wurde. Genommen werden die ältesten zuerst, damit
* das Ergebnis wiederholbar ist und nicht vom Zufall abhängt.
*/
export async function settleWithKulanz(
playerId: number,
ctx: ActionContext,
): Promise<{ umgebucht: number; balances: Balances }> {
const stand = await getBalances(playerId);
const schulden = Math.max(0, -stand.REDESIGN_PAID);
const kulanz = Math.max(0, stand.REDESIGN_KULANZ);
const moeglich = Math.min(schulden, kulanz);
if (moeglich === 0) {
throw conflict(
'NOTHING_TO_SETTLE',
schulden === 0
? 'Im bezahlten Redesign-Topf steht kein Minus - hier gibt es nichts auszugleichen'
: 'Es ist keine Kulanz übrig, mit der sich das Minus ausgleichen liesse',
);
}
// Die ältesten bezahlten Redesigns zuerst: wer nachträglich Kulanz
// zugestanden bekommt, bekommt sie für das, was am längsten zurückliegt.
const kandidaten = await prisma.creditLedger.findMany({
where: {
playerId,
kind: CreditKind.REDESIGN_PAID,
amount: { lt: 0 },
source: CreditSource.CONSUME,
reversedBy: null,
},
orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
take: moeglich,
include: { character: { select: { id: true, name: true } } },
});
if (kandidaten.length === 0) {
throw conflict(
'NOTHING_TO_SETTLE',
'Es gibt keine bezahlten Redesigns, die sich auf Kulanz umstellen liessen',
);
}
await prisma.$transaction(async (tx) => {
for (const buchung of kandidaten) {
await tx.creditLedger.create({
data: {
playerId,
kind: buchung.kind,
amount: -buchung.amount,
source: CreditSource.REVERSAL,
groupId: buchung.groupId,
characterId: buchung.characterId,
reversalOfId: buchung.id,
note: `Auf Kulanz umgestellt - Storno: ${buchung.character?.name ?? ''}`.trim(),
createdById: ctx.actorId,
occurredAt: buchung.occurredAt,
},
});
await tx.creditLedger.create({
data: {
playerId,
kind: CreditKind.REDESIGN_KULANZ,
amount: buchung.amount,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: buchung.characterId,
note: buchung.note ?? buchung.character?.name ?? null,
createdById: ctx.actorId,
occurredAt: buchung.occurredAt,
},
});
}
await recordAudit(tx, ctx, {
action: 'credit.settled_with_kulanz',
targetType: 'player',
targetId: playerId,
details: { umgebucht: kandidaten.length },
});
});
return { umgebucht: kandidaten.length, balances: await getBalances(playerId) };
}
/** /**
* Verschiebt einen CC innerhalb seiner Kette oder in eine andere. * Verschiebt einen CC innerhalb seiner Kette oder in eine andere.
* *
@@ -1560,7 +1667,7 @@ export async function redesignMembership(
input: { input: {
name: string; name: string;
loreText?: string | undefined; loreText?: string | undefined;
useKind: RedesignKind; useKind: JoinKind;
note?: string | undefined; note?: string | undefined;
}, },
ctx: ActionContext, ctx: ActionContext,
@@ -1570,8 +1677,8 @@ export async function redesignMembership(
if (gruppe.status !== CharacterStatus.ACTIVE) { if (gruppe.status !== CharacterStatus.ACTIVE) {
throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden'); throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden');
} }
if (!REDESIGN_KINDS.includes(input.useKind)) { if (!JOIN_KINDS.includes(input.useKind)) {
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für ein Redesign');
} }
const name = input.name.trim(); const name = input.name.trim();
if (!name) throw badRequest('NO_NAME', 'Für den neuen CC wird ein Name gebraucht'); if (!name) throw badRequest('NO_NAME', 'Für den neuen CC wird ein Name gebraucht');
@@ -1630,6 +1737,7 @@ export async function redesignMembership(
characterId: erstellt.id, characterId: erstellt.id,
note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`, note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`,
createdById: ctx.actorId, createdById: ctx.actorId,
...(input.useKind === CreditKind.CC ? { fundingPinned: true } : {}),
}, },
}); });
+137 -4
View File
@@ -447,7 +447,7 @@ describe('Redesign nachträglich umbuchen', () => {
); );
assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht'); assert.equal(zwei.balances.REDESIGN_PAID, 0, 'das bezahlte ist verbraucht');
await credits.bookKulanz(spieler, 1, 'Absprache aufgetaucht', ctx); await credits.bookKulanz(spieler, ctx, { note: 'Absprache aufgetaucht' });
const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx); const danach = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx);
assert.equal(danach.balances.REDESIGN_PAID, 1, 'das bezahlte ist wieder frei'); assert.equal(danach.balances.REDESIGN_PAID, 1, 'das bezahlte ist wieder frei');
@@ -466,7 +466,7 @@ describe('Redesign nachträglich umbuchen', () => {
it('und wieder zurück', async () => { it('und wieder zurück', async () => {
const spieler = await spielerMitPaketen('Hin und her', 1); const spieler = await spielerMitPaketen('Hin und her', 1);
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx); const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
await credits.bookKulanz(spieler, 1, 'Erstmal Kulanz', ctx); await credits.bookKulanz(spieler, ctx, { note: 'Erstmal Kulanz' });
const zwei = await characters.redesignCharacter( const zwei = await characters.redesignCharacter(
eins.character.id, eins.character.id,
{ name: 'B', useKind: 'REDESIGN_KULANZ' }, { name: 'B', useKind: 'REDESIGN_KULANZ' },
@@ -681,7 +681,7 @@ describe('Die Stelle in der Kette bestimmt, was ein Eintrag kostet', () => {
it('lässt eine Kulanz-Entscheidung unangetastet', async () => { it('lässt eine Kulanz-Entscheidung unangetastet', async () => {
const spieler = await spielerMitPaketen('Kulanzfall', 2); const spieler = await spielerMitPaketen('Kulanzfall', 2);
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx); const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
await credits.bookKulanz(spieler, 1, 'Absprache', ctx); await credits.bookKulanz(spieler, ctx, { note: 'Absprache' });
const zwei = await characters.redesignCharacter( const zwei = await characters.redesignCharacter(
eins.character.id, eins.character.id,
{ name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' }, { name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' },
@@ -951,7 +951,7 @@ describe('Die Rechnung geht auf', () => {
it('lässt eine Kulanz-Entscheidung auch beim Nachrechnen stehen', async () => { it('lässt eine Kulanz-Entscheidung auch beim Nachrechnen stehen', async () => {
const spieler = await spielerMitPaketen('Kulanz bleibt', 1); const spieler = await spielerMitPaketen('Kulanz bleibt', 1);
const eins = await characters.createCharacter(spieler, { name: 'Start' }, ctx); const eins = await characters.createCharacter(spieler, { name: 'Start' }, ctx);
await credits.bookKulanz(spieler, 1, 'Absprache', ctx); await credits.bookKulanz(spieler, ctx, { note: 'Absprache' });
const zwei = await characters.redesignCharacter( const zwei = await characters.redesignCharacter(
eins.character.id, eins.character.id,
{ name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' }, { name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' },
@@ -967,3 +967,136 @@ describe('Die Rechnung geht auf', () => {
assert.equal(buchung?.kind, 'REDESIGN_KULANZ'); assert.equal(buchung?.kind, 'REDESIGN_KULANZ');
}); });
}); });
describe('Kulanz gegen ein Minus verrechnen', () => {
/** Ein Spieler mit Redesigns im Minus und Kulanz auf der Seite. */
async function schieflage() {
const spieler = await spielerMitPaketen('Schieflage', 1);
const erster = await characters.createCharacter(spieler, { name: 'Anfang' }, ctx);
// Vier Redesigns, aber nur eines bezahlt - der Topf geht ins Minus.
let letzter = erster.character.id;
for (let i = 2; i <= 5; i += 1) {
const naechster = await characters.redesignCharacter(
letzter,
{ name: `Stufe ${i}`, useKind: 'REDESIGN_PAID' },
ctx,
);
letzter = naechster.character.id;
}
const stand = await credits.getBalances(spieler);
assert.equal(stand.REDESIGN_PAID, -3, 'vier verbraucht, eines aus dem Paket');
return spieler;
}
it('gleicht so viel aus, wie beide Seiten hergeben', async () => {
const spieler = await schieflage();
for (let i = 0; i < 2; i += 1) {
await credits.bookKulanz(spieler, ctx, { note: 'Nachträglich zugestanden' });
}
const ergebnis = await characters.settleWithKulanz(spieler, ctx);
assert.equal(ergebnis.umgebucht, 2, 'zwei Kulanzen, zwei Übergänge');
assert.equal(ergebnis.balances.REDESIGN_PAID, -1, 'das Minus schrumpft um zwei');
assert.equal(ergebnis.balances.REDESIGN_KULANZ, 0, 'die Kulanz ist aufgebraucht');
assert.equal(
ergebnis.balances.redesignTotal,
-1,
'unterm Strich bleibt dasselbe - nur anders verteilt',
);
});
it('nimmt die ältesten Übergänge zuerst', async () => {
const spieler = await schieflage();
await credits.bookKulanz(spieler, ctx, { note: 'Eine Geste' });
await characters.settleWithKulanz(spieler, ctx);
const kulanzZeile = await db.prisma.creditLedger.findFirstOrThrow({
where: { playerId: spieler, kind: 'REDESIGN_KULANZ', amount: { lt: 0 } },
include: { character: { select: { name: true } } },
});
assert.equal(
kulanzZeile.character?.name,
'Stufe 2',
'wer nachträglich Kulanz bekommt, bekommt sie für das Älteste',
);
});
it('meldet sich, wenn es nichts auszugleichen gibt', async () => {
const spieler = await spielerMitPaketen('Im Plus', 1);
await credits.bookKulanz(spieler, ctx, { note: 'Übrig' });
await assert.rejects(
() => characters.settleWithKulanz(spieler, ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_SETTLE',
);
});
it('meldet sich, wenn keine Kulanz da ist', async () => {
const spieler = await schieflage();
await assert.rejects(
() => characters.settleWithKulanz(spieler, ctx),
(error: Error & { code?: string }) => error.code === 'NOTHING_TO_SETTLE',
);
});
});
describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => {
it('nimmt ein CC-Guthaben an', async () => {
const spieler = await spielerMitPaketen('Ausnahmefall', 2);
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
const vorher = await credits.getBalances(spieler);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Aus dem CC-Topf', useKind: 'CC' },
ctx,
);
assert.equal(zwei.balances.CC, vorher.CC - 1, 'bezahlt aus dem CC-Topf');
assert.equal(zwei.balances.REDESIGN_PAID, vorher.REDESIGN_PAID, 'die Redesigns bleiben');
});
it('lässt sich vom Ausrichten an den Ketten nicht überschreiben', async () => {
// Ohne Merker würde die Automatik beim nächsten Umsortieren daraus
// wieder ein gewöhnliches Redesign machen.
const spieler = await spielerMitPaketen('Bleibt so', 2);
const eins = await characters.createCharacter(spieler, { name: 'Erstes' }, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'Zweites', useKind: 'CC' },
ctx,
);
await characters.rebuildFunding(spieler, ctx);
const buchung = await db.prisma.creditLedger.findFirst({
where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null },
orderBy: { id: 'desc' },
});
assert.equal(buchung?.kind, 'CC', 'die bewusste Entscheidung bleibt stehen');
});
it('merkt sich auch eine von Hand geänderte Bezahlart', async () => {
const spieler = await spielerMitPaketen('Von Hand', 2);
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
const zwei = await characters.redesignCharacter(
eins.character.id,
{ name: 'B', useKind: 'REDESIGN_PAID' },
ctx,
);
await characters.changeFunding(zwei.character.id, 'CC', ctx);
await characters.rebuildFunding(spieler, ctx);
const buchung = await db.prisma.creditLedger.findFirst({
where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null },
orderBy: { id: 'desc' },
});
assert.equal(buchung?.kind, 'CC');
});
});
+11
View File
@@ -0,0 +1,11 @@
{
// Tests werden nicht gebaut, aber geprüft: Ein Aufruf mit vertauschten
// Argumenten läuft zur Laufzeit oft einfach durch und prüft dann etwas
// anderes als gedacht - ohne dass es auffällt.
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+10 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import Notice from './Notice'; import Notice from './Notice';
import { useDialog } from './DialogProvider';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
import type { AliasType, PlayerAlias } from '../lib/types'; import type { AliasType, PlayerAlias } from '../lib/types';
@@ -18,6 +19,8 @@ export default function AliasBox({
canEdit: boolean; canEdit: boolean;
onChange: () => void; onChange: () => void;
}) { }) {
const { bestaetigen } = useDialog();
const [type, setType] = useState<AliasType>('STEAM_ID'); const [type, setType] = useState<AliasType>('STEAM_ID');
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [note, setNote] = useState(''); const [note, setNote] = useState('');
@@ -48,7 +51,13 @@ export default function AliasBox({
}; };
const entfernen = async (alias: PlayerAlias) => { 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 { try {
await api(`/api/players/aliases/${alias.id}`, { method: 'DELETE' }); await api(`/api/players/aliases/${alias.id}`, { method: 'DELETE' });
onChange(); onChange();
+9 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import MemberList from './MemberList'; import MemberList from './MemberList';
import { useDialog } from './DialogProvider';
import Notice from './Notice'; import Notice from './Notice';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
import { REASON_LABEL, formatDate, rueckkehrArt } from '../lib/format'; import { REASON_LABEL, formatDate, rueckkehrArt } from '../lib/format';
@@ -131,6 +132,8 @@ export default function ChainView({
onMoved: () => void; onMoved: () => void;
onError: (message: string) => void; onError: (message: string) => void;
}) { }) {
const { bestaetigen } = useDialog();
const [ziehe, setZiehe] = useState<Character | null>(null); const [ziehe, setZiehe] = useState<Character | null>(null);
const [ueber, setUeber] = useState<string | null>(null); const [ueber, setUeber] = useState<string | null>(null);
@@ -208,7 +211,12 @@ export default function ChainView({
}; };
const herausloesen = async (character: Character) => { 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); setMeldung(null);
try { try {
await api(`/api/characters/${character.id}/detach`, { method: 'POST', body: {} }); 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 { useState } from 'react';
import Modal from './Modal'; import Modal from './Modal';
import { useDialog } from './DialogProvider';
import Notice from './Notice'; import Notice from './Notice';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
import type { Character, CharacterLink } from '../lib/types'; import type { Character, CharacterLink } from '../lib/types';
@@ -21,6 +22,8 @@ export default function LinksModal({
onClose: () => void; onClose: () => void;
onChanged: () => void; onChanged: () => void;
}) { }) {
const { bestaetigen } = useDialog();
const [links, setLinks] = useState<CharacterLink[]>(character.links ?? []); const [links, setLinks] = useState<CharacterLink[]>(character.links ?? []);
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
@@ -62,7 +65,13 @@ export default function LinksModal({
}; };
const entfernen = async (link: CharacterLink) => { 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 { try {
await api(`/api/characters/links/${link.id}`, { method: 'DELETE' }); await api(`/api/characters/links/${link.id}`, { method: 'DELETE' });
setLinks((alt) => alt.filter((l) => l.id !== link.id)); setLinks((alt) => alt.filter((l) => l.id !== link.id));
+29 -10
View File
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useDialog } from './DialogProvider';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
import { REASON_LABEL, rueckkehrArt } from '../lib/format'; import { REASON_LABEL, rueckkehrArt } from '../lib/format';
import type { Character, CharacterMember } from '../lib/types'; import type { Character, CharacterMember } from '../lib/types';
@@ -20,6 +21,7 @@ export default function MemberList({
onChanged: () => void; onChanged: () => void;
onError: (message: string) => void; onError: (message: string) => void;
}) { }) {
const { bestaetigen, abfragen, waehlen } = useDialog();
const alle = character.members ?? []; const alle = character.members ?? [];
const aktiv = alle.filter((m) => !m.leftAt); const aktiv = alle.filter((m) => !m.leftAt);
// Wer ausgefallen ist, kann zurückkehren - wer gegangen ist, nicht. // 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; if (aktiv.length <= 1 && ruhend.length === 0 && ehemalig.length === 0) return null;
const austragen = async (playerId: number, name: string) => { 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 { try {
await api(`/api/characters/${character.id}/members/${playerId}`, { method: 'DELETE' }); await api(`/api/characters/${character.id}/members/${playerId}`, { method: 'DELETE' });
onChanged(); onChanged();
@@ -44,19 +52,30 @@ export default function MemberList({
let reason: string | undefined; let reason: string | undefined;
if (art === 'admin') { if (art === 'admin') {
const eingabe = window.prompt( const eingabe = await abfragen({
`"${name}" ist im Rollenspiel gestorben. Womit kehrt er zurück?`, titel: `"${name}" zurückholen`,
); text: 'Im Rollenspiel gestorben - die Rückkehr entscheidet ein Administrator und will erklärt sein.',
if (!eingabe?.trim()) return; label: 'Womit kehrt er zurück?',
reason = eingabe.trim(); platzhalter: 'z. B. Klon aus der Lagerhalle',
knopf: 'Zurückholen',
});
if (!eingabe) return;
reason = eingabe;
} }
let useKind: string | undefined; let useKind: string | undefined;
if (art === 'bezahlt') { if (art === 'bezahlt') {
const kulanz = window.confirm( const gewaehlt = await waehlen({
`Die Rückkehr von "${name}" kostet ein Guthaben.\n\nOK = Kulanz-Redesign, Abbrechen = bezahltes Redesign.`, titel: `"${name}" zurückholen`,
); text: 'Diese Rückkehr kostet ein Guthaben. Woraus soll sie bezahlt werden?',
useKind = kulanz ? 'REDESIGN_KULANZ' : 'REDESIGN_PAID'; 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 { try {
+11 -7
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import Notice from './Notice'; import Notice from './Notice';
import { useDialog } from './DialogProvider';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
import { formatDate } from '../lib/format'; import { formatDate } from '../lib/format';
import type { PendingRow } from '../lib/types'; import type { PendingRow } from '../lib/types';
@@ -31,6 +32,8 @@ export default function PendingRowsBox({
onError: (message: string) => void; onError: (message: string) => void;
onFlash: (message: string) => void; onFlash: (message: string) => void;
}) { }) {
const { bestaetigen } = useDialog();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set()); const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set());
@@ -50,13 +53,14 @@ export default function PendingRowsBox({
const ausfuehren = async (pfad: 'book' | 'dismiss') => { const ausfuehren = async (pfad: 'book' | 'dismiss') => {
const anzahl = betroffen.length; const anzahl = betroffen.length;
if ( if (pfad === 'dismiss') {
pfad === 'dismiss' && const ja = await bestaetigen({
!window.confirm( titel: anzahl === 1 ? 'Paket weglegen' : `${anzahl} Pakete weglegen`,
`${anzahl} ${anzahl === 1 ? 'Paket' : 'Pakete'} ohne Buchung aus der Vorschau nehmen?`, text: 'Sie verschwinden aus der Vorschau, ohne gebucht zu werden - am Konto ändert sich nichts.',
) knopf: 'Weglegen',
) { gefahr: true,
return; });
if (!ja) return;
} }
setBusy(true); setBusy(true);
+4 -1
View File
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import App from './App'; import App from './App';
import { DialogProvider } from './components/DialogProvider';
import { SessionProvider } from './lib/session'; import { SessionProvider } from './lib/session';
import './styles.css'; import './styles.css';
@@ -12,7 +13,9 @@ createRoot(container).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<SessionProvider> <SessionProvider>
<App /> <DialogProvider>
<App />
</DialogProvider>
</SessionProvider> </SessionProvider>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
+9 -1
View File
@@ -1,11 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Notice from '../components/Notice'; import Notice from '../components/Notice';
import { useDialog } from '../components/DialogProvider';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
import { useSession } from '../lib/session'; import { useSession } from '../lib/session';
export default function Account() { export default function Account() {
const { user, refresh } = useSession(); const { user, refresh } = useSession();
const { abfragen } = useDialog();
const navigate = useNavigate(); const navigate = useNavigate();
const [current, setCurrent] = useState(''); const [current, setCurrent] = useState('');
@@ -77,7 +79,13 @@ export default function Account() {
}; };
const disableTotp = async () => { 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; if (!password) return;
setError(null); setError(null);
try { try {
+27 -8
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import Modal from '../components/Modal'; import Modal from '../components/Modal';
import Notice from '../components/Notice'; import Notice from '../components/Notice';
import { useDialog } from '../components/DialogProvider';
import { ApiError, api } from '../lib/api'; import { ApiError, api } from '../lib/api';
import { formatDate } from '../lib/format'; import { formatDate } from '../lib/format';
import { useSession } from '../lib/session'; import { useSession } from '../lib/session';
@@ -87,6 +88,7 @@ interface TabProps {
function UsersTab({ onError, onDone }: TabProps) { function UsersTab({ onError, onDone }: TabProps) {
const { user: me } = useSession(); const { user: me } = useSession();
const { bestaetigen, abfragen } = useDialog();
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@@ -113,10 +115,14 @@ function UsersTab({ onError, onDone }: TabProps) {
} }
}; };
const resetPassword = (target: AdminUser) => { const resetPassword = async (target: AdminUser) => {
const password = window.prompt( const password = await abfragen({
`Neues Passwort für "${target.username}" (min. 12 Zeichen, Groß/Klein, Ziffer):`, 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; if (!password) return;
void act( void act(
() => () =>
@@ -203,7 +209,7 @@ function UsersTab({ onError, onDone }: TabProps) {
<button <button
type="button" type="button"
className="btn btn-tiny btn-ghost" className="btn btn-tiny btn-ghost"
onClick={() => resetPassword(row)} onClick={() => void resetPassword(row)}
> >
Passwort Passwort
</button> </button>
@@ -231,8 +237,14 @@ function UsersTab({ onError, onDone }: TabProps) {
<button <button
type="button" type="button"
className="btn btn-tiny btn-danger" className="btn btn-tiny btn-danger"
onClick={() => { onClick={async () => {
if (!window.confirm(`Konto "${row.username}" wirklich löschen?`)) return; 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( void act(
() => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }), () => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }),
`"${row.username}" gelöscht.`, `"${row.username}" gelöscht.`,
@@ -341,6 +353,7 @@ function CreateUserModal({
} }
function MappingsTab({ onError, onDone }: TabProps) { function MappingsTab({ onError, onDone }: TabProps) {
const { bestaetigen } = useDialog();
const [items, setItems] = useState<ProductMapping[]>([]); const [items, setItems] = useState<ProductMapping[]>([]);
const [pattern, setPattern] = useState(''); const [pattern, setPattern] = useState('');
const [target, setTarget] = useState<ProductTarget>('CC_PACKAGE'); const [target, setTarget] = useState<ProductTarget>('CC_PACKAGE');
@@ -374,7 +387,13 @@ function MappingsTab({ onError, onDone }: TabProps) {
}; };
const remove = async (row: ProductMapping) => { 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 { try {
await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' }); await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' });
await load(); await load();
+8 -7
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import Notice from '../components/Notice'; import Notice from '../components/Notice';
import { useDialog } from '../components/DialogProvider';
import { ApiError, api, uploadFile } from '../lib/api'; import { ApiError, api, uploadFile } from '../lib/api';
import { PLAN_LABEL, formatDate } from '../lib/format'; import { PLAN_LABEL, formatDate } from '../lib/format';
import { useSession } from '../lib/session'; import { useSession } from '../lib/session';
@@ -482,17 +483,17 @@ function BackupBox({
onError: (message: string) => void; onError: (message: string) => void;
onFlash: (message: string) => void; onFlash: (message: string) => void;
}) { }) {
const { bestaetigen } = useDialog();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [bericht, setBericht] = useState<BackupReport | null>(null); const [bericht, setBericht] = useState<BackupReport | null>(null);
const einspielen = async (file: File) => { const einspielen = async (file: File) => {
if ( const ja = await bestaetigen({
!window.confirm( titel: 'Sicherung einspielen',
`"${file.name}" einspielen?\n\nWas es hier schon gibt, bleibt unangetastet - ergänzt wird nur, was fehlt.`, text: `"${file.name}" wird gelesen. Was es hier schon gibt, bleibt unangetastet - ergänzt wird nur, was fehlt.`,
) knopf: 'Einspielen',
) { });
return; if (!ja) return;
}
setBusy(true); setBusy(true);
setBericht(null); setBericht(null);
+74 -10
View File
@@ -4,6 +4,7 @@ import BalanceCards from '../components/BalanceCards';
import UnmatchedBox from '../components/UnmatchedBox'; import UnmatchedBox from '../components/UnmatchedBox';
import AliasBox from '../components/AliasBox'; import AliasBox from '../components/AliasBox';
import ChainView from '../components/ChainView'; import ChainView from '../components/ChainView';
import { useDialog } from '../components/DialogProvider';
import LinksModal from '../components/LinksModal'; import LinksModal from '../components/LinksModal';
import PendingRowsBox from '../components/PendingRowsBox'; import PendingRowsBox from '../components/PendingRowsBox';
import ThreadsBox from '../components/ThreadsBox'; import ThreadsBox from '../components/ThreadsBox';
@@ -58,6 +59,7 @@ export default function PlayerDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const playerId = Number(id); const playerId = Number(id);
const { can, gextensionBaseUrl } = useSession(); const { can, gextensionBaseUrl } = useSession();
const { bestaetigen } = useDialog();
const navigate = useNavigate(); const navigate = useNavigate();
const [detail, setDetail] = useState<Detail | null>(null); const [detail, setDetail] = useState<Detail | null>(null);
@@ -109,13 +111,12 @@ export default function PlayerDetail() {
* eine Buchung blieb, wo sie beim Anlegen entstand. * eine Buchung blieb, wo sie beim Anlegen entstand.
*/ */
const buchungenNachrechnen = async () => { const buchungenNachrechnen = async () => {
if ( const ja = await bestaetigen({
!window.confirm( titel: 'Buchungen nach den Ketten ausrichten',
'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.', 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',
) { });
return; if (!ja) return;
}
setBusy(true); setBusy(true);
setError(null); 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. * 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. * einer Bestellung und werden dort storniert, wo der Zusammenhang steht.
*/ */
const zugabeZuruecknehmen = async (pfad: string, was: string) => { 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( await run(
() => () =>
api(`/api/players/${playerId}/credits/${pfad}/remove`, { method: 'POST', body: {} }), api(`/api/players/${playerId}/credits/${pfad}/remove`, { method: 'POST', body: {} }),
@@ -390,6 +436,19 @@ export default function PlayerDetail() {
> >
Buchungen nachrechnen Buchungen nachrechnen
</button> </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 && ( {can('EDITOR') && detail.player.steamId && (
<button <button
type="button" type="button"
@@ -543,7 +602,7 @@ export default function PlayerDetail() {
submitLabel="Redesign durchführen" submitLabel="Redesign durchführen"
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`} hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
busy={busy} busy={busy}
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID']} withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
balances={detail.balances} balances={detail.balances}
onClose={() => setDialog({ type: 'none' })} onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) => onSubmit={(values) =>
@@ -608,7 +667,7 @@ export default function PlayerDetail() {
submitLabel="Redesign durchführen" 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.`} 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} busy={busy}
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID']} withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
balances={detail.balances} balances={detail.balances}
onClose={() => setDialog({ type: 'none' })} onClose={() => setDialog({ type: 'none' })}
onSubmit={(values) => onSubmit={(values) =>
@@ -1007,6 +1066,11 @@ function CharacterFormModal({
{' '} {' '}
({balances[kind]} verfügbar) ({balances[kind]} verfügbar)
</span> </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> </span>
</label> </label>
))} ))}