diff --git a/README.md b/README.md index be8f115..e2a896c 100644 --- a/README.md +++ b/README.md @@ -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. Ü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 nach dieser Regel aus. Angefasst wird nur, was nicht passt; umgebucht wird über eine 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 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 sich anders als *0 von 0*. Das eine heißt aufgebraucht, das andere nie etwas gehabt. diff --git a/server/package.json b/server/package.json index 838371c..b3dc829 100644 --- a/server/package.json +++ b/server/package.json @@ -12,7 +12,7 @@ "migrate": "prisma migrate deploy", "migrate:dev": "prisma migrate dev", "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" }, "dependencies": { diff --git a/server/prisma/migrations/20250114000000_bewusste_bezahlart/migration.sql b/server/prisma/migrations/20250114000000_bewusste_bezahlart/migration.sql new file mode 100644 index 0000000..7c1e523 --- /dev/null +++ b/server/prisma/migrations/20250114000000_bewusste_bezahlart/migration.sql @@ -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; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index e997ef2..d9af245 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -227,6 +227,11 @@ model CreditLedger { /// hier eingetippt hat. 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, kind]) @@index([groupId]) diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index 5271b6d..3e3f251 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -27,6 +27,7 @@ import { changeFunding, addMemberToChain, rebuildFunding, + settleWithKulanz, } from '../services/characters.js'; import { parseThreadId } from '../lib/forum.js'; import { linkThreadToCharacter } from '../services/forum.js'; @@ -49,7 +50,13 @@ async function linkThread( 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]); export async function characterRoutes(app: FastifyInstance): Promise { @@ -208,6 +215,15 @@ export async function characterRoutes(app: FastifyInstance): Promise { 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 --- /** @@ -294,7 +310,13 @@ export async function characterRoutes(app: FastifyInstance): Promise { z.object({ name: z.string().trim().min(1).max(160), 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, }), request.body, diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index c9d8b22..78f66b4 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -162,7 +162,7 @@ export async function redesignCharacter( input: { name: string; loreText?: string | undefined; - useKind: RedesignKind; + useKind: JoinKind; note?: string | undefined; }, ctx: ActionContext, @@ -172,9 +172,13 @@ export async function redesignCharacter( if (old.status !== CharacterStatus.ACTIVE) { throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden'); } - if (!REDESIGN_KINDS.includes(input.useKind)) { - throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); + // Der Regelfall ist ein Redesign-Guthaben. Ausnahmsweise lässt sich auch + // 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 predecessor = await tx.character.update({ @@ -221,6 +225,7 @@ export async function redesignCharacter( characterId: created.id, note: input.note ?? `Redesign: ${old.name} -> ${created.name}`, createdById: ctx.actorId, + ...(ausnahme ? { fundingPinned: true } : {}), }, }); @@ -809,6 +814,8 @@ export async function changeFunding( createdById: ctx.actorId, // Bezahlt wurde damals, nicht heute. 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; + // 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; 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. * @@ -1560,7 +1667,7 @@ export async function redesignMembership( input: { name: string; loreText?: string | undefined; - useKind: RedesignKind; + useKind: JoinKind; note?: string | undefined; }, ctx: ActionContext, @@ -1570,8 +1677,8 @@ export async function redesignMembership( if (gruppe.status !== CharacterStatus.ACTIVE) { throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden'); } - if (!REDESIGN_KINDS.includes(input.useKind)) { - throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben'); + if (!JOIN_KINDS.includes(input.useKind)) { + throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für ein Redesign'); } const name = input.name.trim(); 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, note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`, createdById: ctx.actorId, + ...(input.useKind === CreditKind.CC ? { fundingPinned: true } : {}), }, }); diff --git a/server/test/ketten.test.ts b/server/test/ketten.test.ts index f8145de..ab6fb15 100644 --- a/server/test/ketten.test.ts +++ b/server/test/ketten.test.ts @@ -447,7 +447,7 @@ describe('Redesign nachträglich umbuchen', () => { ); 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); 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 () => { const spieler = await spielerMitPaketen('Hin und her', 1); 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( eins.character.id, { 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 () => { const spieler = await spielerMitPaketen('Kulanzfall', 2); 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( eins.character.id, { 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 () => { const spieler = await spielerMitPaketen('Kulanz bleibt', 1); 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( eins.character.id, { name: 'Aus Kulanz', useKind: 'REDESIGN_KULANZ' }, @@ -967,3 +967,136 @@ describe('Die Rechnung geht auf', () => { 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'); + }); +}); diff --git a/server/tsconfig.test.json b/server/tsconfig.test.json new file mode 100644 index 0000000..dfc3842 --- /dev/null +++ b/server/tsconfig.test.json @@ -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"] +} diff --git a/web/src/components/AliasBox.tsx b/web/src/components/AliasBox.tsx index d94795f..ccec6ed 100644 --- a/web/src/components/AliasBox.tsx +++ b/web/src/components/AliasBox.tsx @@ -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('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(); diff --git a/web/src/components/ChainView.tsx b/web/src/components/ChainView.tsx index ae88fde..f4a8b4b 100644 --- a/web/src/components/ChainView.tsx +++ b/web/src/components/ChainView.tsx @@ -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(null); const [ueber, setUeber] = useState(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: {} }); diff --git a/web/src/components/DialogProvider.tsx b/web/src/components/DialogProvider.tsx new file mode 100644 index 0000000..87d9b40 --- /dev/null +++ b/web/src/components/DialogProvider.tsx @@ -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 { + 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; + abfragen: (optionen: Abfrage) => Promise; + /** + * 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: (optionen: Wahl) => Promise; +} + +const DialogContext = createContext(null); + +type Offen = + | { art: 'bestaetigung'; optionen: Bestaetigung } + | { art: 'abfrage'; optionen: Abfrage } + | { art: 'wahl'; optionen: Wahl } + | null; + +export function DialogProvider({ children }: { children: ReactNode }) { + const [offen, setOffen] = useState(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((resolve) => { + antwort.current = (wert) => resolve(wert === true); + }); + }, []); + + const abfragen = useCallback((optionen: Abfrage) => { + setOffen({ art: 'abfrage', optionen }); + setEingabe(optionen.vorgabe ?? ''); + return new Promise((resolve) => { + antwort.current = (wert) => resolve(typeof wert === 'string' ? wert : null); + }); + }, []); + + const waehlen = useCallback((optionen: Wahl) => { + setOffen({ art: 'wahl', optionen: optionen as Wahl }); + setEingabe(optionen.vorgabe ?? optionen.optionen[0]?.wert ?? ''); + return new Promise((resolve) => { + antwort.current = (wert) => resolve(typeof wert === 'string' ? (wert as T) : null); + }); + }, []); + + const dienst = useMemo( + () => ({ bestaetigen, abfragen, waehlen }), + [bestaetigen, abfragen, waehlen], + ); + + return ( + + {children} + + {offen?.art === 'bestaetigung' && ( + schliessen(false)}> + {offen.optionen.text} + +
+ + +
+
+ )} + + {offen?.art === 'wahl' && ( + schliessen(null)}> + {offen.optionen.text && {offen.optionen.text}} + +
+ {offen.optionen.optionen.map((option) => ( + + ))} +
+ +
+ + +
+
+ )} + + {offen?.art === 'abfrage' && ( + schliessen(null)}> +
{ + event.preventDefault(); + if (eingabe.trim()) schliessen(eingabe.trim()); + }} + > + {offen.optionen.text && {offen.optionen.text}} + + + setEingabe(event.target.value)} + autoFocus + required + /> + +
+ + +
+
+
+ )} +
+ ); +} + +export function useDialog(): DialogDienst { + const dienst = useContext(DialogContext); + if (!dienst) throw new Error('useDialog benötigt einen DialogProvider'); + return dienst; +} diff --git a/web/src/components/LinksModal.tsx b/web/src/components/LinksModal.tsx index 09f1573..9693823 100644 --- a/web/src/components/LinksModal.tsx +++ b/web/src/components/LinksModal.tsx @@ -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(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)); diff --git a/web/src/components/MemberList.tsx b/web/src/components/MemberList.tsx index a197ed2..0ac69bf 100644 --- a/web/src/components/MemberList.tsx +++ b/web/src/components/MemberList.tsx @@ -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 { diff --git a/web/src/components/PendingRowsBox.tsx b/web/src/components/PendingRowsBox.tsx index 57800b4..94dd9d0 100644 --- a/web/src/components/PendingRowsBox.tsx +++ b/web/src/components/PendingRowsBox.tsx @@ -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>(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); diff --git a/web/src/main.tsx b/web/src/main.tsx index c24edd8..6a61c74 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( - + + + , diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx index 876048d..2e5465c 100644 --- a/web/src/pages/Account.tsx +++ b/web/src/pages/Account.tsx @@ -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 { diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx index 4d9c9b6..0e702ca 100644 --- a/web/src/pages/Admin.tsx +++ b/web/src/pages/Admin.tsx @@ -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([]); 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) { @@ -231,8 +237,14 @@ function UsersTab({ onError, onDone }: TabProps) { + {/* 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 && ( + + )} {can('EDITOR') && detail.player.steamId && (