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:
@@ -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
@@ -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;
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: {} });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<DialogProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</DialogProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user