CC-Guthaben wird vorher umgewandelt, nicht am CC eingesetzt
Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein Guthaben dafuer einsetzen will, tauscht es oben um und setzt es danach ein wie jedes andere Redesign. Damit faellt die Wahl CC-Guthaben an den CC-Aktionen weg; was ein Eintrag kostet, entscheidet seine Stelle in der Liste. Die Bezahlart gilt jetzt je Nutzer: bei einem Gruppen-CC hat jeder seine eigene Buchung, und umstellen liess sich bisher nur die des Erstellers. Die Guthaben-Historie laesst sich nach Topf filtern.
This commit is contained in:
@@ -200,10 +200,13 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const body = parseWith(
|
const body = parseWith(
|
||||||
z.object({
|
z.object({
|
||||||
useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
|
useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
|
||||||
|
/// Wessen Buchung gemeint ist - bei einem Gruppen-CC hat jeder
|
||||||
|
/// Nutzer seine eigene.
|
||||||
|
forPlayerId: z.number().int().positive().optional(),
|
||||||
}),
|
}),
|
||||||
request.body,
|
request.body,
|
||||||
);
|
);
|
||||||
return changeFunding(id, body.useKind, contextOf(request));
|
return changeFunding(id, body.useKind, contextOf(request), body.forPlayerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
bookAdjustment,
|
bookAdjustment,
|
||||||
bookKulanz,
|
bookKulanz,
|
||||||
bookRedesignPackage,
|
bookRedesignPackage,
|
||||||
|
convertCcToRedesign,
|
||||||
grantCcSlot,
|
grantCcSlot,
|
||||||
removeLastGrant,
|
removeLastGrant,
|
||||||
reverseGroup,
|
reverseGroup,
|
||||||
@@ -44,6 +45,27 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return bookKulanz(id, contextOf(request), { note: body.note });
|
return bookKulanz(id, contextOf(request), { note: body.note });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt ein CC-Guthaben in ein Redesign um.
|
||||||
|
*
|
||||||
|
* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein Guthaben dafür
|
||||||
|
* einsetzen will, tauscht es hier und setzt es danach wie jedes andere
|
||||||
|
* Redesign ein.
|
||||||
|
*/
|
||||||
|
app.post('/api/players/:id/credits/convert', { preHandler: requireEditor }, async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return convertCcToRedesign(id, contextOf(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/players/:id/credits/convert/remove',
|
||||||
|
{ preHandler: requireEditor },
|
||||||
|
async (request) => {
|
||||||
|
const { id } = parseWith(idParam, request.params);
|
||||||
|
return removeLastGrant(id, CreditSource.CONVERSION, contextOf(request));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nimmt die zuletzt von Hand eingetragene Zugabe zurück.
|
* Nimmt die zuletzt von Hand eingetragene Zugabe zurück.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
import { badRequest, conflict, notFound } from '../errors.js';
|
import { badRequest, conflict, notFound } from '../errors.js';
|
||||||
import { getBalances, wandleCcInRedesign, type Balances } from './credits.js';
|
import { getBalances, type Balances } from './credits.js';
|
||||||
import { recordAudit, type ActionContext } from './audit.js';
|
import { recordAudit, type ActionContext } from './audit.js';
|
||||||
|
|
||||||
export interface CharacterResult {
|
export interface CharacterResult {
|
||||||
@@ -397,7 +397,7 @@ export async function reactivatePaid(
|
|||||||
playerId: updated.playerId,
|
playerId: updated.playerId,
|
||||||
characterId: updated.id,
|
characterId: updated.id,
|
||||||
kind: useKind,
|
kind: useKind,
|
||||||
redesign: updated.predecessorId !== null,
|
redesign: null,
|
||||||
note: note ?? `Reaktivierung: ${updated.name}`,
|
note: note ?? `Reaktivierung: ${updated.name}`,
|
||||||
actorId: ctx.actorId,
|
actorId: ctx.actorId,
|
||||||
});
|
});
|
||||||
@@ -745,7 +745,7 @@ export async function reviveDeadCharacter(
|
|||||||
playerId: updated.playerId,
|
playerId: updated.playerId,
|
||||||
characterId: updated.id,
|
characterId: updated.id,
|
||||||
kind: input.useKind,
|
kind: input.useKind,
|
||||||
redesign: updated.predecessorId !== null,
|
redesign: null,
|
||||||
note: `Rückkehr nach Tod: ${input.reason}`,
|
note: `Rückkehr nach Tod: ${input.reason}`,
|
||||||
actorId: ctx.actorId,
|
actorId: ctx.actorId,
|
||||||
});
|
});
|
||||||
@@ -777,13 +777,9 @@ export async function reviveDeadCharacter(
|
|||||||
/**
|
/**
|
||||||
* Stellt um, woraus ein Eintrag bezahlt wurde.
|
* Stellt um, woraus ein Eintrag bezahlt wurde.
|
||||||
*
|
*
|
||||||
* Zwei Dinge lassen sich hier entscheiden: ob ein Redesign aus dem bezahlten
|
* Zu entscheiden bleibt eines: ob ein Redesign aus dem bezahlten Topf kommt
|
||||||
* Topf oder aus Kulanz kommt - und ob dafür ausnahmsweise ein CC-Guthaben
|
* oder aus Kulanz. Ob ein Eintrag überhaupt ein Redesign ist, steht nicht
|
||||||
* eingesetzt wird. Das Letzte ist keine eigene Bezahlart, sondern eine
|
* zur Wahl - das entscheidet seine Stelle in der Liste.
|
||||||
* Umwandlung: ein Platz weniger, ein Redesign mehr.
|
|
||||||
*
|
|
||||||
* Ob ein Eintrag überhaupt ein Redesign ist, steht nicht zur Wahl - das
|
|
||||||
* entscheidet seine Stelle in der Kette.
|
|
||||||
*
|
*
|
||||||
* Geändert wird wie jede Korrektur im Kontobuch: die alte Zeile wird
|
* Geändert wird wie jede Korrektur im Kontobuch: die alte Zeile wird
|
||||||
* gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas
|
* gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas
|
||||||
@@ -793,19 +789,21 @@ export async function changeFunding(
|
|||||||
characterId: number,
|
characterId: number,
|
||||||
useKind: JoinKind,
|
useKind: JoinKind,
|
||||||
ctx: ActionContext,
|
ctx: ActionContext,
|
||||||
|
forPlayerId?: number,
|
||||||
): Promise<CharacterResult> {
|
): Promise<CharacterResult> {
|
||||||
const character = await loadCharacter(characterId);
|
const character = await loadCharacter(characterId);
|
||||||
|
const spieler = forPlayerId ?? character.playerId;
|
||||||
|
|
||||||
if (!JOIN_KINDS.includes(useKind)) {
|
if (!JOIN_KINDS.includes(useKind)) {
|
||||||
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art');
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die Buchung, mit der dieser CC bezahlt wurde. Stornierte zählen nicht
|
// Die Buchung, mit der dieser Spieler seinen Platz bezahlt hat. Bei einem
|
||||||
// mehr; offen ist immer höchstens eine.
|
// Gruppen-CC hat jeder Nutzer seine eigene; stornierte zählen nicht mehr.
|
||||||
const buchung = await prisma.creditLedger.findFirst({
|
const buchung = await prisma.creditLedger.findFirst({
|
||||||
where: {
|
where: {
|
||||||
characterId,
|
characterId,
|
||||||
playerId: character.playerId,
|
playerId: spieler,
|
||||||
source: CreditSource.CONSUME,
|
source: CreditSource.CONSUME,
|
||||||
reversedBy: null,
|
reversedBy: null,
|
||||||
},
|
},
|
||||||
@@ -814,64 +812,42 @@ export async function changeFunding(
|
|||||||
if (!buchung) {
|
if (!buchung) {
|
||||||
throw conflict(
|
throw conflict(
|
||||||
'NO_BOOKING',
|
'NO_BOOKING',
|
||||||
'Für diesen CC ist nichts gebucht - hier gibt es nichts umzustellen',
|
'Für diesen Spieler ist an diesem CC nichts gebucht - hier gibt es nichts umzustellen. ' +
|
||||||
|
'Sein Platz hängt weiter unten in der Liste.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redesign = await istRedesignPosition(prisma, characterId, buchung.playerId);
|
const redesign = await istRedesignPosition(prisma, characterId, spieler);
|
||||||
if (!redesign && useKind !== CreditKind.CC) {
|
if (!redesign && useKind !== CreditKind.CC) {
|
||||||
throw conflict(
|
throw conflict(
|
||||||
'CHAIN_START',
|
'CHAIN_START',
|
||||||
'Dieser Eintrag steht am Anfang einer Kette und bucht einen eigenen Platz. ' +
|
'Dieser Eintrag steht am Anfang einer Liste und bucht einen eigenen Platz. ' +
|
||||||
'Erst über einem anderen Eintrag wird daraus ein Redesign.',
|
'Erst über einem anderen Eintrag wird daraus ein Redesign.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (redesign && useKind === CreditKind.CC) {
|
||||||
// Ein CC-Guthaben für ein Redesign wird umgewandelt, nicht anders gebucht.
|
throw conflict(
|
||||||
const willUmwandeln = redesign && useKind === CreditKind.CC;
|
'CONVERT_FIRST',
|
||||||
const zielArt = willUmwandeln ? CreditKind.REDESIGN_PAID : useKind;
|
'Ein Redesign wird nicht aus dem CC-Topf bezahlt. Wandle das CC-Guthaben oben in ein ' +
|
||||||
const hatUmwandlung =
|
'Redesign um, dann steht es hier zur Verfügung.',
|
||||||
(await prisma.creditLedger.count({
|
);
|
||||||
where: {
|
}
|
||||||
characterId,
|
if (buchung.kind === useKind) {
|
||||||
playerId: buchung.playerId,
|
|
||||||
source: CreditSource.CONVERSION,
|
|
||||||
reversedBy: null,
|
|
||||||
},
|
|
||||||
})) > 0;
|
|
||||||
|
|
||||||
if (buchung.kind === zielArt && willUmwandeln === hatUmwandlung) {
|
|
||||||
throw conflict('SAME_KIND', 'Dieser CC ist bereits so gebucht');
|
throw conflict('SAME_KIND', 'Dieser CC ist bereits so gebucht');
|
||||||
}
|
}
|
||||||
|
|
||||||
const grund = willUmwandeln
|
const grund =
|
||||||
? 'CC-Guthaben umgewandelt'
|
useKind === CreditKind.REDESIGN_KULANZ
|
||||||
: useKind === CreditKind.CC
|
? 'Umgestellt auf Kulanz'
|
||||||
? 'Umgestellt auf CC-Paket'
|
: 'Umgestellt auf bezahltes Redesign';
|
||||||
: useKind === CreditKind.REDESIGN_KULANZ
|
|
||||||
? 'Umgestellt auf Kulanz'
|
|
||||||
: 'Umgestellt auf bezahltes Redesign';
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await storniere(tx, buchung, `${grund}: ${character.name}`, ctx.actorId);
|
await storniere(tx, buchung, `${grund}: ${character.name}`, ctx.actorId);
|
||||||
|
|
||||||
if (willUmwandeln && !hatUmwandlung) {
|
|
||||||
await wandleCcInRedesign(tx, {
|
|
||||||
playerId: buchung.playerId,
|
|
||||||
characterId,
|
|
||||||
actorId: ctx.actorId,
|
|
||||||
note: `CC-Guthaben als Redesign eingesetzt: ${character.name}`,
|
|
||||||
occurredAt: buchung.occurredAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!willUmwandeln && hatUmwandlung) {
|
|
||||||
await nimmUmwandlungZurueck(tx, characterId, buchung.playerId, ctx.actorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.creditLedger.create({
|
await tx.creditLedger.create({
|
||||||
data: {
|
data: {
|
||||||
playerId: buchung.playerId,
|
playerId: spieler,
|
||||||
kind: zielArt,
|
kind: useKind,
|
||||||
amount: buchung.amount,
|
amount: buchung.amount,
|
||||||
source: CreditSource.CONSUME,
|
source: CreditSource.CONSUME,
|
||||||
groupId: randomUUID(),
|
groupId: randomUUID(),
|
||||||
@@ -887,11 +863,11 @@ export async function changeFunding(
|
|||||||
action: 'character.funding_changed',
|
action: 'character.funding_changed',
|
||||||
targetType: 'character',
|
targetType: 'character',
|
||||||
targetId: characterId,
|
targetId: characterId,
|
||||||
details: { name: character.name, from: buchung.kind, to: zielArt, umgewandelt: willUmwandeln },
|
details: { name: character.name, playerId: spieler, from: buchung.kind, to: useKind },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const balances = await getBalances(character.playerId);
|
const balances = await getBalances(spieler);
|
||||||
return { character, balances, overdrawn: isOverdrawn(balances) };
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,11 +903,11 @@ async function istRedesignPosition(
|
|||||||
/**
|
/**
|
||||||
* Bucht, was ein Eintrag kostet.
|
* Bucht, was ein Eintrag kostet.
|
||||||
*
|
*
|
||||||
* Ein Platz am Anfang einer Kette kommt aus dem CC-Topf, alles darüber aus
|
* Am Anfang einer Liste steht ein gekaufter Platz, alles darüber ein
|
||||||
* einem Redesign-Topf. Wer sein CC-Guthaben für ein Redesign einsetzen will,
|
* Redesign. Die Stelle entscheidet, nicht die Wahl - sonst ginge die
|
||||||
* wandelt es hier um: ein Platz weniger, ein bezahltes Redesign mehr. Der
|
* Rechnung "eine Liste, ein CC-Paket" nicht mehr auf. Wer sein CC-Guthaben
|
||||||
* Eintrag selbst ist danach ein Redesign wie jedes andere - dadurch bleibt
|
* für ein Redesign einsetzen will, wandelt es vorher um; danach ist es ein
|
||||||
* die Rechnung überall dieselbe, ohne Sonderfall.
|
* Redesign wie jedes andere.
|
||||||
*/
|
*/
|
||||||
async function bucheVerbrauch(
|
async function bucheVerbrauch(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
@@ -939,30 +915,40 @@ async function bucheVerbrauch(
|
|||||||
playerId: number;
|
playerId: number;
|
||||||
characterId: number;
|
characterId: number;
|
||||||
kind: JoinKind;
|
kind: JoinKind;
|
||||||
/// Steht der Eintrag über einem Vorgänger? Dann ist es ein Redesign.
|
/**
|
||||||
redesign: boolean;
|
* Steht der Eintrag über einem Vorgänger? Dann ist es ein Redesign.
|
||||||
|
*
|
||||||
|
* `null` bei einer Buchung, die neben einer bestehenden steht - eine
|
||||||
|
* Rückkehr etwa kostet zusätzlich, und was sie kostet, entscheidet der
|
||||||
|
* Fall, nicht die Stelle in der Liste.
|
||||||
|
*/
|
||||||
|
redesign: boolean | null;
|
||||||
note: string;
|
note: string;
|
||||||
actorId: number | null;
|
actorId: number | null;
|
||||||
occurredAt?: Date | undefined;
|
occurredAt?: Date | undefined;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ausCcTopf = params.kind === CreditKind.CC;
|
const ausCcTopf = params.kind === CreditKind.CC;
|
||||||
const umwandeln = ausCcTopf && params.redesign;
|
|
||||||
|
|
||||||
if (umwandeln) {
|
if (params.redesign === true && ausCcTopf) {
|
||||||
await wandleCcInRedesign(tx, {
|
throw conflict(
|
||||||
playerId: params.playerId,
|
'CONVERT_FIRST',
|
||||||
characterId: params.characterId,
|
'Ein Redesign wird nicht aus dem CC-Topf bezahlt. Wandle das CC-Guthaben oben in ein ' +
|
||||||
actorId: params.actorId,
|
'Redesign um, dann steht es hier zur Verfügung.',
|
||||||
note: `CC-Guthaben als Redesign eingesetzt: ${params.note}`,
|
);
|
||||||
occurredAt: params.occurredAt ?? null,
|
}
|
||||||
});
|
if (params.redesign === false && !ausCcTopf) {
|
||||||
|
throw conflict(
|
||||||
|
'CHAIN_START',
|
||||||
|
'Dieser Eintrag steht am Anfang einer Liste und bucht einen eigenen Platz. Dafür wird ein ' +
|
||||||
|
'CC-Guthaben gebraucht, kein Redesign.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.creditLedger.create({
|
await tx.creditLedger.create({
|
||||||
data: {
|
data: {
|
||||||
playerId: params.playerId,
|
playerId: params.playerId,
|
||||||
kind: umwandeln ? CreditKind.REDESIGN_PAID : params.kind,
|
kind: params.kind,
|
||||||
amount: -1,
|
amount: -1,
|
||||||
source: CreditSource.CONSUME,
|
source: CreditSource.CONSUME,
|
||||||
groupId: randomUUID(),
|
groupId: randomUUID(),
|
||||||
@@ -1005,27 +991,6 @@ async function storniere(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Nimmt die Umwandlung zurück, aus der ein Eintrag bezahlt wurde.
|
|
||||||
*
|
|
||||||
* Wer ein CC-Guthaben in ein Redesign getauscht hat und den Eintrag später
|
|
||||||
* an den Anfang einer Kette stellt, bekommt den Tausch zurück: der Eintrag
|
|
||||||
* bucht dann wieder einen eigenen Platz.
|
|
||||||
*/
|
|
||||||
async function nimmUmwandlungZurueck(
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
characterId: number,
|
|
||||||
playerId: number,
|
|
||||||
actorId: number | null,
|
|
||||||
): Promise<void> {
|
|
||||||
const zeilen = await tx.creditLedger.findMany({
|
|
||||||
where: { characterId, playerId, source: CreditSource.CONVERSION, reversedBy: null },
|
|
||||||
});
|
|
||||||
for (const zeile of zeilen) {
|
|
||||||
await storniere(tx, zeile, 'Umwandlung zurückgenommen: bucht wieder einen Platz', actorId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bucht ein Kettenglied auf die Art um, die seine Stelle in der Kette
|
* Bucht ein Kettenglied auf die Art um, die seine Stelle in der Kette
|
||||||
* verlangt.
|
* verlangt.
|
||||||
@@ -1084,12 +1049,6 @@ async function buchungFolgtPosition(
|
|||||||
|
|
||||||
await storniere(tx, offen, `${grund} - Storno: ${glied.name}`, actorId);
|
await storniere(tx, offen, `${grund} - Storno: ${glied.name}`, actorId);
|
||||||
|
|
||||||
if (sollCC) {
|
|
||||||
// Wer sein CC-Guthaben einmal für dieses Redesign umgewandelt hat,
|
|
||||||
// bekommt den Tausch zurück - hier bucht der Eintrag wieder einen Platz.
|
|
||||||
await nimmUmwandlungZurueck(tx, glied.id, offen.playerId, actorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.creditLedger.create({
|
await tx.creditLedger.create({
|
||||||
data: {
|
data: {
|
||||||
playerId: offen.playerId,
|
playerId: offen.playerId,
|
||||||
@@ -2207,7 +2166,7 @@ export async function reactivateMember(
|
|||||||
playerId,
|
playerId,
|
||||||
characterId,
|
characterId,
|
||||||
kind: input.useKind,
|
kind: input.useKind,
|
||||||
redesign: mitglied.predecessorId !== null,
|
redesign: null,
|
||||||
note: input.reason ?? `Rückkehr zu "${gruppe.name}"`,
|
note: input.reason ?? `Rückkehr zu "${gruppe.name}"`,
|
||||||
actorId: ctx.actorId,
|
actorId: ctx.actorId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -192,44 +192,40 @@ export async function bookCcPackage(
|
|||||||
* Wandelt ein CC-Guthaben in ein bezahltes Redesign um.
|
* Wandelt ein CC-Guthaben in ein bezahltes Redesign um.
|
||||||
*
|
*
|
||||||
* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben dafür
|
* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben dafür
|
||||||
* einsetzen will, tauscht es vorher: ein Platz weniger, ein Redesign mehr.
|
* einsetzen will, tauscht es vorher um: ein Platz weniger, ein Redesign mehr.
|
||||||
* Danach ist der Eintrag ein Redesign wie jedes andere - keine Ausnahme, die
|
* Danach wird es eingesetzt wie jedes andere Redesign - dadurch bleibt die
|
||||||
* sich durch Slot-Prüfung, Kettenrechnung und Anzeige ziehen müsste.
|
* Rechnung überall dieselbe, ohne Sonderfall an einzelnen CCs.
|
||||||
*
|
|
||||||
* Läuft immer in der Transaktion des Vorgangs mit, der die Umwandlung
|
|
||||||
* auslöst: beides gilt zusammen oder gar nicht.
|
|
||||||
*/
|
*/
|
||||||
export async function wandleCcInRedesign(
|
export async function convertCcToRedesign(
|
||||||
tx: Prisma.TransactionClient,
|
playerId: number,
|
||||||
params: {
|
ctx: ActionContext,
|
||||||
playerId: number;
|
): Promise<{ balances: Balances }> {
|
||||||
/// Der Eintrag, für den das Guthaben eingesetzt wird. Daran hängt die
|
await requirePlayer(playerId);
|
||||||
/// Umwandlung, damit sie sich zurücknehmen lässt, wenn der Eintrag
|
|
||||||
/// später doch einen eigenen Platz bekommt.
|
|
||||||
characterId: number;
|
|
||||||
actorId: number | null;
|
|
||||||
note: string;
|
|
||||||
/// Wann der Vorgang war, zu dem die Umwandlung gehört.
|
|
||||||
occurredAt?: Date | null;
|
|
||||||
},
|
|
||||||
): Promise<void> {
|
|
||||||
const groupId = randomUUID();
|
const groupId = randomUUID();
|
||||||
const gemeinsam = {
|
const gemeinsam = {
|
||||||
playerId: params.playerId,
|
playerId,
|
||||||
characterId: params.characterId,
|
|
||||||
source: CreditSource.CONVERSION,
|
source: CreditSource.CONVERSION,
|
||||||
groupId,
|
groupId,
|
||||||
note: params.note,
|
note: 'CC-Guthaben in ein Redesign umgewandelt',
|
||||||
createdById: params.actorId,
|
createdById: ctx.actorId,
|
||||||
...(params.occurredAt ? { occurredAt: params.occurredAt } : {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await tx.creditLedger.create({
|
await prisma.$transaction(async (tx) => {
|
||||||
data: { ...gemeinsam, kind: CreditKind.CC, amount: -1 },
|
await tx.creditLedger.create({ data: { ...gemeinsam, kind: CreditKind.CC, amount: -1 } });
|
||||||
});
|
await tx.creditLedger.create({
|
||||||
await tx.creditLedger.create({
|
data: { ...gemeinsam, kind: CreditKind.REDESIGN_PAID, amount: 1 },
|
||||||
data: { ...gemeinsam, kind: CreditKind.REDESIGN_PAID, amount: 1 },
|
});
|
||||||
|
|
||||||
|
await recordAudit(tx, ctx, {
|
||||||
|
action: 'credit.converted',
|
||||||
|
targetType: 'player',
|
||||||
|
targetId: playerId,
|
||||||
|
details: { from: CreditKind.CC, to: CreditKind.REDESIGN_PAID },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { balances: await getBalances(playerId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -385,6 +381,7 @@ const HANDEINTRAEGE = [
|
|||||||
CreditSource.CC_PACKAGE,
|
CreditSource.CC_PACKAGE,
|
||||||
CreditSource.REDESIGN_PACKAGE,
|
CreditSource.REDESIGN_PACKAGE,
|
||||||
CreditSource.KULANZ,
|
CreditSource.KULANZ,
|
||||||
|
CreditSource.CONVERSION,
|
||||||
] as const;
|
] as const;
|
||||||
export type Handeintrag = (typeof HANDEINTRAEGE)[number];
|
export type Handeintrag = (typeof HANDEINTRAEGE)[number];
|
||||||
|
|
||||||
@@ -425,7 +422,9 @@ export async function removeLastGrant(
|
|||||||
? 'CC-Paket'
|
? 'CC-Paket'
|
||||||
: source === CreditSource.REDESIGN_PACKAGE
|
: source === CreditSource.REDESIGN_PACKAGE
|
||||||
? 'Redesign-Paket'
|
? 'Redesign-Paket'
|
||||||
: 'Kulanz';
|
: source === CreditSource.CONVERSION
|
||||||
|
? 'umgewandeltes CC-Guthaben'
|
||||||
|
: 'Kulanz';
|
||||||
throw conflict(
|
throw conflict(
|
||||||
'NOTHING_TO_REMOVE',
|
'NOTHING_TO_REMOVE',
|
||||||
`Es gibt kein von Hand eingetragenes ${was}, das sich zurücknehmen liesse. Aus einem Import gebuchte Pakete werden über die Historie storniert.`,
|
`Es gibt kein von Hand eingetragenes ${was}, das sich zurücknehmen liesse. Aus einem Import gebuchte Pakete werden über die Historie storniert.`,
|
||||||
|
|||||||
+65
-56
@@ -1067,72 +1067,81 @@ describe('Ein CC-Guthaben für ein Redesign einsetzen', () => {
|
|||||||
return buchung?.kind;
|
return buchung?.kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
it('wandelt es um, statt eine Ausnahme zu buchen', async () => {
|
it('geht nur über eine Umwandlung, nicht direkt am CC', async () => {
|
||||||
const spieler = await spielerMitPaketen('Ausnahmefall', 2);
|
const spieler = await spielerMitPaketen('Direktzahler', 2);
|
||||||
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
|
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
|
||||||
|
|
||||||
const vorher = await credits.getBalances(spieler);
|
await assert.rejects(
|
||||||
const zwei = await characters.redesignCharacter(
|
() =>
|
||||||
eins.character.id,
|
characters.redesignCharacter(eins.character.id, { name: 'Zweites', useKind: 'CC' }, ctx),
|
||||||
{ name: 'Aus dem CC-Topf', useKind: 'CC' },
|
(error: Error & { code?: string }) => error.code === 'CONVERT_FIRST',
|
||||||
ctx,
|
'ein Redesign wird nicht aus dem CC-Topf bezahlt',
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
assert.equal(zwei.balances.CC, vorher.CC - 1, 'der Platz ist weg');
|
it('tauscht einen Platz gegen ein bezahltes Redesign', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Umwandler', 2);
|
||||||
|
const vorher = await credits.getBalances(spieler);
|
||||||
|
|
||||||
|
const danach = await credits.convertCcToRedesign(spieler, ctx);
|
||||||
|
|
||||||
|
assert.equal(danach.balances.CC, vorher.CC - 1, 'ein Platz weniger');
|
||||||
|
assert.equal(danach.balances.REDESIGN_PAID, vorher.REDESIGN_PAID + 1, 'ein Redesign mehr');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
zwei.balances.REDESIGN_PAID,
|
danach.balances.granted.CC,
|
||||||
vorher.REDESIGN_PAID,
|
vorher.granted.CC,
|
||||||
'das eingetauschte Redesign wird sofort wieder verbraucht',
|
'gekauft wurde deswegen nicht weniger - der Platz ist verbraucht',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await verbrauchtAls(zwei.character.id),
|
danach.balances.granted.REDESIGN_PAID,
|
||||||
'REDESIGN_PAID',
|
|
||||||
'der Eintrag selbst ist ein Redesign wie jedes andere',
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
zwei.balances.granted.REDESIGN_PAID,
|
|
||||||
vorher.granted.REDESIGN_PAID + 1,
|
vorher.granted.REDESIGN_PAID + 1,
|
||||||
'und das umgewandelte Guthaben ist als erhalten ausgewiesen',
|
'und das eingetauschte Redesign ist erhalten worden',
|
||||||
);
|
);
|
||||||
assert.equal(zwei.balances.granted.CC, vorher.granted.CC, 'die Käufe bleiben, wie sie waren');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bleibt beim Ausrichten an den Ketten stehen', async () => {
|
it('setzt das umgewandelte Redesign ein wie jedes andere', async () => {
|
||||||
const spieler = await spielerMitPaketen('Bleibt so', 2);
|
const spieler = await spielerMitPaketen('Einsetzer', 2);
|
||||||
const eins = await characters.createCharacter(spieler, { name: 'Erstes' }, ctx);
|
const eins = await characters.createCharacter(spieler, { name: 'Erster' }, ctx);
|
||||||
|
|
||||||
|
// Beide Redesigns aus den Paketen erst verbrauchen.
|
||||||
const zwei = await characters.redesignCharacter(
|
const zwei = await characters.redesignCharacter(
|
||||||
eins.character.id,
|
eins.character.id,
|
||||||
{ name: 'Zweites', useKind: 'CC' },
|
{ name: 'Zweiter', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const drei = await characters.redesignCharacter(
|
||||||
|
zwei.character.id,
|
||||||
|
{ name: 'Dritter', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.equal((await credits.getBalances(spieler)).REDESIGN_PAID, 0);
|
||||||
|
|
||||||
|
await credits.convertCcToRedesign(spieler, ctx);
|
||||||
|
const vier = await characters.redesignCharacter(
|
||||||
|
drei.character.id,
|
||||||
|
{ name: 'Vierter', useKind: 'REDESIGN_PAID' },
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
const vorher = await credits.getBalances(spieler);
|
assert.equal(await verbrauchtAls(vier.character.id), 'REDESIGN_PAID');
|
||||||
const lauf = await characters.rebuildFunding(spieler, ctx);
|
|
||||||
|
|
||||||
assert.equal(lauf.umgebucht, 0, 'es passt bereits alles');
|
|
||||||
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_PAID');
|
|
||||||
assert.deepEqual(lauf.balances, vorher, 'und am Guthaben ändert sich nichts');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gibt den Tausch zurück, wenn der Eintrag wieder allein steht', async () => {
|
|
||||||
const spieler = await spielerMitPaketen('Rueckzieher', 2);
|
|
||||||
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
|
||||||
const zwei = await characters.redesignCharacter(
|
|
||||||
eins.character.id,
|
|
||||||
{ name: 'B', useKind: 'CC' },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
|
|
||||||
await characters.detachFromChain(zwei.character.id, ctx);
|
|
||||||
|
|
||||||
assert.equal(await verbrauchtAls(zwei.character.id), 'CC', 'allein steht er für sich');
|
|
||||||
const stand = await credits.getBalances(spieler);
|
const stand = await credits.getBalances(spieler);
|
||||||
assert.equal(stand.CC, 0, 'beide Plätze sind belegt');
|
assert.equal(stand.REDESIGN_PAID, 0, 'das umgewandelte ist aufgebraucht');
|
||||||
assert.equal(stand.REDESIGN_PAID, 2, 'die Redesigns sind wieder frei');
|
assert.equal(stand.CC, 0, 'und der Platz dafür weg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lässt sich auch nachträglich einsetzen', async () => {
|
it('lässt sich zurücknehmen wie jede andere Handeingabe', async () => {
|
||||||
const spieler = await spielerMitPaketen('Von Hand', 2);
|
const spieler = await spielerMitPaketen('Reumuetig', 1);
|
||||||
|
const vorher = await credits.getBalances(spieler);
|
||||||
|
|
||||||
|
await credits.convertCcToRedesign(spieler, ctx);
|
||||||
|
const danach = await credits.removeLastGrant(spieler, 'CONVERSION', ctx);
|
||||||
|
|
||||||
|
assert.equal(danach.balances.CC, vorher.CC, 'der Platz ist wieder da');
|
||||||
|
assert.equal(danach.balances.REDESIGN_PAID, vorher.REDESIGN_PAID, 'das Redesign wieder weg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt die Bezahlart eines Redesigns nicht auf den CC-Topf stellen', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Umsteller zwei', 1);
|
||||||
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
||||||
const zwei = await characters.redesignCharacter(
|
const zwei = await characters.redesignCharacter(
|
||||||
eins.character.id,
|
eins.character.id,
|
||||||
@@ -1140,16 +1149,16 @@ describe('Ein CC-Guthaben für ein Redesign einsetzen', () => {
|
|||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
const danach = await characters.changeFunding(zwei.character.id, 'CC', ctx);
|
await assert.rejects(
|
||||||
|
() => characters.changeFunding(zwei.character.id, 'CC', ctx),
|
||||||
|
(error: Error & { code?: string }) => error.code === 'CONVERT_FIRST',
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_PAID');
|
// Zwischen bezahlt und Kulanz bleibt die Wahl.
|
||||||
assert.equal(danach.balances.CC, 0, 'der Platz ist eingetauscht');
|
await credits.bookKulanz(spieler, ctx);
|
||||||
assert.equal(danach.balances.REDESIGN_PAID, 2, 'das Redesign dafür wieder frei');
|
const umgestellt = await characters.changeFunding(zwei.character.id, 'REDESIGN_KULANZ', ctx);
|
||||||
|
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_KULANZ');
|
||||||
// Und zurück: der Tausch wird rückgängig gemacht.
|
assert.equal(umgestellt.balances.REDESIGN_KULANZ, 0, 'die Kulanz ist verbraucht');
|
||||||
const zurueck = await characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx);
|
|
||||||
assert.equal(zurueck.balances.CC, 1, 'der Platz kommt zurück');
|
|
||||||
assert.equal(zurueck.balances.REDESIGN_PAID, 1, 'und das Redesign wird wieder verbraucht');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+114
-28
@@ -66,6 +66,8 @@ export default function PlayerDetail() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [flash, setFlash] = useState<string | null>(null);
|
const [flash, setFlash] = useState<string | null>(null);
|
||||||
|
/// Welcher Topf in der Historie gezeigt wird; null heisst alle.
|
||||||
|
const [topf, setTopf] = useState<CreditKind | null>(null);
|
||||||
const [dialog, setDialog] = useState<DialogState>({ type: 'none' });
|
const [dialog, setDialog] = useState<DialogState>({ type: 'none' });
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -198,6 +200,19 @@ export default function PlayerDetail() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const umwandeln = async () => {
|
||||||
|
const ja = await bestaetigen({
|
||||||
|
titel: 'Ein CC-Guthaben in ein Redesign umwandeln?',
|
||||||
|
text: 'Das kostet einen CC-Platz und bringt ein bezahltes Redesign. Einsetzen lässt es sich danach wie jedes andere Redesign.',
|
||||||
|
knopf: 'Umwandeln',
|
||||||
|
});
|
||||||
|
if (!ja) return;
|
||||||
|
await run(
|
||||||
|
() => api(`/api/players/${playerId}/credits/convert`, { method: 'POST', body: {} }),
|
||||||
|
'CC-Guthaben in ein Redesign umgewandelt.',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung
|
// Der Abgleich bucht nichts, er legt eine Vorschau an. Das muss die Meldung
|
||||||
// auch sagen, sonst wirkt der Knopf wirkungslos. Gelesen werden alle Konten
|
// auch sagen, sonst wirkt der Knopf wirkungslos. Gelesen werden alle Konten
|
||||||
// des Spielers, deshalb kann auch ein einzelner Lauf dauern.
|
// des Spielers, deshalb kann auch ein einzelner Lauf dauern.
|
||||||
@@ -222,6 +237,8 @@ export default function PlayerDetail() {
|
|||||||
|
|
||||||
const editor = can('EDITOR');
|
const editor = can('EDITOR');
|
||||||
const admin = can('ADMIN');
|
const admin = can('ADMIN');
|
||||||
|
const gezeigteBuchungen =
|
||||||
|
topf === null ? detail.ledger : detail.ledger.filter((entry) => entry.kind === topf);
|
||||||
|
|
||||||
const playerName = detail.player.displayName;
|
const playerName = detail.player.displayName;
|
||||||
|
|
||||||
@@ -418,6 +435,30 @@ export default function PlayerDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein Guthaben
|
||||||
|
dafür einsetzen will, tauscht es hier - danach ist es ein
|
||||||
|
Redesign wie jedes andere. */}
|
||||||
|
<span className="credit-pair">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
disabled={busy}
|
||||||
|
title="Ein CC-Guthaben in ein bezahltes Redesign tauschen: ein Platz weniger, ein Redesign mehr"
|
||||||
|
onClick={() => void umwandeln()}
|
||||||
|
>
|
||||||
|
CC → Redesign
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
disabled={busy}
|
||||||
|
title="Die zuletzt vorgenommene Umwandlung zurücknehmen"
|
||||||
|
onClick={() => void zugabeZuruecknehmen('convert', 'umgewandelte CC-Guthaben')}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
@@ -505,6 +546,27 @@ export default function PlayerDetail() {
|
|||||||
|
|
||||||
<section className="block">
|
<section className="block">
|
||||||
<h2>Guthaben-Historie</h2>
|
<h2>Guthaben-Historie</h2>
|
||||||
|
{/* Wer wissen will, wo eine einzelne Kulanz geblieben ist, soll nicht
|
||||||
|
durch hundert Zeilen scrollen müssen. */}
|
||||||
|
<div className="filter-row">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
[null, 'Alle Töpfe'],
|
||||||
|
['CC', KIND_LABEL.CC],
|
||||||
|
['REDESIGN_PAID', KIND_LABEL.REDESIGN_PAID],
|
||||||
|
['REDESIGN_KULANZ', KIND_LABEL.REDESIGN_KULANZ],
|
||||||
|
] as const
|
||||||
|
).map(([wert, label]) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-tiny ${topf === wert ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
onClick={() => setTopf(wert)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -519,7 +581,7 @@ export default function PlayerDetail() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{detail.ledger.map((entry) => (
|
{gezeigteBuchungen.map((entry) => (
|
||||||
<tr key={entry.id} className={entry.reversedBy ? 'is-reversed' : undefined}>
|
<tr key={entry.id} className={entry.reversedBy ? 'is-reversed' : undefined}>
|
||||||
<td
|
<td
|
||||||
className="muted small"
|
className="muted small"
|
||||||
@@ -558,10 +620,12 @@ export default function PlayerDetail() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{detail.ledger.length === 0 && (
|
{gezeigteBuchungen.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="empty">
|
<td colSpan={7} className="empty">
|
||||||
Noch keine Buchungen.
|
{topf === null
|
||||||
|
? 'Noch keine Buchungen.'
|
||||||
|
: `In diesem Topf gibt es keine Buchung.`}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -570,7 +634,7 @@ export default function PlayerDetail() {
|
|||||||
</div>
|
</div>
|
||||||
{detail.ledgerTotal > detail.ledger.length && (
|
{detail.ledgerTotal > detail.ledger.length && (
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{detail.ledger.length} von {detail.ledgerTotal} Buchungen angezeigt.
|
{detail.ledger.length} von {detail.ledgerTotal} Buchungen geladen.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -601,7 +665,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_PAID', 'REDESIGN_KULANZ', 'CC']}
|
withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ']}
|
||||||
balances={detail.balances}
|
balances={detail.balances}
|
||||||
onClose={() => setDialog({ type: 'none' })}
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
onSubmit={(values) =>
|
onSubmit={(values) =>
|
||||||
@@ -643,7 +707,7 @@ export default function PlayerDetail() {
|
|||||||
() =>
|
() =>
|
||||||
api(`/api/characters/${dialog.character.id}/funding`, {
|
api(`/api/characters/${dialog.character.id}/funding`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { useKind },
|
body: { useKind, forPlayerId: playerId },
|
||||||
}),
|
}),
|
||||||
`Bezahlart von "${dialog.character.name}" umgebucht.`,
|
`Bezahlart von "${dialog.character.name}" umgebucht.`,
|
||||||
)
|
)
|
||||||
@@ -666,7 +730,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_PAID', 'REDESIGN_KULANZ', 'CC']}
|
withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ']}
|
||||||
balances={detail.balances}
|
balances={detail.balances}
|
||||||
onClose={() => setDialog({ type: 'none' })}
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
onSubmit={(values) =>
|
onSubmit={(values) =>
|
||||||
@@ -724,8 +788,16 @@ export default function PlayerDetail() {
|
|||||||
{dialog.type === 'reactivatePaid' && (
|
{dialog.type === 'reactivatePaid' && (
|
||||||
<KindChoiceModal
|
<KindChoiceModal
|
||||||
title={`"${dialog.character.name}" reaktivieren`}
|
title={`"${dialog.character.name}" reaktivieren`}
|
||||||
hint="Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Reaktivierung kostet ein Guthaben."
|
hint={
|
||||||
kinds={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
|
dialog.character.predecessorId === null
|
||||||
|
? 'Dieser CC steht am Anfang einer Liste - seine Rückkehr kostet einen eigenen Platz.'
|
||||||
|
: 'Dieser CC wurde redesignt und existiert im Original nicht mehr. Die Rückkehr kostet ein Redesign.'
|
||||||
|
}
|
||||||
|
kinds={
|
||||||
|
dialog.character.predecessorId === null
|
||||||
|
? ['CC', 'REDESIGN_PAID', 'REDESIGN_KULANZ']
|
||||||
|
: ['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']
|
||||||
|
}
|
||||||
balances={detail.balances}
|
balances={detail.balances}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onClose={() => setDialog({ type: 'none' })}
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
@@ -1069,12 +1141,6 @@ function CharacterFormModal({
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{useKind === 'CC' && (
|
|
||||||
<p className="muted small">
|
|
||||||
Ein CC-Guthaben für ein Redesign wird umgewandelt: ein Platz weniger, ein
|
|
||||||
bezahltes Redesign mehr.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1688,7 +1754,11 @@ function ReviveDeadModal({
|
|||||||
}) {
|
}) {
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
const [kostet, setKostet] = useState(false);
|
const [kostet, setKostet] = useState(false);
|
||||||
const [useKind, setUseKind] = useState<CreditKind>('CC');
|
// Am Anfang einer Liste kostet die Rückkehr einen Platz, darüber ein
|
||||||
|
// Redesign - das ergibt sich aus der Stelle, nicht aus der Wahl.
|
||||||
|
const [useKind, setUseKind] = useState<CreditKind>(
|
||||||
|
character.predecessorId === null ? 'CC' : 'REDESIGN_PAID',
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title={`"${character.name}" zurückholen`} onClose={onClose}>
|
<Modal title={`"${character.name}" zurückholen`} onClose={onClose}>
|
||||||
@@ -1730,12 +1800,17 @@ function ReviveDeadModal({
|
|||||||
|
|
||||||
{kostet && (
|
{kostet && (
|
||||||
<div className="radio-group">
|
<div className="radio-group">
|
||||||
{(
|
{(character.predecessorId === null
|
||||||
[
|
? ([
|
||||||
['CC', 'CC-Guthaben', balances.CC],
|
['CC', 'CC-Guthaben', balances.CC],
|
||||||
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
|
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
|
||||||
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
|
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
|
||||||
] as const
|
] as const)
|
||||||
|
: ([
|
||||||
|
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
|
||||||
|
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
|
||||||
|
['CC', 'CC-Guthaben', balances.CC],
|
||||||
|
] as const)
|
||||||
).map(([wert, label, stand]) => (
|
).map(([wert, label, stand]) => (
|
||||||
<label key={wert} className="radio">
|
<label key={wert} className="radio">
|
||||||
<input
|
<input
|
||||||
@@ -1878,22 +1953,33 @@ function RedesignKindModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (useKind: CreditKind) => void;
|
onSubmit: (useKind: CreditKind) => void;
|
||||||
}) {
|
}) {
|
||||||
const [useKind, setUseKind] = useState<CreditKind>('REDESIGN_KULANZ');
|
const [useKind, setUseKind] = useState<CreditKind>('REDESIGN_PAID');
|
||||||
|
|
||||||
|
// Am Anfang einer Liste bucht ein Eintrag einen Platz; das ergibt sich aus
|
||||||
|
// der Stelle und steht nicht zur Wahl. Zu entscheiden bleibt, ob ein
|
||||||
|
// Redesign bezahlt wurde oder auf Kulanz lief.
|
||||||
|
const amAnfang = character.predecessorId === null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title={`Bezahlart von "${character.name}"`} onClose={onClose}>
|
<Modal title={`Bezahlart von "${character.name}"`} onClose={onClose}>
|
||||||
<Notice kind="info">
|
<Notice kind="info">
|
||||||
Woraus ein CC bezahlt wurde, stellt sich manchmal erst später heraus - etwa wenn eine
|
Woraus ein Redesign bezahlt wurde, stellt sich manchmal erst später heraus - etwa wenn
|
||||||
Absprache auftaucht oder sich zeigt, dass ein Eintrag gar nicht der Anfang einer Kette
|
eine Absprache auftaucht. Umgebucht wird über eine Gegenbuchung; in der Historie bleibt
|
||||||
war. Umgebucht wird über eine Gegenbuchung; in der Historie bleibt beides sichtbar.
|
beides sichtbar.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
|
{amAnfang && (
|
||||||
|
<Notice kind="warn">
|
||||||
|
Dieser Eintrag steht am Anfang einer Liste und bucht damit einen eigenen Platz aus dem
|
||||||
|
CC-Guthaben. Erst über einem anderen Eintrag wird daraus ein Redesign.
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="radio-group">
|
<div className="radio-group">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
['CC', 'CC-Paket (eigener Platz)', balances.CC],
|
|
||||||
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
|
|
||||||
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
|
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
|
||||||
|
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
|
||||||
] as const
|
] as const
|
||||||
).map(([wert, label, stand]) => (
|
).map(([wert, label, stand]) => (
|
||||||
<label key={wert} className="radio">
|
<label key={wert} className="radio">
|
||||||
|
|||||||
@@ -1327,3 +1327,11 @@ textarea {
|
|||||||
border-color: var(--warn);
|
border-color: var(--warn);
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Knopfreihe über einer Tabelle, etwa zum Filtern der Historie. */
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user